thunderjar 0.1.2 → 0.1.3-preview-eee7160

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/dist/cli.js +2161 -34
  2. package/package.json +13 -4
  3. package/src/AGENTS.md +156 -0
  4. package/src/adapters/docker/docker.ts +117 -0
  5. package/src/api/declarations.ts +279 -0
  6. package/src/api/fragments.ts +30 -0
  7. package/src/api/parameter-names.d.ts +16 -0
  8. package/src/cli/commands/build/build.docker.test.ts +173 -0
  9. package/src/cli/commands/build/build.test.ts +234 -0
  10. package/src/cli/commands/build/build.ts +115 -0
  11. package/src/cli/commands/declarations.typecheck.test.ts +173 -0
  12. package/src/cli/commands/executions/executions.test.ts +84 -0
  13. package/src/cli/commands/executions/executions.ts +31 -0
  14. package/src/cli/commands/format.ts +33 -0
  15. package/src/cli/commands/generate/generate.test.ts +101 -0
  16. package/src/cli/commands/generate/generate.ts +18 -0
  17. package/src/cli/commands/generate/parameter-names.ts +131 -0
  18. package/src/cli/commands/init/init.test.ts +123 -0
  19. package/src/cli/commands/init/init.ts +189 -0
  20. package/src/cli/commands/options.ts +17 -0
  21. package/src/cli/commands/plan/plan.test.ts +168 -0
  22. package/src/cli/commands/plan/plan.ts +72 -0
  23. package/src/cli/commands/remeasure/remeasure.test.ts +536 -0
  24. package/src/cli/commands/remeasure/remeasure.ts +194 -0
  25. package/src/cli/commands/run/run.test.ts +622 -0
  26. package/src/cli/commands/run/run.ts +203 -0
  27. package/src/cli/commands/show/show.test.ts +288 -0
  28. package/src/cli/commands/show/show.ts +252 -0
  29. package/src/cli/commands/validation.test.ts +200 -0
  30. package/src/cli/index.test.ts +47 -0
  31. package/src/cli/index.ts +9 -0
  32. package/src/cli/program.ts +29 -0
  33. package/src/images/prerun-builds.ts +108 -0
  34. package/src/images/prerun.test.ts +243 -0
  35. package/src/images/prerun.ts +272 -0
  36. package/src/images/repository.ts +72 -0
  37. package/src/index.ts +24 -0
  38. package/src/instruments/instruments.test.ts +240 -0
  39. package/src/instruments/instruments.ts +205 -0
  40. package/src/matrix/execution.ts +122 -0
  41. package/src/matrix/hashing.test.ts +298 -0
  42. package/src/matrix/hashing.ts +131 -0
  43. package/src/matrix/permutations.test.ts +58 -0
  44. package/src/matrix/permutations.ts +50 -0
  45. package/src/matrix/resolve.ts +63 -0
  46. package/src/matrix/tags.test.ts +15 -0
  47. package/src/matrix/tags.ts +22 -0
  48. package/src/matrix/ulid.test.ts +28 -0
  49. package/src/matrix/ulid.ts +22 -0
  50. package/src/measuring/aggregate.test.ts +188 -0
  51. package/src/measuring/aggregate.ts +154 -0
  52. package/src/measuring/measure.test.ts +105 -0
  53. package/src/measuring/measure.ts +62 -0
  54. package/src/measuring/remeasure-templates/v1/isPrime.test.template.txt +4 -0
  55. package/src/measuring/remeasure-templates/v2/isPrime.test.template.txt +5 -0
  56. package/src/measuring/remeasure.test.ts +352 -0
  57. package/src/measuring/remeasure.ts +113 -0
  58. package/src/platform/fs.test.ts +89 -0
  59. package/src/platform/fs.ts +123 -0
  60. package/src/platform/path.test.ts +38 -0
  61. package/src/platform/path.ts +72 -0
  62. package/src/platform/signals.test.ts +25 -0
  63. package/src/platform/signals.ts +68 -0
  64. package/src/project/config.test.ts +68 -0
  65. package/src/project/config.ts +297 -0
  66. package/src/running/runner.test.ts +73 -0
  67. package/src/running/runner.ts +252 -0
  68. package/src/store/run-data-store.test.ts +232 -0
  69. package/src/store/run-data-store.ts +254 -0
  70. package/src/testing/cli.ts +65 -0
  71. package/src/testing/fake-docker.test.ts +197 -0
  72. package/src/testing/fake-docker.ts +104 -0
  73. package/src/testing/fixture-project.ts +177 -0
  74. package/src/testing/measurement-context.ts +5 -0
  75. package/src/testing/seed-repo.test.ts +10 -0
  76. package/src/testing/seed-repo.ts +83 -0
  77. package/src/testing/test-setup.ts +10 -0
@@ -0,0 +1,173 @@
1
+ // spec/055-declaring-test-cases.md#typed-names: a declaration naming a parameter absent from the
2
+ // generated types, or whose config or aggregate doesn't fit its instrument, is a compile error,
3
+ // and one that fits compiles cleanly.
4
+ //
5
+ // In the checked-in fixtureTests/scenario_11_type_errors, in place: `thunderjar generate` writes
6
+ // its gitignored types, then real `tsc` checks the project with its own tsconfig, once. Each test
7
+ // asserts on what `tsc` reported for one declaration. By hand:
8
+ //
9
+ // bun run fixture 11 generate
10
+ // cd fixtureTests/scenario_11_type_errors/fixtures && bunx tsc -p .thunderjar
11
+
12
+ import { beforeAll, describe, expect, test } from "bun:test";
13
+ import { join, resolve } from "../../platform/path.ts";
14
+ import { scenario, thunderjar } from "../../testing/cli.ts";
15
+
16
+ const tsc = resolve(import.meta.dir, "..", "..", "..", "node_modules", ".bin", "tsc");
17
+ const fixtures = scenario("scenario_11_type_errors");
18
+
19
+ let exitCode: number;
20
+ /** Each diagnostic `tsc` printed, with its indented elaboration, keyed by its file. */
21
+ const reported = new Map<string, string[]>();
22
+
23
+ beforeAll(async () => {
24
+ const generated = await thunderjar(fixtures, ["generate"]);
25
+ expect(generated.exitCode).toBe(0);
26
+
27
+ const proc = Bun.spawn([tsc, "--noEmit", "-p", join(fixtures, ".thunderjar")], {
28
+ cwd: fixtures,
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ });
32
+ const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
33
+ exitCode = await proc.exited;
34
+ for (const diagnostic of (stdout + stderr).trimEnd().split(/\n(?=\S)/)) {
35
+ const file = diagnostic.slice(0, diagnostic.indexOf("("));
36
+ reported.set(file, [...(reported.get(file) ?? []), diagnostic.slice(file.length)]);
37
+ }
38
+ }, 30000);
39
+
40
+ /** What `tsc` reported in `.thunderjar/<file>`, each diagnostic without its file name. */
41
+ const errorsIn = (file: string) => reported.get(`.thunderjar/${file}`);
42
+
43
+ test("only the declarations that don't fit fail to compile", () => {
44
+ expect(exitCode).not.toBe(0);
45
+ expect([...reported.keys()].sort()).toEqual(
46
+ [
47
+ "test-cases/prompt-of-another-task.ts",
48
+ "test-cases/unknown-model.ts",
49
+ "test-cases/unknown-prompt-set.ts",
50
+ "test-case-parameters/codeStates/reads-missing-config/index.ts",
51
+ "test-case-parameters/codeStates/top-level-commit/index.ts",
52
+ ...[
53
+ "fileCreatedByMean",
54
+ "grepByRate",
55
+ "grepWithoutPattern",
56
+ "misfitConfig",
57
+ "templateTestByMax",
58
+ "templateTestWithoutTemplate",
59
+ "unknownInstrument",
60
+ "withoutAggregate",
61
+ ].map((name) => `test-case-parameters/tasks/badly-measured/measurements/${name}.ts`),
62
+ ]
63
+ .map((file) => `.thunderjar/${file}`)
64
+ .sort(),
65
+ );
66
+ });
67
+
68
+ describe("a test case", () => {
69
+ test("naming only generated names compiles cleanly", () => {
70
+ expect(errorsIn("test-cases/sound.ts")).toBeUndefined();
71
+ });
72
+
73
+ test("naming an unknown prompt set fails to compile", () => {
74
+ expect(errorsIn("test-cases/unknown-prompt-set.ts")).toEqual([
75
+ `(6,15): error TS2322: Type '"snirk"' is not assignable to type '"snerk"'.`,
76
+ ]);
77
+ });
78
+
79
+ test("naming a model absent from its family fails to compile", () => {
80
+ expect(errorsIn("test-cases/unknown-model.ts")).toEqual([
81
+ `(7,18): error TS2322: Type '"fake-harness-alpha@1.0.0/fake-model-huge"' is not assignable to type '"fake-harness-alpha@1.0.0/fake-model-small"'.`,
82
+ ]);
83
+ });
84
+
85
+ test("naming an initial prompt that belongs to a different task fails to compile", () => {
86
+ expect(errorsIn("test-cases/prompt-of-another-task.ts")).toEqual([
87
+ `(9,19): error TS2322: Type '"fizzy"' is not assignable to type '"plain"'.`,
88
+ ]);
89
+ });
90
+ });
91
+
92
+ describe("a measurement", () => {
93
+ const measurement = (task: string, name: string) => errorsIn(`test-case-parameters/tasks/${task}/measurements/${name}.ts`);
94
+ const bad = (name: string) => measurement("badly-measured", name);
95
+
96
+ test("whose config and aggregate fit fileCreated, grep or templateTest compiles cleanly", () => {
97
+ expect(measurement("is-prime", "isPrimeTsExists")).toBeUndefined();
98
+ expect(measurement("is-prime", "tsIgnoreCount")).toBeUndefined();
99
+ expect(measurement("is-prime", "isPrimeTemplateTest")).toBeUndefined();
100
+ });
101
+
102
+ test("whose config doesn't fit its instrument fails to compile", () => {
103
+ expect(bad("misfitConfig")).toEqual([
104
+ `(3,74): error TS2353: Object literal may only specify known properties, and 'path' does not exist in type '{ glob: string; } | { glob: string; pattern: string; } | { subject: string; template: string; }'.`,
105
+ ]);
106
+ });
107
+
108
+ test("naming an unknown instrument fails to compile", () => {
109
+ expect(bad("unknownInstrument")).toEqual([
110
+ `(3,37): error TS2820: Type '"fileCraeted"' is not assignable to type '"fileCreated" | "grep" | "templateTest"'. Did you mean '"fileCreated"'?`,
111
+ ]);
112
+ });
113
+
114
+ test("without an aggregate fails to compile", () => {
115
+ expect(bad("withoutAggregate")).toEqual([
116
+ `(3,35): error TS2345: Argument of type '{ instrument: "fileCreated"; config: { glob: string; }; }' is not assignable to parameter of type 'MeasurementDeclaration'.
117
+ Property 'aggregate' is missing in type '{ instrument: "fileCreated"; config: { glob: string; }; }' but required in type '{ instrument: "fileCreated"; config: { glob: string; }; aggregate: ("count" | "rate")[]; }'.`,
118
+ ]);
119
+ });
120
+
121
+ test("with an aggregate that doesn't fit its instrument's value type fails to compile", () => {
122
+ expect(bad("fileCreatedByMean")).toEqual([
123
+ `(3,35): error TS2345: Argument of type '{ instrument: "fileCreated"; config: { glob: string; }; aggregate: "mean"[]; }' is not assignable to parameter of type 'MeasurementDeclaration'.
124
+ Types of property 'aggregate' are incompatible.
125
+ Type '"mean"[]' is not assignable to type '("count" | "rate")[]'.
126
+ Type '"mean"' is not assignable to type '"count" | "rate"'.`,
127
+ ]);
128
+ expect(bad("grepByRate")).toEqual([
129
+ `(3,35): error TS2345: Argument of type '{ instrument: "grep"; config: { glob: string; pattern: string; }; aggregate: "rate"[]; }' is not assignable to parameter of type 'MeasurementDeclaration'.
130
+ Types of property 'aggregate' are incompatible.
131
+ Type '"rate"[]' is not assignable to type '("max" | "mean" | "median" | "min" | "sum")[]'.
132
+ Type '"rate"' is not assignable to type '"max" | "mean" | "median" | "min" | "sum"'.`,
133
+ ]);
134
+ expect(bad("templateTestByMax")).toEqual([
135
+ `(3,35): error TS2345: Argument of type '{ instrument: "templateTest"; config: { subject: string; template: string; }; aggregate: "max"[]; }' is not assignable to parameter of type 'MeasurementDeclaration'.
136
+ Types of property 'aggregate' are incompatible.
137
+ Type '"max"[]' is not assignable to type '("mean" | "sum")[]'.
138
+ Type '"max"' is not assignable to type '"mean" | "sum"'.`,
139
+ ]);
140
+ });
141
+
142
+ test("whose config is missing a field fails to compile", () => {
143
+ expect(bad("grepWithoutPattern")).toEqual([
144
+ `(3,35): error TS2345: Argument of type '{ instrument: "grep"; config: { glob: string; }; aggregate: "max"[]; }' is not assignable to parameter of type 'MeasurementDeclaration'.
145
+ Types of property 'config' are incompatible.
146
+ Property 'pattern' is missing in type '{ glob: string; }' but required in type '{ glob: string; pattern: string; }'.`,
147
+ ]);
148
+ expect(bad("templateTestWithoutTemplate")).toEqual([
149
+ `(3,65): error TS2322: Type '{ subject: string; }' is not assignable to type '{ glob: string; } | { glob: string; pattern: string; } | { subject: string; template: string; }'.
150
+ Property 'template' is missing in type '{ subject: string; }' but required in type '{ subject: string; template: string; }'.`,
151
+ ]);
152
+ });
153
+ });
154
+
155
+ describe("a code state", () => {
156
+ const codeState = (name: string) => errorsIn(`test-case-parameters/codeStates/${name}/index.ts`);
157
+
158
+ test("whose config reaches its applyParameter typed, and whose checkoutCommit takes its ctx, compiles cleanly", () => {
159
+ expect(codeState("pinned")).toBeUndefined();
160
+ });
161
+
162
+ test("reading a property its config doesn't have fails to compile", () => {
163
+ expect(codeState("reads-missing-config")).toEqual([
164
+ `(5,58): error TS2339: Property 'sha' does not exist on type '{ commit: string; }'.`,
165
+ ]);
166
+ });
167
+
168
+ test("with the old top-level commit fails to compile: it belongs in config", () => {
169
+ expect(codeState("top-level-commit")).toEqual([
170
+ `(4,3): error TS2353: Object literal may only specify known properties, and 'commit' does not exist in type 'CodeStateDeclaration<undefined>'.`,
171
+ ]);
172
+ });
173
+ });
@@ -0,0 +1,84 @@
1
+ // `thunderjar executions`, through the CLI, in the checked-in fixtureTests/ scenarios, in place.
2
+ // Scenario 8 starts with a run data store of four executions of two test cases; scenario 5 has
3
+ // none. Each asserts on stdout. Any of these can be run by hand:
4
+ //
5
+ // bun run fixture 8 executions fails
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { join } from "../../../platform/path.ts";
9
+ import { scenario, thunderjar } from "../../../testing/cli.ts";
10
+
11
+ describe("scenario_5_empty_parameter_folders", () => {
12
+ const fixtures = scenario("scenario_5_empty_parameter_folders");
13
+
14
+ test("lists nothing before anything has run", async () => {
15
+ expect(await thunderjar(fixtures, ["executions"])).toEqual({
16
+ stdout: "No executions recorded.\n",
17
+ stderr: "",
18
+ exitCode: 0,
19
+ });
20
+ expect(await thunderjar(fixtures, ["executions", "fails"])).toEqual({
21
+ stdout: "No executions recorded for fails.\n",
22
+ stderr: "",
23
+ exitCode: 0,
24
+ });
25
+ });
26
+
27
+ test("--data-dir lists the executions in a store kept elsewhere", async () => {
28
+ const dataDir = join(scenario("scenario_8_recorded_executions"), ".thunderjar", ".data");
29
+ expect(await thunderjar(fixtures, ["executions", "times-out", "--data-dir", dataDir])).toEqual({
30
+ stdout: "01k4xb7q0c2tnvabcdefghjkmn times-out 2026-09-29 15:02\n",
31
+ stderr: "",
32
+ exitCode: 0,
33
+ });
34
+ });
35
+ });
36
+
37
+ describe("scenario_8_recorded_executions", () => {
38
+ const fixtures = scenario("scenario_8_recorded_executions");
39
+
40
+ test("executions: every test case's executions, newest first", async () => {
41
+ expect(await thunderjar(fixtures, ["executions"])).toEqual({
42
+ stdout: `01k4xc0000000000000000000a fails 2026-09-30 08:00
43
+ 01k4xb7q0c2tnvabcdefghjkmn times-out 2026-09-29 15:02
44
+ 01k4x9j2e8mqz3abcdefghjkmn fails 2026-09-29 14:30
45
+ 01k4tq3h8r0000000000000000 fails 2026-09-28 09:15
46
+ `,
47
+ stderr: "",
48
+ exitCode: 0,
49
+ });
50
+ });
51
+
52
+ test("executions <test case>: only that test case's, newest first", async () => {
53
+ expect(await thunderjar(fixtures, ["executions", "fails"])).toEqual({
54
+ stdout: `01k4xc0000000000000000000a fails 2026-09-30 08:00
55
+ 01k4x9j2e8mqz3abcdefghjkmn fails 2026-09-29 14:30
56
+ 01k4tq3h8r0000000000000000 fails 2026-09-28 09:15
57
+ `,
58
+ stderr: "",
59
+ exitCode: 0,
60
+ });
61
+ });
62
+
63
+ test("executions <test case> with nothing recorded says so", async () => {
64
+ expect(await thunderjar(fixtures, ["executions", "nope"])).toEqual({
65
+ stdout: "No executions recorded for nope.\n",
66
+ stderr: "",
67
+ exitCode: 0,
68
+ });
69
+ });
70
+
71
+ test("says what's wrong, then its usage, for extra arguments or a flag", async () => {
72
+ for (const [args, error] of [
73
+ [["executions", "a", "b"], "error: too many arguments for 'executions'"],
74
+ [["executions", "--json"], "error: unknown option '--json'"],
75
+ [["executions", "--data-dir"], "error: option '--data-dir <dir>' argument missing"],
76
+ ] as const) {
77
+ const { stdout, stderr, exitCode } = await thunderjar(fixtures, [...args]);
78
+ expect(stdout).toBe("");
79
+ expect(stderr).toStartWith(error);
80
+ expect(stderr).toContain("Usage: thunderjar executions [options] [test case]");
81
+ expect(exitCode).toBe(1);
82
+ }
83
+ });
84
+ });
@@ -0,0 +1,31 @@
1
+ // `thunderjar executions [<test case>]` — spec/100-cli-reference.md#executions. Lists the test
2
+ // case executions in the run data store, newest first: execution ID, test case, and when it
3
+ // started, in UTC to the minute. `--data-dir` reads a store kept outside the project.
4
+
5
+ import { Command } from "commander";
6
+ import { jsonFileStore, runDataStorePath } from "../../../store/run-data-store.ts";
7
+ import { minute } from "../format.ts";
8
+ import { dataDirOption } from "../options.ts";
9
+
10
+ export async function executions(
11
+ testCase?: string,
12
+ { cwd = process.cwd(), dataDir }: { cwd?: string; dataDir?: string } = {},
13
+ ): Promise<string> {
14
+ const store = jsonFileStore(runDataStorePath(cwd, dataDir));
15
+ const listed = await store.listExecutions(testCase);
16
+ if (listed.length === 0) {
17
+ return testCase === undefined ? "No executions recorded." : `No executions recorded for ${testCase}.`;
18
+ }
19
+ const width = Math.max(...listed.map((e) => e.testCase.length));
20
+ return listed.map((e) => `${e.executionId} ${e.testCase.padEnd(width)} ${minute(e.ranAt)}`).join("\n");
21
+ }
22
+
23
+ export function createExecutionsCommand(): Command {
24
+ return new Command("executions")
25
+ .description("List recorded executions.")
26
+ .argument("[test case]", "list only this test case's executions")
27
+ .addOption(dataDirOption())
28
+ .action(async (testCase: string | undefined, options: { dataDir?: string }) => {
29
+ console.log(await executions(testCase, { dataDir: options.dataDir }));
30
+ });
31
+ }
@@ -0,0 +1,33 @@
1
+ // How commands print things they share: counts, durations, measured values, times. A command
2
+ // never imports another command; what two of them print alike lives here.
3
+
4
+ import type { RecordedOutcome } from "../../store/run-data-store.ts";
5
+
6
+ /** `1 permutation`, `2 permutations`. */
7
+ export const count = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`;
8
+
9
+ /** A measured value as `run` prints it: JSON, but with an object's keys unquoted, `{passed: 4, failed: 1}`. */
10
+ export const showValue = (value: unknown): string =>
11
+ typeof value === "object" && value !== null && !Array.isArray(value)
12
+ ? `{${Object.entries(value)
13
+ .map(([key, v]) => `${key}: ${showValue(v)}`)
14
+ .join(", ")}}`
15
+ : (JSON.stringify(value) ?? String(value));
16
+
17
+ /** A measure as `run` and `remeasure` print it: its value, or what kept it from having one. */
18
+ export const showOutcome = (outcome: RecordedOutcome) =>
19
+ outcome.outcome === "measured"
20
+ ? showValue(outcome.data)
21
+ : outcome.outcome === "skipped"
22
+ ? "skipped"
23
+ : `erroredWhileMeasuring: ${outcome.error.message}`;
24
+
25
+ /** Milliseconds as a duration: `0.04s`. */
26
+ export const seconds = (ms: number) => `${(ms / 1000).toFixed(2)}s`;
27
+
28
+ /** How long a container run took: `0.04s`. */
29
+ export const duration = ({ startedAt, finishedAt }: { startedAt: number; finishedAt: number }) =>
30
+ seconds(finishedAt - startedAt);
31
+
32
+ /** `2026-09-29 14:30`, from an ISO timestamp. */
33
+ export const minute = (iso: string) => iso.slice(0, 16).replace("T", " ");
@@ -0,0 +1,101 @@
1
+ // `thunderjar generate`, through the CLI, in the checked-in fixtureTests/ scenarios, in place. It
2
+ // writes only the gitignored `.thunderjar/_generated/parameter-names.d.ts`, so each test deletes
3
+ // that file first, then asserts on what was written. Any of these can be run by hand:
4
+ //
5
+ // bun run fixture scenario_2 generate
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { remove } from "../../../platform/fs.ts";
9
+ import { join } from "../../../platform/path.ts";
10
+ import { scenario, thunderjar } from "../../../testing/cli.ts";
11
+
12
+ /** Runs `generate` in `fixtures` with no earlier output left, returning its output and the file. */
13
+ async function generateIn(fixtures: string) {
14
+ const path = join(fixtures, ".thunderjar", "_generated", "parameter-names.d.ts");
15
+ await remove(path);
16
+ const result = await thunderjar(fixtures, ["generate"]);
17
+ return { ...result, path, content: await Bun.file(path).text() };
18
+ }
19
+
20
+ describe("scenario_1_minimal_initial_setup", () => {
21
+ const fixtures = scenario("scenario_1_minimal_initial_setup");
22
+
23
+ test("generate: one of everything", async () => {
24
+ const { stdout, stderr, exitCode, path, content } = await generateIn(fixtures);
25
+
26
+ expect(stderr).toBe("");
27
+ expect(exitCode).toBe(0);
28
+ expect(stdout).toBe(`generated ${path}\n`);
29
+ expect(content).toBe(`// _generated/parameter-names.d.ts — generated by \`thunderjar generate\`, not edited
30
+
31
+ type BaseImageName = "node20";
32
+ type CodeStateName = "baseline";
33
+ type PromptSetName = "snerk";
34
+ type TaskName = "is-prime";
35
+ type InitialPromptNames = {
36
+ "is-prime": "plain";
37
+ };
38
+ type HarnessModelName = \`fake-harness-alpha@\${"1.0.0"}/\${"fake-model-small"}\`;
39
+ `);
40
+ });
41
+ });
42
+
43
+ describe("scenario_2_typical_project", () => {
44
+ const fixtures = scenario("scenario_2_typical_project");
45
+
46
+ test("generate: sorted unions of every name, and a harness model pair per family", async () => {
47
+ const { stdout, stderr, exitCode, path, content } = await generateIn(fixtures);
48
+
49
+ expect(stderr).toBe("");
50
+ expect(exitCode).toBe(0);
51
+ expect(stdout).toBe(`generated ${path}\n`);
52
+ expect(content).toBe(`// _generated/parameter-names.d.ts — generated by \`thunderjar generate\`, not edited
53
+
54
+ type BaseImageName = "node-lts" | "node20";
55
+ type CodeStateName = "baseline";
56
+ type PromptSetName = "glurk" | "glurk-reworded" | "snerk";
57
+ type TaskName = "is-prime" | "no-measures";
58
+ type InitialPromptNames = {
59
+ "is-prime": "plain" | "terse";
60
+ "no-measures": "plain";
61
+ };
62
+ type HarnessModelName =
63
+ | \`fake-harness-alpha@\${"1.0.0" | "1.1.0"}/\${"fake-model-large" | "fake-model-small"}\`
64
+ | \`fake-harness-beta@\${"1.0.0"}/\${"fake-model-large" | "fake-model-small"}\`;
65
+ `);
66
+ });
67
+
68
+ test("generate: from a folder below the project, writes into the project's .thunderjar/", async () => {
69
+ const below = join(fixtures, ".thunderjar", "test-cases");
70
+ const path = join(fixtures, ".thunderjar", "_generated", "parameter-names.d.ts");
71
+ await remove(path);
72
+
73
+ const { stdout, stderr, exitCode } = await thunderjar(below, ["generate"]);
74
+
75
+ expect(stderr).toBe("");
76
+ expect(exitCode).toBe(0);
77
+ expect(stdout).toBe(`generated ${path}\n`);
78
+ expect(await Bun.file(path).text()).toContain('type CodeStateName = "baseline";');
79
+ });
80
+ });
81
+
82
+ describe("scenario_5_empty_parameter_folders", () => {
83
+ const fixtures = scenario("scenario_5_empty_parameter_folders");
84
+
85
+ test("generate: empty parameter folders render as never", async () => {
86
+ const { stdout, stderr, exitCode, path, content } = await generateIn(fixtures);
87
+
88
+ expect(stderr).toBe("");
89
+ expect(exitCode).toBe(0);
90
+ expect(stdout).toBe(`generated ${path}\n`);
91
+ expect(content).toBe(`// _generated/parameter-names.d.ts — generated by \`thunderjar generate\`, not edited
92
+
93
+ type BaseImageName = never;
94
+ type CodeStateName = never;
95
+ type PromptSetName = never;
96
+ type TaskName = never;
97
+ type InitialPromptNames = Record<string, never>;
98
+ type HarnessModelName = never;
99
+ `);
100
+ });
101
+ });
@@ -0,0 +1,18 @@
1
+ // `thunderjar generate` — spec/100-cli-reference.md#generate.
2
+
3
+ import { Command } from "commander";
4
+ import { findThunderjarRoot } from "../../../project/config.ts";
5
+ import { writeGeneratedTypes } from "./parameter-names.ts";
6
+
7
+ export async function generate(cwd: string = process.cwd()): Promise<{ path: string }> {
8
+ const root = findThunderjarRoot(cwd);
9
+ const { path } = await writeGeneratedTypes(root);
10
+ return { path };
11
+ }
12
+
13
+ export function createGenerateCommand(): Command {
14
+ return new Command("generate").description("Write the parameter-name types for this project.").action(async () => {
15
+ const { path } = await generate();
16
+ console.log(`generated ${path}`);
17
+ });
18
+ }
@@ -0,0 +1,131 @@
1
+ // Reads parameter names and harness family version/model maps under
2
+ // `test-case-parameters/` and renders `_generated/parameter-names.d.ts` —
3
+ // spec/055-declaring-test-cases.md#typed-names.
4
+
5
+ import { listEntries } from "../../../platform/fs.ts";
6
+ import { join } from "../../../platform/path.ts";
7
+ import { InvalidConfigError, loadDeclaration, type Problem } from "../../../project/config.ts";
8
+
9
+ interface HarnessFamilyNames {
10
+ versions: string[];
11
+ models: string[];
12
+ }
13
+
14
+ export interface ParameterNames {
15
+ baseImages: string[];
16
+ codeStates: string[];
17
+ promptSets: string[];
18
+ tasks: Record<string, string[]>; // task name -> initial prompt names
19
+ harnessFamilies: Record<string, HarnessFamilyNames>;
20
+ }
21
+
22
+ async function listDirNames(dir: string): Promise<string[]> {
23
+ try {
24
+ return listEntries(dir)
25
+ .filter((entry) => entry.isDirectory)
26
+ .map((entry) => entry.path)
27
+ .sort();
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ async function listFileStems(dir: string): Promise<string[]> {
34
+ try {
35
+ return listEntries(dir)
36
+ .filter((entry) => !entry.isDirectory && entry.path.endsWith(".ts"))
37
+ .map((entry) => entry.path.slice(0, -".ts".length))
38
+ .sort();
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ async function loadFamilyNames(root: string, family: string, problems: Problem[]): Promise<HarnessFamilyNames> {
45
+ const familyDir = `test-case-parameters/harnessModels/${family}`;
46
+ const models = await loadDeclaration(root, `${familyDir}/models.ts`, "declareHarnessModels", problems);
47
+ const versions = await loadDeclaration(root, `${familyDir}/versions.ts`, "declareHarnessVersions", problems);
48
+ return {
49
+ models: Object.keys(models ?? {}).sort(),
50
+ versions: Object.keys(versions ?? {}).sort(),
51
+ };
52
+ }
53
+
54
+ /** Scans a `.thunderjar/` root's `test-case-parameters/` folder for declared names. */
55
+ export async function collectParameterNames(root: string): Promise<ParameterNames> {
56
+ const parameters = join(root, "test-case-parameters");
57
+
58
+ const [baseImages, codeStates, promptSets, taskNames, familyNames] = await Promise.all([
59
+ listDirNames(join(parameters, "baseImages")),
60
+ listDirNames(join(parameters, "codeStates")),
61
+ listDirNames(join(parameters, "promptSets")),
62
+ listDirNames(join(parameters, "tasks")),
63
+ listDirNames(join(parameters, "harnessModels")),
64
+ ]);
65
+
66
+ const tasks: Record<string, string[]> = {};
67
+ for (const task of taskNames) {
68
+ tasks[task] = await listFileStems(join(parameters, "tasks", task, "initial-prompts"));
69
+ }
70
+
71
+ const harnessFamilies: Record<string, HarnessFamilyNames> = {};
72
+ const problems: Problem[] = [];
73
+ for (const family of familyNames) {
74
+ harnessFamilies[family] = await loadFamilyNames(root, family, problems);
75
+ }
76
+ if (problems.length > 0) throw new InvalidConfigError(problems);
77
+
78
+ return { baseImages, codeStates, promptSets, tasks, harnessFamilies };
79
+ }
80
+
81
+ function literalUnion(names: string[]): string {
82
+ return names.length === 0 ? "never" : names.map((name) => JSON.stringify(name)).join(" | ");
83
+ }
84
+
85
+ /** Renders `_generated/parameter-names.d.ts`'s contents from scanned names. */
86
+ export function renderParameterNames(names: ParameterNames): string {
87
+ const lines = [
88
+ "// _generated/parameter-names.d.ts — generated by `thunderjar generate`, not edited",
89
+ "",
90
+ `type BaseImageName = ${literalUnion(names.baseImages)};`,
91
+ `type CodeStateName = ${literalUnion(names.codeStates)};`,
92
+ `type PromptSetName = ${literalUnion(names.promptSets)};`,
93
+ `type TaskName = ${literalUnion(Object.keys(names.tasks))};`,
94
+ ];
95
+
96
+ const tasks = Object.entries(names.tasks);
97
+ if (tasks.length === 0) {
98
+ lines.push("type InitialPromptNames = Record<string, never>;");
99
+ } else {
100
+ lines.push("type InitialPromptNames = {");
101
+ for (const [task, prompts] of tasks) {
102
+ lines.push(` ${JSON.stringify(task)}: ${literalUnion(prompts)};`);
103
+ }
104
+ lines.push("};");
105
+ }
106
+
107
+ const pairs = Object.entries(names.harnessFamilies)
108
+ .filter(([, { versions, models }]) => versions.length > 0 && models.length > 0)
109
+ .map(([family, { versions, models }]) => "`" + family + "@${" + literalUnion(versions) + "}/${" + literalUnion(models) + "}`");
110
+ if (pairs.length === 0) {
111
+ lines.push("type HarnessModelName = never;");
112
+ } else if (pairs.length === 1) {
113
+ lines.push(`type HarnessModelName = ${pairs[0]};`);
114
+ } else {
115
+ lines.push("type HarnessModelName =");
116
+ lines.push(...pairs.map((pair) => ` | ${pair}`));
117
+ lines[lines.length - 1] += ";";
118
+ }
119
+ lines.push("");
120
+
121
+ return lines.join("\n");
122
+ }
123
+
124
+ /** Scans `root` and writes `_generated/parameter-names.d.ts`, returning its path. */
125
+ export async function writeGeneratedTypes(root: string): Promise<{ path: string; content: string }> {
126
+ const names = await collectParameterNames(root);
127
+ const content = renderParameterNames(names);
128
+ const path = join(root, "_generated", "parameter-names.d.ts");
129
+ await Bun.write(path, content);
130
+ return { path, content };
131
+ }