argsbarg 6.0.1 → 6.0.2

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 (33) hide show
  1. package/CHANGELOG.md +14 -2
  2. package/README.md +3 -3
  3. package/docs/config-schema.md +15 -13
  4. package/docs/output-schema.md +67 -66
  5. package/examples/full-example/README.md +7 -7
  6. package/examples/full-example/bun.lock +0 -29
  7. package/examples/full-example/justfile +6 -3
  8. package/examples/full-example/package.json +0 -2
  9. package/examples/full-example/src/commands/status/__generated__/index.ts +5 -0
  10. package/examples/full-example/src/commands/status/command.ts +2 -2
  11. package/examples/full-example/src/commands/status/types.ts +1 -1
  12. package/examples/full-example/src/config/__generated__/index.ts +5 -0
  13. package/examples/full-example/src/program.ts +4 -4
  14. package/package.json +8 -1
  15. package/src/cli-tool/full-example-capabilities.test.ts +2 -1
  16. package/src/cli-tool/post-create.ts +3 -3
  17. package/src/cli-tool/program.ts +35 -0
  18. package/src/cli-tool/run-schemagen.ts +23 -0
  19. package/src/cli-tool/schemagen/cleanup.ts +60 -0
  20. package/{examples/full-example/scripts → src/cli-tool}/schemagen/discover-schema-roots.ts +13 -58
  21. package/src/cli-tool/schemagen/index.ts +3 -0
  22. package/src/cli-tool/schemagen/names.ts +22 -0
  23. package/src/cli-tool/schemagen/run.ts +108 -0
  24. package/src/cli-tool/schemagen/schemagen.test.ts +125 -0
  25. package/examples/full-example/schemas/configSchemas.ts +0 -6
  26. package/examples/full-example/schemas/outputSchemas.ts +0 -6
  27. package/examples/full-example/scripts/schemagen/discover-schema-roots.test.ts +0 -25
  28. package/examples/full-example/scripts/schemagen/naming.ts +0 -99
  29. package/examples/full-example/scripts/schemagen.ts +0 -91
  30. /package/examples/full-example/{schemas/generated/status.json → src/commands/status/__generated__/outputSchema.json} +0 -0
  31. /package/examples/full-example/src/commands/status/{schema-types.ts → schema.ts} +0 -0
  32. /package/examples/full-example/{schemas/generated/app-config.json → src/config/__generated__/configSchema.json} +0 -0
  33. /package/examples/full-example/src/config/{schema-types.ts → schema.ts} +0 -0
@@ -5,6 +5,7 @@ Argsbarg developer tools — bootstrap consumer CLIs via `create`.
5
5
  import pkg from "../../package.json" with { type: "json" };
6
6
  import { CliOptionKind, type CliProgram } from "../index.ts";
7
7
  import { runCreate } from "./run-create.ts";
8
+ import { runSchemagenCli } from "./run-schemagen.ts";
8
9
 
9
10
  export const program = {
10
11
  key: "argsbarg",
@@ -92,5 +93,39 @@ export const program = {
92
93
  process.exit(code);
93
94
  },
94
95
  },
96
+ {
97
+ key: "schemagen",
98
+ description: "Generate JSON Schema artifacts from src/**/schema.ts into colocated __generated__/ directories.",
99
+ options: [
100
+ {
101
+ name: "root",
102
+ description: "Project root (default: current working directory).",
103
+ kind: CliOptionKind.String,
104
+ },
105
+ {
106
+ name: "src-dir",
107
+ description: "Source directory relative to --root (default: src).",
108
+ kind: CliOptionKind.String,
109
+ },
110
+ {
111
+ name: "tsconfig",
112
+ description: "Path to tsconfig relative to --root (default: tsconfig.json).",
113
+ kind: CliOptionKind.String,
114
+ },
115
+ ],
116
+ handler: (ctx) => {
117
+ try {
118
+ runSchemagenCli({
119
+ root: ctx.stringOpt("root"),
120
+ srcDir: ctx.stringOpt("src-dir"),
121
+ tsconfig: ctx.stringOpt("tsconfig"),
122
+ });
123
+ } catch (error) {
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ process.stderr.write(`${message}\n`);
126
+ process.exit(1);
127
+ }
128
+ },
129
+ },
95
130
  ],
96
131
  } satisfies CliProgram;
@@ -0,0 +1,23 @@
1
+ /** Run argsbarg schemagen in the current working directory (or `--root`). */
2
+
3
+ import { resolve } from "node:path";
4
+ import { runSchemagen } from "./schemagen/run.ts";
5
+
6
+ export interface RunSchemagenCliOptions {
7
+ root?: string;
8
+ srcDir?: string;
9
+ tsconfig?: string;
10
+ }
11
+
12
+ /** Exit 0 on success; throws on failure. */
13
+ export function runSchemagenCli(options: RunSchemagenCliOptions = {}): void {
14
+ const projectRoot = resolve(options.root ?? process.cwd());
15
+ const counts = runSchemagen({
16
+ projectRoot,
17
+ srcDir: options.srcDir,
18
+ tsconfig: options.tsconfig,
19
+ });
20
+ console.log(
21
+ `config roots: ${counts.configRoots}, input roots: ${counts.inputRoots}, output roots: ${counts.outputRoots}`,
22
+ );
23
+ }
@@ -0,0 +1,60 @@
1
+ /*
2
+ Remove stale __generated__/ directories and files after schemagen runs.
3
+ */
4
+
5
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
6
+ import { dirname, join, relative } from "node:path";
7
+ import type { SchemaRoot } from "./discover-schema-roots.ts";
8
+ import { GENERATED_DIR, SCHEMA_FILE, schemaJsonBasename } from "./names.ts";
9
+
10
+ function listGeneratedDirs(srcDir: string, projectRoot: string, out: string[]): void {
11
+ for (const ent of readdirSync(srcDir)) {
12
+ const full = join(srcDir, ent);
13
+ const st = statSync(full);
14
+ if (!st.isDirectory()) {
15
+ continue;
16
+ }
17
+ if (ent === GENERATED_DIR) {
18
+ out.push(relative(projectRoot, full));
19
+ continue;
20
+ }
21
+ listGeneratedDirs(full, projectRoot, out);
22
+ }
23
+ }
24
+
25
+ /** Drop orphan `__generated__/` trees and JSON files for removed schema kinds. */
26
+ export function cleanStaleGenerated(
27
+ projectRoot: string,
28
+ srcDir: string,
29
+ activeBySchemaFile: Map<string, SchemaRoot[]>,
30
+ ): void {
31
+ const srcPath = join(projectRoot, srcDir);
32
+ if (!existsSync(srcPath)) {
33
+ return;
34
+ }
35
+
36
+ const generatedDirs: string[] = [];
37
+ listGeneratedDirs(srcPath, projectRoot, generatedDirs);
38
+
39
+ for (const relGeneratedDir of generatedDirs) {
40
+ const generatedDir = join(projectRoot, relGeneratedDir);
41
+ const relSchemaPath = relative(projectRoot, join(dirname(generatedDir), SCHEMA_FILE));
42
+ const roots = existsSync(join(projectRoot, relSchemaPath)) ? (activeBySchemaFile.get(relSchemaPath) ?? []) : [];
43
+
44
+ if (roots.length === 0) {
45
+ rmSync(generatedDir, { recursive: true, force: true });
46
+ console.log(`removed ${relGeneratedDir}`);
47
+ continue;
48
+ }
49
+
50
+ const keep = new Set(["index.ts", ...roots.map((root) => schemaJsonBasename(root.kind))]);
51
+ for (const ent of readdirSync(generatedDir)) {
52
+ if (keep.has(ent)) {
53
+ continue;
54
+ }
55
+ const stalePath = join(generatedDir, ent);
56
+ rmSync(stalePath, { recursive: true, force: true });
57
+ console.log(`removed ${relative(projectRoot, stalePath)}`);
58
+ }
59
+ }
60
+ }
@@ -1,18 +1,10 @@
1
1
  /*
2
- Discovers schema roots in schema-types.ts files via configType / inputType / outputType exports.
3
- Copy per consumer repo — see docs/output-schema.md and docs/config-schema.md.
2
+ Discovers schema roots in schema.ts files via configType / inputType / outputType exports.
4
3
  */
5
4
 
6
5
  import { readdirSync, readFileSync, statSync } from "node:fs";
7
6
  import { join, relative } from "node:path";
8
- import {
9
- configSchemaExportName,
10
- inputSchemaExportName,
11
- outfileForConfigType,
12
- outfileForInputType,
13
- outfileForOutputType,
14
- outputSchemaExportName,
15
- } from "./naming.ts";
7
+ import { SCHEMA_FILE } from "./names.ts";
16
8
 
17
9
  export type SchemaRootKind = "config" | "input" | "output";
18
10
 
@@ -21,14 +13,10 @@ export type SchemaRole = "configType" | "inputType" | "outputType";
21
13
  export interface SchemaRoot {
22
14
  kind: SchemaRootKind;
23
15
  typeName: string;
24
- /** Path relative to project root (e.g. src/config/schema-types.ts). */
16
+ /** Path relative to project root (e.g. src/config/schema.ts). */
25
17
  path: string;
26
- outfile: string;
27
- exportName: string;
28
18
  }
29
19
 
30
- const SCHEMA_TYPES_FILE = "schema-types.ts";
31
-
32
20
  const ROLE_EXPORT_RE = /export\s+type\s+(configType|inputType|outputType)\s*=\s*(\w+)/g;
33
21
 
34
22
  const ROLE_TO_KIND: Record<SchemaRole, SchemaRootKind> = {
@@ -37,15 +25,15 @@ const ROLE_TO_KIND: Record<SchemaRole, SchemaRootKind> = {
37
25
  outputType: "output",
38
26
  };
39
27
 
40
- function listSchemaTypesFiles(srcDir: string, baseDir: string, out: string[]): void {
28
+ function listSchemaFiles(srcDir: string, baseDir: string, out: string[]): void {
41
29
  for (const ent of readdirSync(srcDir)) {
42
30
  const full = join(srcDir, ent);
43
31
  const st = statSync(full);
44
32
  if (st.isDirectory()) {
45
- listSchemaTypesFiles(full, baseDir, out);
33
+ listSchemaFiles(full, baseDir, out);
46
34
  continue;
47
35
  }
48
- if (ent === SCHEMA_TYPES_FILE) {
36
+ if (ent === SCHEMA_FILE) {
49
37
  out.push(relative(baseDir, full));
50
38
  }
51
39
  }
@@ -62,35 +50,6 @@ function isTypeDefinedInFile(text: string, typeName: string): boolean {
62
50
  return false;
63
51
  }
64
52
 
65
- function rootForRole(role: SchemaRole, typeName: string, path: string): SchemaRoot {
66
- const kind = ROLE_TO_KIND[role];
67
- if (kind === "config") {
68
- return {
69
- kind,
70
- typeName,
71
- path,
72
- outfile: outfileForConfigType(typeName),
73
- exportName: configSchemaExportName(typeName),
74
- };
75
- }
76
- if (kind === "input") {
77
- return {
78
- kind,
79
- typeName,
80
- path,
81
- outfile: outfileForInputType(typeName),
82
- exportName: inputSchemaExportName(typeName),
83
- };
84
- }
85
- return {
86
- kind,
87
- typeName,
88
- path,
89
- outfile: outfileForOutputType(typeName),
90
- exportName: outputSchemaExportName(typeName),
91
- };
92
- }
93
-
94
53
  function discoverFromFile(path: string, text: string): SchemaRoot[] {
95
54
  const rolesSeen = new Set<SchemaRole>();
96
55
  const roots: SchemaRoot[] = [];
@@ -108,17 +67,17 @@ function discoverFromFile(path: string, text: string): SchemaRoot[] {
108
67
  if (!isTypeDefinedInFile(text, typeName)) {
109
68
  continue;
110
69
  }
111
- roots.push(rootForRole(role, typeName, path));
70
+ roots.push({ kind: ROLE_TO_KIND[role], typeName, path });
112
71
  }
113
72
 
114
73
  return roots;
115
74
  }
116
75
 
117
- /** Find all schema roots under `src/` in files named schema-types.ts. */
118
- export function discoverSchemaRoots(projectRoot: string): SchemaRoot[] {
119
- const srcDir = join(projectRoot, "src");
76
+ /** Find all schema roots under `srcDir` in files named `schema.ts`. */
77
+ export function discoverSchemaRoots(projectRoot: string, srcDir = "src"): SchemaRoot[] {
78
+ const srcPath = join(projectRoot, srcDir);
120
79
  const files: string[] = [];
121
- listSchemaTypesFiles(srcDir, projectRoot, files);
80
+ listSchemaFiles(srcPath, projectRoot, files);
122
81
 
123
82
  const roots: SchemaRoot[] = [];
124
83
  const typeOwners = new Map<string, string>();
@@ -128,9 +87,7 @@ export function discoverSchemaRoots(projectRoot: string): SchemaRoot[] {
128
87
  for (const root of discoverFromFile(relPath, text)) {
129
88
  const prev = typeOwners.get(root.typeName);
130
89
  if (prev) {
131
- throw new Error(
132
- `${relPath}: duplicate schema root type ${root.typeName} (already declared in ${prev})`,
133
- );
90
+ throw new Error(`${relPath}: duplicate schema root type ${root.typeName} (already declared in ${prev})`);
134
91
  }
135
92
  typeOwners.set(root.typeName, relPath);
136
93
  roots.push(root);
@@ -139,9 +96,7 @@ export function discoverSchemaRoots(projectRoot: string): SchemaRoot[] {
139
96
 
140
97
  const configRoots = roots.filter((r) => r.kind === "config");
141
98
  if (configRoots.length > 1) {
142
- throw new Error(
143
- `multiple config schema roots: ${configRoots.map((r) => `${r.typeName} (${r.path})`).join(", ")}`,
144
- );
99
+ throw new Error(`multiple config schema roots: ${configRoots.map((r) => `${r.typeName} (${r.path})`).join(", ")}`);
145
100
  }
146
101
 
147
102
  return roots;
@@ -0,0 +1,3 @@
1
+ export { discoverSchemaRoots, type SchemaRoot, type SchemaRootKind } from "./discover-schema-roots.ts";
2
+ export { GENERATED_DIR, SCHEMA_FILE, schemaExportName, schemaJsonBasename } from "./names.ts";
3
+ export { type RunSchemagenOptions, type RunSchemagenResult, runSchemagen } from "./run.ts";
@@ -0,0 +1,22 @@
1
+ import type { SchemaRootKind } from "./discover-schema-roots.ts";
2
+
3
+ /** Directory name for generated schema artifacts (next to `schema.ts`). */
4
+ export const GENERATED_DIR = "__generated__";
5
+
6
+ /** TypeScript schema root filename under `src/`. */
7
+ export const SCHEMA_FILE = "schema.ts";
8
+
9
+ /** JSON basename for a schema root kind (`outputSchema.json`, etc.). */
10
+ export function schemaJsonBasename(kind: SchemaRootKind): string {
11
+ return `${kind}Schema.json`;
12
+ }
13
+
14
+ /** Export const name wired on leaves or `program.appConfig`. */
15
+ export function schemaExportName(kind: SchemaRootKind): string {
16
+ return `${kind}Schema`;
17
+ }
18
+
19
+ /** Safe import binding for a schema JSON basename. */
20
+ export function schemaJsonImportVar(kind: SchemaRootKind): string {
21
+ return `${kind}SchemaJson`;
22
+ }
@@ -0,0 +1,108 @@
1
+ /*
2
+ Generate JSON Schema artifacts under __generated__/ and write index.ts re-exports.
3
+ */
4
+
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, relative } from "node:path";
7
+ import { createGenerator } from "ts-json-schema-generator";
8
+ import { cleanStaleGenerated } from "./cleanup.ts";
9
+ import { discoverSchemaRoots, type SchemaRoot, type SchemaRootKind } from "./discover-schema-roots.ts";
10
+ import { GENERATED_DIR, schemaExportName, schemaJsonBasename, schemaJsonImportVar } from "./names.ts";
11
+
12
+ const KIND_ORDER: SchemaRootKind[] = ["config", "input", "output"];
13
+
14
+ export interface RunSchemagenOptions {
15
+ /** Project root (default: `process.cwd()`). */
16
+ projectRoot?: string;
17
+ /** Source tree directory relative to project root (default: `src`). */
18
+ srcDir?: string;
19
+ /** Path to tsconfig relative to project root (default: `tsconfig.json`). */
20
+ tsconfig?: string;
21
+ }
22
+
23
+ export interface RunSchemagenResult {
24
+ configRoots: number;
25
+ inputRoots: number;
26
+ outputRoots: number;
27
+ }
28
+
29
+ function resolveTsconfig(projectRoot: string, tsconfig: string): string {
30
+ const path = join(projectRoot, tsconfig);
31
+ try {
32
+ return path;
33
+ } catch {
34
+ throw new Error(`tsconfig not found: ${path}`);
35
+ }
36
+ }
37
+
38
+ function generateJson(projectRoot: string, tsconfigPath: string, root: SchemaRoot): Record<string, unknown> {
39
+ const generator = createGenerator({
40
+ path: join(projectRoot, root.path),
41
+ type: root.typeName,
42
+ tsconfig: tsconfigPath,
43
+ topRef: false,
44
+ skipTypeCheck: false,
45
+ jsDoc: "extended",
46
+ additionalProperties: root.kind === "config" ? false : undefined,
47
+ });
48
+ const schema = generator.createSchema(root.typeName) as Record<string, unknown>;
49
+ if (root.kind === "config") {
50
+ schema.additionalProperties = false;
51
+ }
52
+ return schema;
53
+ }
54
+
55
+ function writeGeneratedIndex(generatedDir: string, roots: SchemaRoot[]): void {
56
+ const sorted = [...roots].sort((left, right) => KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind));
57
+ const lines = ["// Auto-generated by argsbarg schemagen — do not edit by hand.", ""];
58
+ for (const root of sorted) {
59
+ lines.push(`import ${schemaJsonImportVar(root.kind)} from "./${schemaJsonBasename(root.kind)}";`);
60
+ }
61
+ if (sorted.length > 0) {
62
+ lines.push("");
63
+ }
64
+ for (const root of sorted) {
65
+ lines.push(
66
+ `export const ${schemaExportName(root.kind)} = ${schemaJsonImportVar(root.kind)} as Record<string, unknown>;`,
67
+ );
68
+ lines.push("");
69
+ }
70
+ writeFileSync(join(generatedDir, "index.ts"), `${lines.join("\n")}`);
71
+ }
72
+
73
+ /** Generate colocated `__generated__` JSON schemas and `index.ts` barrels. */
74
+ export function runSchemagen(options: RunSchemagenOptions = {}): RunSchemagenResult {
75
+ const projectRoot = options.projectRoot ?? process.cwd();
76
+ const srcDir = options.srcDir ?? "src";
77
+ const tsconfigPath = resolveTsconfig(projectRoot, options.tsconfig ?? "tsconfig.json");
78
+
79
+ const roots = discoverSchemaRoots(projectRoot, srcDir);
80
+ const bySchemaFile = new Map<string, SchemaRoot[]>();
81
+
82
+ for (const root of roots) {
83
+ const schema = generateJson(projectRoot, tsconfigPath, root);
84
+ const generatedDir = join(dirname(join(projectRoot, root.path)), GENERATED_DIR);
85
+ mkdirSync(generatedDir, { recursive: true });
86
+ const jsonPath = join(generatedDir, schemaJsonBasename(root.kind));
87
+ writeFileSync(jsonPath, `${JSON.stringify(schema, null, 2)}\n`);
88
+ console.log(`wrote ${relative(projectRoot, jsonPath)} (${root.typeName})`);
89
+
90
+ const list = bySchemaFile.get(root.path) ?? [];
91
+ list.push(root);
92
+ bySchemaFile.set(root.path, list);
93
+ }
94
+
95
+ for (const [relPath, fileRoots] of bySchemaFile) {
96
+ const generatedDir = join(dirname(join(projectRoot, relPath)), GENERATED_DIR);
97
+ writeGeneratedIndex(generatedDir, fileRoots);
98
+ console.log(`wrote ${relative(projectRoot, join(generatedDir, "index.ts"))}`);
99
+ }
100
+
101
+ cleanStaleGenerated(projectRoot, srcDir, bySchemaFile);
102
+
103
+ return {
104
+ configRoots: roots.filter((r) => r.kind === "config").length,
105
+ inputRoots: roots.filter((r) => r.kind === "input").length,
106
+ outputRoots: roots.filter((r) => r.kind === "output").length,
107
+ };
108
+ }
@@ -0,0 +1,125 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { discoverSchemaRoots } from "./discover-schema-roots.ts";
6
+ import { schemaExportName, schemaJsonBasename } from "./names.ts";
7
+ import { runSchemagen } from "./run.ts";
8
+
9
+ const exampleRoot = join(import.meta.dir, "../../../examples/full-example");
10
+
11
+ const tempRoots: string[] = [];
12
+
13
+ afterEach(() => {
14
+ for (const root of tempRoots.splice(0)) {
15
+ rmSync(root, { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ function makeTempProject(): string {
20
+ const root = mkdtempSync(join(tmpdir(), "argsbarg-schemagen-"));
21
+ tempRoots.push(root);
22
+ writeFileSync(
23
+ join(root, "tsconfig.json"),
24
+ JSON.stringify(
25
+ {
26
+ compilerOptions: {
27
+ strict: true,
28
+ target: "ESNext",
29
+ module: "ESNext",
30
+ moduleResolution: "bundler",
31
+ resolveJsonModule: true,
32
+ },
33
+ include: ["src/**/*"],
34
+ },
35
+ null,
36
+ 2,
37
+ ),
38
+ );
39
+ return root;
40
+ }
41
+
42
+ function writeSchema(root: string, relDir: string, body: string): void {
43
+ const dir = join(root, "src", relDir);
44
+ mkdirSync(dir, { recursive: true });
45
+ writeFileSync(join(dir, "schema.ts"), body);
46
+ }
47
+
48
+ describe("schemagen", () => {
49
+ test("discovers AppConfig and StatusJsonOutput in full-example", () => {
50
+ const roots = discoverSchemaRoots(exampleRoot);
51
+ expect(roots.map((r) => `${r.kind}:${r.typeName}`).sort()).toEqual(["config:AppConfig", "output:StatusJsonOutput"]);
52
+ });
53
+
54
+ test("maps schema kinds to __generated__ filenames and export names", () => {
55
+ expect(schemaJsonBasename("output")).toBe("outputSchema.json");
56
+ expect(schemaJsonBasename("config")).toBe("configSchema.json");
57
+ expect(schemaExportName("output")).toBe("outputSchema");
58
+ });
59
+
60
+ test("runSchemagen writes __generated__ artifacts in full-example", () => {
61
+ const counts = runSchemagen({ projectRoot: exampleRoot });
62
+ expect(counts).toEqual({ configRoots: 1, inputRoots: 0, outputRoots: 1 });
63
+ });
64
+
65
+ test("removes stale JSON files when a schema kind is dropped", () => {
66
+ const root = makeTempProject();
67
+ writeSchema(
68
+ root,
69
+ "commands/demo",
70
+ `/** Tool input. */
71
+ export interface DemoInput { id: string; }
72
+ /** Tool output. */
73
+ export interface DemoOutput { ok: boolean; }
74
+ export type inputType = DemoInput;
75
+ export type outputType = DemoOutput;
76
+ `,
77
+ );
78
+
79
+ runSchemagen({ projectRoot: root });
80
+ const generatedDir = join(root, "src/commands/demo/__generated__");
81
+ expect(existsSync(join(generatedDir, "inputSchema.json"))).toBe(true);
82
+ expect(existsSync(join(generatedDir, "outputSchema.json"))).toBe(true);
83
+
84
+ writeSchema(
85
+ root,
86
+ "commands/demo",
87
+ `/** Tool output. */
88
+ export interface DemoOutput { ok: boolean; }
89
+ export type outputType = DemoOutput;
90
+ `,
91
+ );
92
+
93
+ runSchemagen({ projectRoot: root });
94
+ expect(existsSync(join(generatedDir, "inputSchema.json"))).toBe(false);
95
+ expect(existsSync(join(generatedDir, "outputSchema.json"))).toBe(true);
96
+ expect(existsSync(join(generatedDir, "index.ts"))).toBe(true);
97
+ });
98
+
99
+ test("removes orphan __generated__ when schema.ts is gone", () => {
100
+ const root = makeTempProject();
101
+ const generatedDir = join(root, "src/orphan/__generated__");
102
+ mkdirSync(generatedDir, { recursive: true });
103
+ writeFileSync(join(generatedDir, "outputSchema.json"), "{}\n");
104
+ writeFileSync(join(generatedDir, "index.ts"), "export const outputSchema = {};\n");
105
+
106
+ runSchemagen({ projectRoot: root });
107
+ expect(existsSync(generatedDir)).toBe(false);
108
+ });
109
+
110
+ test("removes __generated__ when schema.ts has no discoverable roots", () => {
111
+ const root = makeTempProject();
112
+ writeSchema(
113
+ root,
114
+ "commands/empty",
115
+ `export type outputType = never;
116
+ `,
117
+ );
118
+ const generatedDir = join(root, "src/commands/empty/__generated__");
119
+ mkdirSync(generatedDir, { recursive: true });
120
+ writeFileSync(join(generatedDir, "outputSchema.json"), "{}\n");
121
+
122
+ runSchemagen({ projectRoot: root });
123
+ expect(existsSync(generatedDir)).toBe(false);
124
+ });
125
+ });
@@ -1,6 +0,0 @@
1
- // Auto-generated by scripts/schemagen.ts — do not edit by hand.
2
-
3
- import app_config from "./generated/app-config.json";
4
-
5
- /** JSON Schema for program.appConfig.jsonSchema from `AppConfig`. */
6
- export const APP_CONFIG_JSON_SCHEMA = app_config as Record<string, unknown>;
@@ -1,6 +0,0 @@
1
- // Auto-generated by scripts/schemagen.ts — do not edit by hand.
2
-
3
- import status from "./generated/status.json";
4
-
5
- /** JSON Schema for leaf outputSchema from `StatusJsonOutput`. */
6
- export const STATUS_JSON_OUTPUT_SCHEMA = status as Record<string, unknown>;
@@ -1,25 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { join } from "node:path";
3
- import { discoverSchemaRoots } from "./discover-schema-roots.ts";
4
-
5
- const projectRoot = join(import.meta.dir, "../..");
6
-
7
- describe("discover-schema-roots", () => {
8
- test("finds AppConfig and StatusJsonOutput in schema-types.ts files", () => {
9
- const roots = discoverSchemaRoots(projectRoot);
10
- const config = roots.find((r) => r.kind === "config");
11
- const output = roots.find((r) => r.kind === "output");
12
- expect(config).toMatchObject({
13
- typeName: "AppConfig",
14
- path: "src/config/schema-types.ts",
15
- outfile: "app-config.json",
16
- exportName: "APP_CONFIG_JSON_SCHEMA",
17
- });
18
- expect(output).toMatchObject({
19
- typeName: "StatusJsonOutput",
20
- path: "src/commands/status/schema-types.ts",
21
- outfile: "status.json",
22
- exportName: "STATUS_JSON_OUTPUT_SCHEMA",
23
- });
24
- });
25
- });
@@ -1,99 +0,0 @@
1
- /*
2
- Maps discovered schema root type names to generated filenames and bridge export constants.
3
- Matches conventions in docs/output-schema.md and docs/config-schema.md.
4
- */
5
-
6
- /** kebab-case from PascalCase segments. */
7
- export function camelToKebab(name: string): string {
8
- return name
9
- .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
10
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
11
- .toLowerCase();
12
- }
13
-
14
- /** SCREAMING_SNAKE from PascalCase. */
15
- export function camelToScreamingSnake(name: string): string {
16
- return camelToKebab(name).replace(/-/g, "_").toUpperCase();
17
- }
18
-
19
- /** Generated JSON filename for a tool-input schema root. */
20
- export function outfileForInputType(typeName: string): string {
21
- if (typeName.endsWith("ToolInput")) {
22
- return `${camelToKebab(typeName.slice(0, -"ToolInput".length))}-tool-input.json`;
23
- }
24
- return `${camelToKebab(typeName)}-tool-input.json`;
25
- }
26
-
27
- /** Bridge export constant for a tool-input schema root. */
28
- export function inputSchemaExportName(typeName: string): string {
29
- if (typeName.endsWith("ToolInput")) {
30
- const base = typeName.slice(0, -"ToolInput".length);
31
- return `${camelToScreamingSnake(base)}_TOOL_INPUT_SCHEMA`;
32
- }
33
- return `${camelToScreamingSnake(typeName)}_TOOL_INPUT_SCHEMA`;
34
- }
35
-
36
- /** Generated JSON filename for an output-schema root type. */
37
- export function outfileForOutputType(typeName: string): string {
38
- if (typeName.endsWith("JsonOutput")) {
39
- return `${camelToKebab(typeName.slice(0, -"JsonOutput".length))}.json`;
40
- }
41
- if (typeName.endsWith("OpResult")) {
42
- return `${camelToKebab(typeName.slice(0, -"OpResult".length))}-op-result.json`;
43
- }
44
- if (typeName.endsWith("Output")) {
45
- return `${camelToKebab(typeName.slice(0, -"Output".length))}.json`;
46
- }
47
- if (typeName.endsWith("Result")) {
48
- return `${camelToKebab(typeName.slice(0, -"Result".length))}.json`;
49
- }
50
- return `${camelToKebab(typeName)}.json`;
51
- }
52
-
53
- /** Bridge export constant for an output-schema root. */
54
- export function outputSchemaExportName(typeName: string): string {
55
- if (typeName.endsWith("JsonOutput")) {
56
- const base = typeName.slice(0, -"JsonOutput".length);
57
- return `${camelToScreamingSnake(base)}_JSON_OUTPUT_SCHEMA`;
58
- }
59
- if (typeName.endsWith("OpResult")) {
60
- const base = typeName.slice(0, -"OpResult".length);
61
- return `${camelToScreamingSnake(base)}_OP_RESULT_OUTPUT_SCHEMA`;
62
- }
63
- if (typeName.endsWith("Output")) {
64
- const base = typeName.slice(0, -"Output".length);
65
- return `${camelToScreamingSnake(base)}_OUTPUT_SCHEMA`;
66
- }
67
- if (typeName.endsWith("Result")) {
68
- const base = typeName.slice(0, -"Result".length);
69
- return `${camelToScreamingSnake(base)}_RESULT_OUTPUT_SCHEMA`;
70
- }
71
- return `${camelToScreamingSnake(typeName)}_OUTPUT_SCHEMA`;
72
- }
73
-
74
- /** Generated JSON filename for a config-schema root (typically AppConfig → app-config.json). */
75
- export function outfileForConfigType(typeName: string): string {
76
- if (typeName.endsWith("Config")) {
77
- return `${camelToKebab(typeName.slice(0, -"Config".length))}-config.json`;
78
- }
79
- return `${camelToKebab(typeName)}-config.json`;
80
- }
81
-
82
- /** Bridge export constant for a config-schema root (AppConfig → APP_CONFIG_JSON_SCHEMA). */
83
- export function configSchemaExportName(typeName: string): string {
84
- if (typeName.endsWith("Config")) {
85
- const base = typeName.slice(0, -"Config".length);
86
- return `${camelToScreamingSnake(base)}_CONFIG_JSON_SCHEMA`;
87
- }
88
- return `${camelToScreamingSnake(typeName)}_CONFIG_JSON_SCHEMA`;
89
- }
90
-
91
- /** Import path basename for a generated JSON file (no extension). */
92
- export function jsonImportBasename(outfile: string): string {
93
- return outfile.replace(/\.json$/, "");
94
- }
95
-
96
- /** Safe import binding for a generated JSON file (no extension, hyphens → underscores). */
97
- export function jsonImportVar(outfile: string): string {
98
- return jsonImportBasename(outfile).replace(/-/g, "_");
99
- }