@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
package/dist/ast.d.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The abstract syntax tree for `.spec` files, mirroring GRAMMAR.md one node
3
+ * per production. The tree is deliberately flat and operator-free: statements
4
+ * are linear, arguments are literals/references/words, and the only nesting
5
+ * is blocks and object literals.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { Span } from "./source.js";
11
+ /** A parsed `.spec` file: its `use` imports, definitions, and tests. */
12
+ export interface SpecFileNode {
13
+ /** Path the file was loaded from, used in every diagnostic. */
14
+ path: string;
15
+ /** Namespaces imported unqualified into this file (file-scoped). */
16
+ uses: UseNode[];
17
+ /** Suite-global command and fixture definitions declared here. */
18
+ definitions: DefinitionNode[];
19
+ /** Executable tests declared here, in source order. */
20
+ tests: TestNode[];
21
+ }
22
+ /** `use fs` — imports one namespace's tools as unqualified names. */
23
+ export interface UseNode {
24
+ /** The imported namespace, e.g. `"fs"`. */
25
+ namespace: string;
26
+ /** Location of the whole statement. */
27
+ span: Span;
28
+ }
29
+ /** A suite-global definition: a reusable command or fixture. */
30
+ export type DefinitionNode = CommandNode | FixtureNode;
31
+ /** `command login(user) { … }` — reusable behavior composed of statements. */
32
+ export interface CommandNode {
33
+ kind: "command";
34
+ /** The command's suite-global name. */
35
+ name: string;
36
+ /** Positional parameter names; empty for `command logout { … }`. */
37
+ params: string[];
38
+ /** The statements the command executes. */
39
+ body: BlockNode;
40
+ /** Location of the whole definition. */
41
+ span: Span;
42
+ }
43
+ /** `fixture user { … }` — reusable setup that yields a value via `return`. */
44
+ export interface FixtureNode {
45
+ kind: "fixture";
46
+ /** The fixture's suite-global name. */
47
+ name: string;
48
+ /** The statements the fixture executes. */
49
+ body: BlockNode;
50
+ /** Location of the whole definition. */
51
+ span: Span;
52
+ }
53
+ /** `test "title" { given {…} when {…} then {…} }` — one specification. */
54
+ export interface TestNode {
55
+ /** The test's human-readable title. */
56
+ title: string;
57
+ /** Setup phase, when present. */
58
+ given?: BlockNode;
59
+ /** Action phase, when present. */
60
+ when?: BlockNode;
61
+ /** Verification phase, when present. */
62
+ then?: BlockNode;
63
+ /** Location of the whole test. */
64
+ span: Span;
65
+ }
66
+ /** A `{ … }` sequence of statements. */
67
+ export interface BlockNode {
68
+ /** The statements, in source order. */
69
+ statements: StatementNode[];
70
+ /** Location including the braces. */
71
+ span: Span;
72
+ }
73
+ /** Every statement the language has, forming a closed, exhaustive set. */
74
+ export type StatementNode = LetNode | ReturnNode | ExpectNode | EventuallyNode | CallNode;
75
+ /** `let name = <rhs>` — binds a value in the enclosing test/body scope. */
76
+ export interface LetNode {
77
+ kind: "let";
78
+ /** The name being bound. */
79
+ name: string;
80
+ /** What to evaluate: an expression or a value-producing invocation. */
81
+ value: RhsNode;
82
+ span: Span;
83
+ }
84
+ /** `return <rhs>` — ends a fixture/command body, producing a value. */
85
+ export interface ReturnNode {
86
+ kind: "return";
87
+ /** What to evaluate and yield to the caller. */
88
+ value: RhsNode;
89
+ span: Span;
90
+ }
91
+ /**
92
+ * The right-hand side of `let`/`return`: a plain expression, a fixture
93
+ * invocation, or a call expression. Calls are only legal here — never nested
94
+ * inside arguments — which keeps statements linear.
95
+ */
96
+ export type RhsNode = ExpressionNode | FixtureCallNode | CallExprNode;
97
+ /** `fixture user` in expression position — runs the fixture for its value. */
98
+ export interface FixtureCallNode {
99
+ kind: "fixture-call";
100
+ name: string;
101
+ span: Span;
102
+ }
103
+ /** A value-producing invocation: `run "node" "index.js"` on a `let`/`return`. */
104
+ export interface CallExprNode {
105
+ kind: "call-expr";
106
+ /** Dotted target as written: `"run"` or `"http.post"`. */
107
+ target: string;
108
+ /** The invocation's arguments, in order. */
109
+ args: ArgumentNode[];
110
+ span: Span;
111
+ }
112
+ /** `expect <argument>+` — value or observable assertion (see GRAMMAR.md). */
113
+ export interface ExpectNode {
114
+ kind: "expect";
115
+ /** The assertion's arguments; resolution decides the form at runtime. */
116
+ args: ArgumentNode[];
117
+ span: Span;
118
+ }
119
+ /** `eventually [within 10s] { … }` — retried assertions, `then`-only. */
120
+ export interface EventuallyNode {
121
+ kind: "eventually";
122
+ /** Deadline override in milliseconds, from `within <duration>`. */
123
+ withinMs?: number;
124
+ /** The assertions to retry as a unit. */
125
+ block: BlockNode;
126
+ span: Span;
127
+ }
128
+ /** A statement-position invocation: `login user`, `open post.url`. */
129
+ export interface CallNode {
130
+ kind: "call";
131
+ /** Dotted target as written: `"login"`, `"fs.write"`. */
132
+ target: string;
133
+ /** The invocation's arguments, in order. */
134
+ args: ArgumentNode[];
135
+ span: Span;
136
+ }
137
+ /** One argument: an expression, or a bare-identifier word. */
138
+ export type ArgumentNode = ExpressionNode | WordNode;
139
+ /** A bare identifier in argument position — a symbol for the tool. */
140
+ export interface WordNode {
141
+ kind: "word";
142
+ /** The identifier as written, e.g. `"exists"`, `"textbox"`, `"with"`. */
143
+ word: string;
144
+ span: Span;
145
+ }
146
+ /** Every expression form; references are dotted paths into bindings. */
147
+ export type ExpressionNode = StringNode | NumberNode | BooleanNode | DurationNode | ObjectNode | ReferenceNode;
148
+ /** A single-line or multiline string literal, already decoded/dedented. */
149
+ export interface StringNode {
150
+ kind: "string";
151
+ /** The decoded content. */
152
+ value: string;
153
+ span: Span;
154
+ }
155
+ /** A numeric literal. */
156
+ export interface NumberNode {
157
+ kind: "number";
158
+ value: number;
159
+ span: Span;
160
+ }
161
+ /** `true` or `false`. */
162
+ export interface BooleanNode {
163
+ kind: "boolean";
164
+ value: boolean;
165
+ span: Span;
166
+ }
167
+ /** A duration literal like `10s`, normalized to milliseconds at lex time. */
168
+ export interface DurationNode {
169
+ kind: "duration";
170
+ milliseconds: number;
171
+ span: Span;
172
+ }
173
+ /** `{ key: expr, … }` — an object literal. */
174
+ export interface ObjectNode {
175
+ kind: "object";
176
+ /** Entries in source order; later duplicate keys are a parse error. */
177
+ entries: ObjectEntryNode[];
178
+ span: Span;
179
+ }
180
+ /** One `key: value` entry of an object literal. */
181
+ export interface ObjectEntryNode {
182
+ /** The key, from an identifier or string. */
183
+ key: string;
184
+ value: ExpressionNode;
185
+ span: Span;
186
+ }
187
+ /** A dotted reference into bindings: `user`, `result.exit_code`. */
188
+ export interface ReferenceNode {
189
+ kind: "reference";
190
+ /** The path segments, e.g. `["result", "exit_code"]`. */
191
+ path: string[];
192
+ span: Span;
193
+ }
package/dist/ast.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The abstract syntax tree for `.spec` files, mirroring GRAMMAR.md one node
3
+ * per production. The tree is deliberately flat and operator-free: statements
4
+ * are linear, arguments are literals/references/words, and the only nesting
5
+ * is blocks and object literals.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The built-in capability set, and how a host chooses part of it.
3
+ *
4
+ * Kept apart from the runner because importing a plugin is not free: `cli` and
5
+ * `browser` spawn processes and `db` imports Bun's SQL client. `runTests` takes
6
+ * a plugin set so a host can assemble the factories it is able to load.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import type { Plugin } from "./plugin.js";
12
+ /**
13
+ * Every built-in namespace, in the order a run registers them.
14
+ *
15
+ * The order is what a selection is sorted back into, so two hosts asking for the
16
+ * same namespaces get the same list regardless of how they wrote it down.
17
+ */
18
+ export declare const BUILTIN_NAMESPACES: readonly ["fs", "cli", "http", "browser", "db", "url", "jwt", "env", "sample"];
19
+ /** A built-in namespace's name. */
20
+ export type BuiltinNamespace = (typeof BUILTIN_NAMESPACES)[number];
21
+ /**
22
+ * Build the built-in plugins, all of them or a chosen few. Choosing a subset
23
+ * is not a permission decision: a namespace left out simply does not exist,
24
+ * so a spec naming it fails to resolve, on the same footing as a capability.
25
+ *
26
+ * @param only - Namespaces to build; omit for every built-in. Duplicates collapse.
27
+ * @returns The plugins, in {@link BUILTIN_NAMESPACES} order.
28
+ */
29
+ export declare function createBuiltinPlugins(only?: readonly BuiltinNamespace[]): Plugin[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The built-in capability set, and how a host chooses part of it.
3
+ *
4
+ * Kept apart from the runner because importing a plugin is not free: `cli` and
5
+ * `browser` spawn processes and `db` imports Bun's SQL client. `runTests` takes
6
+ * a plugin set so a host can assemble the factories it is able to load.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import { createBrowserPlugin } from "./plugins/browser.js";
12
+ import { createCliPlugin } from "./plugins/cli.js";
13
+ import { createDbPlugin } from "./plugins/db.js";
14
+ import { createEnvPlugin } from "./plugins/env.js";
15
+ import { createFsPlugin } from "./plugins/fs.js";
16
+ import { createHttpPlugin } from "./plugins/http.js";
17
+ import { createJwtPlugin } from "./plugins/jwt.js";
18
+ import { createSamplePlugin } from "./plugins/sample.js";
19
+ import { createUrlPlugin } from "./plugins/url.js";
20
+ /**
21
+ * Every built-in namespace, in the order a run registers them.
22
+ *
23
+ * The order is what a selection is sorted back into, so two hosts asking for the
24
+ * same namespaces get the same list regardless of how they wrote it down.
25
+ */
26
+ export const BUILTIN_NAMESPACES = [
27
+ "fs",
28
+ "cli",
29
+ "http",
30
+ "browser",
31
+ "db",
32
+ "url",
33
+ "jwt",
34
+ "env",
35
+ "sample",
36
+ ];
37
+ /** How each built-in namespace is constructed. */
38
+ const BUILTIN_FACTORIES = {
39
+ fs: createFsPlugin,
40
+ cli: createCliPlugin,
41
+ http: createHttpPlugin,
42
+ browser: createBrowserPlugin,
43
+ db: createDbPlugin,
44
+ url: createUrlPlugin,
45
+ jwt: createJwtPlugin,
46
+ env: createEnvPlugin,
47
+ sample: createSamplePlugin,
48
+ };
49
+ /**
50
+ * Build the built-in plugins, all of them or a chosen few. Choosing a subset
51
+ * is not a permission decision: a namespace left out simply does not exist,
52
+ * so a spec naming it fails to resolve, on the same footing as a capability.
53
+ *
54
+ * @param only - Namespaces to build; omit for every built-in. Duplicates collapse.
55
+ * @returns The plugins, in {@link BUILTIN_NAMESPACES} order.
56
+ */
57
+ export function createBuiltinPlugins(only) {
58
+ let wanted = new Set(only ?? BUILTIN_NAMESPACES);
59
+ let plugins = [];
60
+ for (let namespace of BUILTIN_NAMESPACES) {
61
+ if (!wanted.has(namespace))
62
+ continue;
63
+ plugins.push(BUILTIN_FACTORIES[namespace]());
64
+ }
65
+ return plugins;
66
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The `spec` command-line interface. `spec run [dir] [--allow-*]` loads a
4
+ * suite and executes it under the caller's grants — nothing is granted by
5
+ * default, and a permission failure is a security feature, not a bug.
6
+ * Exit codes: 0 all passed, 1 some test failed, 2 usage or load error.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import type { Sink } from "./diagnostics.js";
12
+ /**
13
+ * Run the CLI against an argument vector and write through the sink —
14
+ * separated from the entry point so tests can drive it without a process.
15
+ * Config grants apply only with `--allow-config`, so a clone cannot self-grant.
16
+ *
17
+ * @param argv - Arguments after the program name, e.g. `["run", "spec"]`.
18
+ * @param sink - Where human output goes.
19
+ * @returns The process exit code to use.
20
+ */
21
+ export declare function main(argv: string[], sink: Sink): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The `spec` command-line interface. `spec run [dir] [--allow-*]` loads a
4
+ * suite and executes it under the caller's grants — nothing is granted by
5
+ * default, and a permission failure is a security feature, not a bug.
6
+ * Exit codes: 0 all passed, 1 some test failed, 2 usage or load error.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import { readFile } from "node:fs/promises";
12
+ import { failure, isFailure, success } from "@sdxc/result";
13
+ import { systemSeed } from "@sdxc/sample";
14
+ import { SpecError } from "./errors.js";
15
+ import { loadSuite } from "./loader.js";
16
+ import { configWouldAdmit, grantsFromConfig, mergeGrants, parseGrants } from "./permissions.js";
17
+ import { connectDeclaredPlugins, deniedReferences, disposeAll, launchDeniedError, loadProjectConfig, mergePluginGrants, parsePluginGrant, planPluginLaunch, pluginGrantAdmits, pluginGrantFromConfig, } from "./project-config.js";
18
+ import { reportFatal, reportSuite } from "./reporter.js";
19
+ import { DEFAULT_SEED } from "./run.js";
20
+ import { runSuite } from "./runner.js";
21
+ /**
22
+ * The line appended to a permission denial when the project's
23
+ * `spec/config.jsonc` would have granted it under `--allow-config`. Points at
24
+ * the one-flag path without ever weakening the primary `--allow-*` remedy.
25
+ */
26
+ const CONFIG_HINT = "This project's spec/config.jsonc declares this permission; re-run with --allow-config to apply the project's declared permissions.";
27
+ /** What `spec --help` prints. */
28
+ const USAGE = `spec — executable specifications
29
+
30
+ Usage:
31
+ spec run [directory] [--allow-*] Run the suite (directory defaults to ./spec)
32
+
33
+ Scheduling:
34
+ --concurrency=N (alias --jobs=N) Run up to N tests at once (default 1, sequential)
35
+
36
+ Generated data:
37
+ --seed=VALUE Seed the data sample generates (default: fixed)
38
+ --seed=random Draw a seed and print it, to replay with --seed=<it>
39
+
40
+ Permissions (denied unless granted):
41
+ --allow-run[=name,...] Execute processes (scoped to executable names)
42
+ --allow-net[=host[:port]] Reach the network (scoped to hosts)
43
+ --allow-env[=VAR,...] Read environment variables (scoped to names)
44
+ --allow-host-fs[=dir,...] Touch the host filesystem outside the workspace
45
+ --allow-plugins[=ns,...] Launch project-declared plugins (from spec/config.jsonc)
46
+ --allow-config Apply the permissions spec/config.jsonc declares
47
+ `;
48
+ /**
49
+ * Run the CLI against an argument vector and write through the sink —
50
+ * separated from the entry point so tests can drive it without a process.
51
+ * Config grants apply only with `--allow-config`, so a clone cannot self-grant.
52
+ *
53
+ * @param argv - Arguments after the program name, e.g. `["run", "spec"]`.
54
+ * @param sink - Where human output goes.
55
+ * @returns The process exit code to use.
56
+ */
57
+ export async function main(argv, sink) {
58
+ if (argv[0] === "--help" || argv[0] === "-h" || argv.length === 0) {
59
+ sink.write(USAGE);
60
+ return argv.length === 0 ? 2 : 0;
61
+ }
62
+ if (argv[0] !== "run") {
63
+ reportFatal(new SpecError("usage-error", `Unknown command: ${argv[0]}`), sink);
64
+ sink.write(USAGE);
65
+ return 2;
66
+ }
67
+ let configOptIn = parseConfigOptIn(argv.slice(1));
68
+ if (isFailure(configOptIn)) {
69
+ reportFatal(configOptIn.error, sink);
70
+ return 2;
71
+ }
72
+ let { allowConfig, remaining: afterConfigOptIn } = configOptIn.data;
73
+ let concurrencyParsed = parseConcurrency(afterConfigOptIn);
74
+ if (isFailure(concurrencyParsed)) {
75
+ reportFatal(concurrencyParsed.error, sink);
76
+ return 2;
77
+ }
78
+ let { concurrency, remaining: afterConcurrency } = concurrencyParsed.data;
79
+ let seedParsed = parseSeed(afterConcurrency);
80
+ if (isFailure(seedParsed)) {
81
+ reportFatal(seedParsed.error, sink);
82
+ return 2;
83
+ }
84
+ let { seed, drawn, remaining: afterSeed } = seedParsed.data;
85
+ let pluginParsed = parsePluginGrant(afterSeed);
86
+ if (isFailure(pluginParsed)) {
87
+ reportFatal(pluginParsed.error, sink);
88
+ return 2;
89
+ }
90
+ let { grant: cliPluginGrant, remaining: afterPluginGrant } = pluginParsed.data;
91
+ let parsed = parseGrants(afterPluginGrant);
92
+ if (isFailure(parsed)) {
93
+ reportFatal(parsed.error, sink);
94
+ return 2;
95
+ }
96
+ let { grants: cliGrants, remaining } = parsed.data;
97
+ let unknown = remaining.filter((argument) => argument.startsWith("-"));
98
+ if (unknown.length > 0) {
99
+ reportFatal(new SpecError("usage-error", `Unknown flag: ${unknown[0]}`), sink);
100
+ return 2;
101
+ }
102
+ if (remaining.length > 1) {
103
+ reportFatal(new SpecError("usage-error", `Expected one suite directory, got: ${remaining.join(", ")}`), sink);
104
+ return 2;
105
+ }
106
+ let root = remaining[0] ?? "spec";
107
+ let config = await loadProjectConfig(root);
108
+ if (isFailure(config)) {
109
+ reportFatal(config.error, sink);
110
+ return 2;
111
+ }
112
+ let configEntries = config.data.permissions.allow;
113
+ let configGrants = grantsFromConfig(configEntries);
114
+ let grants = allowConfig ? mergeGrants(cliGrants, configGrants) : cliGrants;
115
+ let configPluginGrant = pluginGrantFromConfig(configEntries);
116
+ let pluginGrant = allowConfig
117
+ ? mergePluginGrants(cliPluginGrant, configPluginGrant)
118
+ : cliPluginGrant;
119
+ let { launch, deniedNamespaces } = planPluginLaunch(config.data, pluginGrant);
120
+ if (deniedNamespaces.length > 0) {
121
+ let loaded = await loadSuite(root);
122
+ if (isFailure(loaded)) {
123
+ reportFatal(loaded.error, sink);
124
+ return 2;
125
+ }
126
+ let referenced = deniedReferences(loaded.data, deniedNamespaces);
127
+ if (referenced.length > 0) {
128
+ let error = launchDeniedError(referenced);
129
+ if (!allowConfig && referenced.every((ns) => pluginGrantAdmits(configPluginGrant, ns))) {
130
+ error.hint = CONFIG_HINT;
131
+ }
132
+ reportFatal(error, sink);
133
+ return 2;
134
+ }
135
+ }
136
+ let externalPlugins = [];
137
+ if (launch.length > 0) {
138
+ let connected = await connectDeclaredPlugins(launch);
139
+ if (isFailure(connected)) {
140
+ reportFatal(connected.error, sink);
141
+ return 2;
142
+ }
143
+ externalPlugins = connected.data;
144
+ }
145
+ if (drawn)
146
+ sink.write(`seed ${seed} (replay with --seed=${seed})\n\n`);
147
+ let run = await runSuite({ root, grants, plugins: externalPlugins, concurrency, seed });
148
+ if (isFailure(run)) {
149
+ await disposeAll(externalPlugins);
150
+ reportFatal(run.error, sink);
151
+ return 2;
152
+ }
153
+ if (!allowConfig) {
154
+ for (let result of run.data.results) {
155
+ let error = result.error;
156
+ if (error === undefined || error.code !== "permission-denied")
157
+ continue;
158
+ let denial = error;
159
+ if (denial.permission === undefined || denial.resource === undefined)
160
+ continue;
161
+ if (configWouldAdmit(configGrants, denial.permission, denial.resource, denial.familyGate ?? false))
162
+ error.hint = CONFIG_HINT;
163
+ }
164
+ }
165
+ let sources = new Map();
166
+ for (let result of run.data.results) {
167
+ let paths = [result.file];
168
+ if (result.error?.file !== undefined)
169
+ paths.push(result.error.file);
170
+ for (let path of paths) {
171
+ if (!sources.has(path)) {
172
+ let text = await readFile(path, "utf8").catch(() => "");
173
+ sources.set(path, { path, text });
174
+ }
175
+ }
176
+ }
177
+ reportSuite(run.data, sources, sink);
178
+ return run.data.failed > 0 ? 1 : 0;
179
+ }
180
+ /**
181
+ * Peel the bare `--allow-config` flag out of an argument list, opting into
182
+ * the permissions `spec/config.jsonc` declares; `--allow-config=…` is a usage
183
+ * error since the flag takes no value. Other arguments pass through untouched.
184
+ *
185
+ * @param args - The raw CLI arguments after `run`.
186
+ * @returns Whether the opt-in was given, plus the remaining arguments.
187
+ */
188
+ function parseConfigOptIn(args) {
189
+ let allowConfig = false;
190
+ let remaining = [];
191
+ for (let argument of args) {
192
+ if (argument === "--allow-config") {
193
+ allowConfig = true;
194
+ continue;
195
+ }
196
+ if (argument.startsWith("--allow-config=")) {
197
+ return failure(new SpecError("usage-error", "--allow-config takes no value; it is a bare flag that applies the permissions spec/config.jsonc declares."));
198
+ }
199
+ remaining.push(argument);
200
+ }
201
+ return success({ allowConfig, remaining });
202
+ }
203
+ /**
204
+ * Peel `--concurrency=N` (or its `--jobs=N` alias) out of an argument list; N
205
+ * sets how many tests run at once and defaults to 1. A missing, malformed, or
206
+ * non-positive value is a usage error; a repeated flag keeps the last value.
207
+ *
208
+ * @param args - The raw CLI arguments after the config opt-in was removed.
209
+ * @returns The chosen concurrency and the remaining arguments.
210
+ */
211
+ function parseConcurrency(args) {
212
+ let concurrency = 1;
213
+ let remaining = [];
214
+ for (let argument of args) {
215
+ let flag = matchConcurrencyFlag(argument);
216
+ if (flag === undefined) {
217
+ remaining.push(argument);
218
+ continue;
219
+ }
220
+ let value = parsePositiveInteger(flag.value);
221
+ if (value === undefined) {
222
+ return failure(new SpecError("usage-error", `${flag.name} expects a positive integer, e.g. ${flag.name}=8; got ${JSON.stringify(flag.value)}.`));
223
+ }
224
+ concurrency = value;
225
+ }
226
+ return success({ concurrency, remaining });
227
+ }
228
+ /**
229
+ * Peel `--seed=VALUE` out of an argument list. `--seed=random` draws one, which
230
+ * the caller prints so a run that turned up a bad value can be replayed;
231
+ * anything else is taken as written, since text and numbers both name a stream.
232
+ * Omitting the flag keeps the runner's fixed default, so a bare run repeats.
233
+ *
234
+ * @param argv - Arguments after the earlier flags were peeled off.
235
+ * @returns The seed, whether it was drawn, and the remaining arguments.
236
+ */
237
+ function parseSeed(argv) {
238
+ let remaining = [];
239
+ let seed = DEFAULT_SEED;
240
+ let drawn = false;
241
+ for (let argument of argv) {
242
+ if (argument !== "--seed" && !argument.startsWith("--seed=")) {
243
+ remaining.push(argument);
244
+ continue;
245
+ }
246
+ let value = argument === "--seed" ? "" : argument.slice("--seed=".length);
247
+ if (value === "") {
248
+ return failure(new SpecError("usage-error", "--seed expects a value, e.g. --seed=checkout or --seed=random to draw one."));
249
+ }
250
+ if (value === "random") {
251
+ seed = systemSeed();
252
+ drawn = true;
253
+ continue;
254
+ }
255
+ seed = /^\d+$/.test(value) ? Number(value) : value;
256
+ drawn = false;
257
+ }
258
+ return success({ seed, drawn, remaining });
259
+ }
260
+ /**
261
+ * Match a concurrency flag and split off its value, recognizing both
262
+ * `--concurrency` and `--jobs` in `--flag=value` form. The bare `--flag` form
263
+ * matches with an empty value so the caller reports the missing-value usage error.
264
+ *
265
+ * @param argument - One raw CLI argument.
266
+ * @returns The matched flag name and its value, or undefined for a non-match.
267
+ */
268
+ function matchConcurrencyFlag(argument) {
269
+ for (let name of ["--concurrency", "--jobs"]) {
270
+ if (argument === name)
271
+ return { name, value: "" };
272
+ if (argument.startsWith(`${name}=`))
273
+ return { name, value: argument.slice(name.length + 1) };
274
+ }
275
+ return undefined;
276
+ }
277
+ /**
278
+ * Parse a strictly positive integer written in decimal, rejecting everything
279
+ * else — empty strings, signs, decimals, whitespace, and non-numeric text — so
280
+ * the caller can turn a bad `--concurrency` value into a usage error.
281
+ *
282
+ * @param text - The flag's raw value.
283
+ * @returns The integer, or undefined when the text is not a positive integer.
284
+ */
285
+ function parsePositiveInteger(text) {
286
+ if (!/^\d+$/.test(text))
287
+ return undefined;
288
+ let value = Number(text);
289
+ if (!Number.isInteger(value) || value < 1)
290
+ return undefined;
291
+ return value;
292
+ }
293
+ if (import.meta.main) {
294
+ let sink = { write: (text) => void process.stdout.write(text) };
295
+ let code = await main(process.argv.slice(2), sink);
296
+ process.exit(code);
297
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Result shapes the runner produces and the reporter renders: per-test
3
+ * outcomes and the suite roll-up. Failures travel as structured `SpecError`s
4
+ * (spans, expected/observed, remedies), never as pre-rendered strings.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { SpecError } from "./errors.js";
10
+ /** How one test ended. */
11
+ export type TestStatus = "passed" | "failed";
12
+ /** The outcome of executing one test. */
13
+ export interface TestResult {
14
+ /** The test's title as written in the spec. */
15
+ title: string;
16
+ /** Path of the file the test lives in. */
17
+ file: string;
18
+ /** Whether every statement held. */
19
+ status: TestStatus;
20
+ /** The failure that ended the test, when it failed. */
21
+ error?: SpecError;
22
+ /** Wall-clock duration of the test in milliseconds. */
23
+ durationMs: number;
24
+ }
25
+ /** The outcome of one `spec run`. */
26
+ export interface SuiteResult {
27
+ /** Per-test outcomes in execution order. */
28
+ results: TestResult[];
29
+ /** Count of passed tests. */
30
+ passed: number;
31
+ /** Count of failed tests. */
32
+ failed: number;
33
+ /**
34
+ * Wall-clock duration of the whole run in milliseconds, from just before
35
+ * the first test starts to just after the last one finishes. Tracks real
36
+ * elapsed time at any concurrency, since concurrent {@link TestResult.durationMs} values overlap.
37
+ */
38
+ wallMs: number;
39
+ }
40
+ /**
41
+ * Where the reporter writes. The CLI passes stdout/stderr; tests pass a
42
+ * buffer. Product output goes through this sink, never through a logger.
43
+ */
44
+ export interface Sink {
45
+ /** Append text verbatim; the reporter controls its own newlines. */
46
+ write(text: string): void;
47
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Result shapes the runner produces and the reporter renders: per-test
3
+ * outcomes and the suite roll-up. Failures travel as structured `SpecError`s
4
+ * (spans, expected/observed, remedies), never as pre-rendered strings.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */