swallowkit 2.0.0-alpha.2 → 2.0.0-alpha.4

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 (176) hide show
  1. package/dist/cli/commands/configure-mcp.d.ts +12 -0
  2. package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
  3. package/dist/cli/commands/configure-mcp.js +42 -0
  4. package/dist/cli/commands/configure-mcp.js.map +1 -0
  5. package/dist/cli/commands/dev.d.ts +2 -2
  6. package/dist/cli/commands/dev.d.ts.map +1 -1
  7. package/dist/cli/commands/dev.js +39 -27
  8. package/dist/cli/commands/dev.js.map +1 -1
  9. package/dist/cli/commands/init.d.ts +6 -2
  10. package/dist/cli/commands/init.d.ts.map +1 -1
  11. package/dist/cli/commands/init.js +43 -122
  12. package/dist/cli/commands/init.js.map +1 -1
  13. package/dist/cli/commands/migrations.d.ts.map +1 -1
  14. package/dist/cli/commands/migrations.js +25 -0
  15. package/dist/cli/commands/migrations.js.map +1 -1
  16. package/dist/cli/commands/provision.d.ts +2 -0
  17. package/dist/cli/commands/provision.d.ts.map +1 -1
  18. package/dist/cli/commands/provision.js +45 -16
  19. package/dist/cli/commands/provision.js.map +1 -1
  20. package/dist/cli/commands/scaffold.d.ts +4 -0
  21. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  22. package/dist/cli/commands/scaffold.js +40 -2
  23. package/dist/cli/commands/scaffold.js.map +1 -1
  24. package/dist/cli/index.d.ts +2 -0
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +27 -2
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/core/operations/model-migration.d.ts +22 -0
  29. package/dist/core/operations/model-migration.d.ts.map +1 -0
  30. package/dist/core/operations/model-migration.js +36 -0
  31. package/dist/core/operations/model-migration.js.map +1 -0
  32. package/dist/core/operations/provision-operations.d.ts +19 -1
  33. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  34. package/dist/core/operations/provision-operations.js +62 -35
  35. package/dist/core/operations/provision-operations.js.map +1 -1
  36. package/dist/core/operations/scaffold-apply.d.ts +1 -0
  37. package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
  38. package/dist/core/operations/scaffold-apply.js +1 -0
  39. package/dist/core/operations/scaffold-apply.js.map +1 -1
  40. package/dist/core/operations/scaffold-machine.d.ts +1 -0
  41. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  42. package/dist/core/operations/scaffold-machine.js.map +1 -1
  43. package/dist/core/operations/scaffold-plan.d.ts +2 -0
  44. package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
  45. package/dist/core/operations/scaffold-plan.js +3 -1
  46. package/dist/core/operations/scaffold-plan.js.map +1 -1
  47. package/dist/core/project/agent-instructions.d.ts.map +1 -1
  48. package/dist/core/project/agent-instructions.js +4 -1
  49. package/dist/core/project/agent-instructions.js.map +1 -1
  50. package/dist/core/project/boundaries.d.ts +5 -2
  51. package/dist/core/project/boundaries.d.ts.map +1 -1
  52. package/dist/core/project/boundaries.js +49 -18
  53. package/dist/core/project/boundaries.js.map +1 -1
  54. package/dist/core/project/capabilities.d.ts +1 -0
  55. package/dist/core/project/capabilities.d.ts.map +1 -1
  56. package/dist/core/project/capabilities.js +20 -2
  57. package/dist/core/project/capabilities.js.map +1 -1
  58. package/dist/core/project/home-page.d.ts +4 -0
  59. package/dist/core/project/home-page.d.ts.map +1 -0
  60. package/dist/core/project/home-page.js +113 -0
  61. package/dist/core/project/home-page.js.map +1 -0
  62. package/dist/core/project/mcp-config.d.ts +26 -0
  63. package/dist/core/project/mcp-config.d.ts.map +1 -0
  64. package/dist/core/project/mcp-config.js +117 -0
  65. package/dist/core/project/mcp-config.js.map +1 -0
  66. package/dist/core/project/react-project.d.ts +1 -1
  67. package/dist/core/project/react-project.d.ts.map +1 -1
  68. package/dist/core/project/react-project.js +45 -24
  69. package/dist/core/project/react-project.js.map +1 -1
  70. package/dist/core/project/workflows.d.ts.map +1 -1
  71. package/dist/core/project/workflows.js +9 -1
  72. package/dist/core/project/workflows.js.map +1 -1
  73. package/dist/core/scaffold/auth-generator.js +8 -8
  74. package/dist/core/scaffold/model-parser.d.ts +23 -0
  75. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  76. package/dist/core/scaffold/model-parser.js +407 -137
  77. package/dist/core/scaffold/model-parser.js.map +1 -1
  78. package/dist/core/scaffold/native-schema-generator.d.ts +11 -0
  79. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  80. package/dist/core/scaffold/native-schema-generator.js +279 -91
  81. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  82. package/dist/core/scaffold/openapi-generator.d.ts +5 -0
  83. package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
  84. package/dist/core/scaffold/openapi-generator.js +53 -3
  85. package/dist/core/scaffold/openapi-generator.js.map +1 -1
  86. package/dist/core/scaffold/ui-generator.d.ts +0 -3
  87. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  88. package/dist/core/scaffold/ui-generator.js +68 -7
  89. package/dist/core/scaffold/ui-generator.js.map +1 -1
  90. package/dist/core/verify/index.d.ts.map +1 -1
  91. package/dist/core/verify/index.js +24 -1
  92. package/dist/core/verify/index.js.map +1 -1
  93. package/dist/machine/index.d.ts.map +1 -1
  94. package/dist/machine/index.js +39 -15
  95. package/dist/machine/index.js.map +1 -1
  96. package/dist/mcp/index.d.ts +3 -2
  97. package/dist/mcp/index.d.ts.map +1 -1
  98. package/dist/mcp/index.js +137 -13
  99. package/dist/mcp/index.js.map +1 -1
  100. package/dist/mcp/version-selection.d.ts +55 -0
  101. package/dist/mcp/version-selection.d.ts.map +1 -0
  102. package/dist/mcp/version-selection.js +152 -0
  103. package/dist/mcp/version-selection.js.map +1 -0
  104. package/dist/utils/azure-cli.d.ts +44 -0
  105. package/dist/utils/azure-cli.d.ts.map +1 -0
  106. package/dist/utils/azure-cli.js +164 -0
  107. package/dist/utils/azure-cli.js.map +1 -0
  108. package/dist/utils/package-manager.js +1 -1
  109. package/dist/utils/package-manager.js.map +1 -1
  110. package/dist/utils/python-uv.d.ts +5 -7
  111. package/dist/utils/python-uv.d.ts.map +1 -1
  112. package/dist/utils/python-uv.js +50 -24
  113. package/dist/utils/python-uv.js.map +1 -1
  114. package/dist/utils/react-dev.d.ts +4 -0
  115. package/dist/utils/react-dev.d.ts.map +1 -0
  116. package/dist/utils/react-dev.js +35 -0
  117. package/dist/utils/react-dev.js.map +1 -0
  118. package/dist/utils/semver.d.ts +14 -0
  119. package/dist/utils/semver.d.ts.map +1 -0
  120. package/dist/utils/semver.js +61 -0
  121. package/dist/utils/semver.js.map +1 -0
  122. package/package.json +1 -1
  123. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
  124. package/src/__tests__/agent-loop-phase34.test.ts +29 -0
  125. package/src/__tests__/auth.test.ts +9 -0
  126. package/src/__tests__/azure-cli.test.ts +199 -0
  127. package/src/__tests__/boundaries.test.ts +42 -0
  128. package/src/__tests__/csharp-schema.test.ts +261 -0
  129. package/src/__tests__/dev.test.ts +13 -6
  130. package/src/__tests__/home-page.test.ts +50 -0
  131. package/src/__tests__/init.test.ts +57 -0
  132. package/src/__tests__/mcp-version.test.ts +262 -0
  133. package/src/__tests__/mcp.test.ts +49 -3
  134. package/src/__tests__/package-manager.test.ts +2 -2
  135. package/src/__tests__/plan-apply.test.ts +68 -0
  136. package/src/__tests__/python-uv.test.ts +50 -14
  137. package/src/__tests__/python-venv.test.ts +48 -0
  138. package/src/__tests__/react-dev.test.ts +72 -0
  139. package/src/__tests__/react.test.ts +19 -0
  140. package/src/__tests__/schema-analysis.test.ts +329 -0
  141. package/src/cli/commands/configure-mcp.ts +54 -0
  142. package/src/cli/commands/dev.ts +40 -31
  143. package/src/cli/commands/init.ts +48 -125
  144. package/src/cli/commands/migrations.ts +22 -0
  145. package/src/cli/commands/provision.ts +53 -17
  146. package/src/cli/commands/scaffold.ts +40 -2
  147. package/src/cli/index.ts +26 -3
  148. package/src/core/operations/model-migration.ts +52 -0
  149. package/src/core/operations/provision-operations.ts +86 -42
  150. package/src/core/operations/scaffold-apply.ts +2 -0
  151. package/src/core/operations/scaffold-machine.ts +1 -0
  152. package/src/core/operations/scaffold-plan.ts +5 -1
  153. package/src/core/project/agent-instructions.ts +4 -1
  154. package/src/core/project/agent-templates/AGENTS.md +39 -3
  155. package/src/core/project/agent-templates/CLAUDE.md +3 -1
  156. package/src/core/project/agent-templates/copilot-instructions.md +3 -1
  157. package/src/core/project/boundaries.ts +60 -18
  158. package/src/core/project/capabilities.ts +22 -2
  159. package/src/core/project/home-page.ts +114 -0
  160. package/src/core/project/mcp-config.ts +84 -0
  161. package/src/core/project/react-project.ts +49 -27
  162. package/src/core/project/workflows.ts +9 -1
  163. package/src/core/scaffold/auth-generator.ts +8 -8
  164. package/src/core/scaffold/model-parser.ts +413 -129
  165. package/src/core/scaffold/native-schema-generator.ts +305 -105
  166. package/src/core/scaffold/openapi-generator.ts +63 -5
  167. package/src/core/scaffold/ui-generator.ts +70 -8
  168. package/src/core/verify/index.ts +23 -1
  169. package/src/machine/index.ts +42 -18
  170. package/src/mcp/index.ts +156 -14
  171. package/src/mcp/version-selection.ts +163 -0
  172. package/src/utils/azure-cli.ts +154 -0
  173. package/src/utils/package-manager.ts +1 -1
  174. package/src/utils/python-uv.ts +53 -33
  175. package/src/utils/react-dev.ts +31 -0
  176. package/src/utils/semver.ts +59 -0
@@ -0,0 +1,48 @@
1
+ import { execFile } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { ensureUvVirtualEnvironment, getVirtualEnvPythonPath } from "../utils/python-uv";
5
+
6
+ let mockInterpreterError: Error | null = null;
7
+ jest.mock("child_process", () => ({
8
+ execFile: jest.fn((
9
+ _command: string,
10
+ args: string[],
11
+ _options: object,
12
+ callback: (error: Error | null, stdout: string, stderr: string) => void
13
+ ) => callback(args[0] === "--version" ? mockInterpreterError : null, "Python 3.15.1\n", "")),
14
+ }));
15
+ jest.mock("fs", () => ({ existsSync: jest.fn() }));
16
+
17
+ describe("uv virtual environment lifecycle", () => {
18
+ const cwd = path.resolve("python-fixture", "functions");
19
+ const venv = path.join(cwd, ".venv");
20
+ const env = { UV_PYTHON: "3.15", UV_PYTHON_DOWNLOADS: "never" };
21
+
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+ mockInterpreterError = null;
25
+ });
26
+
27
+ it("creates with normal uv selection, then checks that Python can actually execute", async () => {
28
+ jest.mocked(fs.existsSync).mockReturnValue(false);
29
+ await expect(ensureUvVirtualEnvironment("uv", venv, cwd, env)).resolves.toBe(getVirtualEnvPythonPath(venv));
30
+ expect(execFile).toHaveBeenNthCalledWith(1, "uv", ["venv", venv], { cwd, env }, expect.any(Function));
31
+ expect(execFile).toHaveBeenNthCalledWith(2, getVirtualEnvPythonPath(venv), ["--version"], { cwd, env }, expect.any(Function));
32
+ });
33
+
34
+ it("reuses an existing executable environment without changing its interpreter", async () => {
35
+ jest.mocked(fs.existsSync).mockReturnValue(true);
36
+ await ensureUvVirtualEnvironment("uv", venv, cwd, env);
37
+ expect(execFile).toHaveBeenCalledTimes(1);
38
+ expect(execFile).toHaveBeenCalledWith(getVirtualEnvPythonPath(venv), ["--version"], { cwd, env }, expect.any(Function));
39
+ });
40
+
41
+ it.each([true, false])("reports blocked Python after checking the environment (existing=%s)", async exists => {
42
+ jest.mocked(fs.existsSync).mockReturnValue(exists);
43
+ mockInterpreterError = new Error("Application control blocked this file (os error 4551)");
44
+ await expect(ensureUvVirtualEnvironment("uv", venv, cwd, env)).rejects.toThrow("os error 4551");
45
+ expect(jest.mocked(execFile).mock.calls.some(call => call[1]?.includes("--clear"))).toBe(false);
46
+ expect(execFile).toHaveBeenCalledTimes(exists ? 1 : 2);
47
+ });
48
+ });
@@ -0,0 +1,72 @@
1
+ import ts from "typescript";
2
+ import {
3
+ buildReactDevConfigSource,
4
+ buildReactDevWatchIgnoredSource,
5
+ } from "../utils/react-dev";
6
+ import { buildReactDevArgs } from "../cli/commands/dev";
7
+
8
+ describe("React dev watch boundaries", () => {
9
+ it.each(["C:\\repo\\", "C:\\repo", "/repo/"])(
10
+ "ignores backend artifacts without excluding frontend or shared sources under %s",
11
+ root => {
12
+ const ignored: string[] = new Function("root", `return ${buildReactDevWatchIgnoredSource("root")}`)(root);
13
+ const normalized = root.replace(/\\/g, "/").replace(/\/$/, "");
14
+ expect(ignored).toEqual([
15
+ `${normalized}/functions/**`,
16
+ `${normalized}/bff/**`,
17
+ `${normalized}/.uv/**`,
18
+ `${normalized}/.swallowkit/**`,
19
+ `${normalized}/shared/dist/**`,
20
+ ]);
21
+ expect(ignored.some(value => value.includes("/src/") || value.includes("/shared/models/"))).toBe(false);
22
+ }
23
+ );
24
+
25
+ function loadWrapper(config: object) {
26
+ const loadConfigFromFile = jest.fn(async () => ({
27
+ config,
28
+ dependencies: ["/repo/vite.config.mts", "/repo/vite-settings.ts"],
29
+ }));
30
+ const mergeConfig = jest.fn((base: object, overrides: object) => ({ base, overrides }));
31
+ const result = { exports: {} as { default: (env: object) => Promise<unknown> } };
32
+ const source = ts.transpileModule(buildReactDevConfigSource(), {
33
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
34
+ }).outputText;
35
+ new Function("require", "exports", source)(
36
+ () => ({ defineConfig: (value: unknown) => value, loadConfigFromFile, mergeConfig }),
37
+ result.exports
38
+ );
39
+ return { load: result.exports.default, loadConfigFromFile, mergeConfig };
40
+ }
41
+
42
+ it("loads the user's Vite config and merges rather than replacing their watch options", async () => {
43
+ const config = { plugins: ["user-plugin"], server: { watch: { ignored: ["**/custom/**"], usePolling: true } } };
44
+ const wrapper = loadWrapper(config);
45
+ const env = { command: "serve", mode: "development" };
46
+ await wrapper.load(env);
47
+ expect(wrapper.loadConfigFromFile).toHaveBeenCalledWith(env, undefined, process.cwd());
48
+ expect(wrapper.mergeConfig).toHaveBeenCalledWith(config, {
49
+ plugins: [expect.objectContaining({ name: "swallowkit-dev-watch-boundaries" })],
50
+ server: { watch: { ignored: expect.arrayContaining([expect.stringContaining("/functions/**")]) } },
51
+ });
52
+ const plugin = wrapper.mergeConfig.mock.calls[0][1] as {
53
+ plugins: Array<{ configResolved: (config: { configFileDependencies: string[] }) => void }>;
54
+ };
55
+ const resolved = { configFileDependencies: ["/repo/wrapper.mjs"] };
56
+ plugin.plugins[0].configResolved(resolved);
57
+ expect(resolved.configFileDependencies).toContain("/repo/vite.config.mts");
58
+ });
59
+
60
+ it("respects an explicitly disabled watcher", async () => {
61
+ const config = { server: { watch: null } };
62
+ const wrapper = loadWrapper(config);
63
+ await expect(wrapper.load({})).resolves.toBe(config);
64
+ expect(wrapper.mergeConfig).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it("passes the override config to existing projects without editing their Vite config", () => {
68
+ expect(buildReactDevArgs("pnpm", "3100", "127.0.0.1", ".swallowkit/config.mjs")).toEqual([
69
+ "exec", "vite", "--port", "3100", "--host", "127.0.0.1", "--strictPort", "--config", ".swallowkit/config.mjs",
70
+ ]);
71
+ });
72
+ });
@@ -14,6 +14,7 @@ import {
14
14
  createReactBffProject,
15
15
  buildReactSwaConfig,
16
16
  } from "../core/project/react-project";
17
+ import { buildHomePage } from "../core/project/home-page";
17
18
  import {
18
19
  generateManagedFunctionConfig,
19
20
  generateReactBffHelper,
@@ -505,6 +506,24 @@ describe("React project integration", () => {
505
506
  ).toBe(false);
506
507
  });
507
508
 
509
+ it("generates the shared home page with the selected package manager and Tailwind defaults", () => {
510
+ createReactProject(project, "sample", "npm");
511
+ expect(fs.readFileSync(path.join(project, "src", "Home.tsx"), "utf8"))
512
+ .toBe(buildHomePage("react", "npm"));
513
+
514
+ const css = fs.readFileSync(path.join(project, "src", "style.css"), "utf8");
515
+ expect(css).toContain('@import "tailwindcss"');
516
+ expect(css).toContain("@media (prefers-color-scheme: dark)");
517
+ expect(css).toContain("--background: #ffffff");
518
+ expect(css).toContain("--background: #0a0a0a");
519
+ expect(css).toContain("--color-background: var(--background)");
520
+ expect(css).toContain("font-family: Arial, Helvetica, sans-serif");
521
+ expect(fs.readFileSync(path.join(project, "index.html"), "utf8"))
522
+ .toContain('class="h-full antialiased"');
523
+ expect(fs.readFileSync(path.join(project, "vite.config.mts"), "utf8"))
524
+ .toContain("plugins: [react(), tailwindcss()]");
525
+ });
526
+
508
527
  it("plans and applies BFF bindings, React pages and metadata through the common path", async () => {
509
528
  const plan = await planScaffoldOperation({ model: "todo" });
510
529
  expect(plan.requiresApproval).toBe(false);
@@ -0,0 +1,329 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { parseModelFile } from "../core/scaffold/model-parser";
5
+ import { generateOpenApiDocument } from "../core/scaffold/openapi-generator";
6
+ import { generateListPage, generateFormComponent } from "../core/scaffold/ui-generator";
7
+ import { generateLanguageSchemaArtifacts, getPythonRegexWarnings, toPythonRegex } from "../core/scaffold/native-schema-generator";
8
+ import { generateDetailPage } from "../core/scaffold/ui-generator";
9
+ import { spawnSync } from "child_process";
10
+ import { FileOperationSession } from "../core/operations/file-session";
11
+ import { buildUserScopedDataWarning } from "../cli/commands/scaffold";
12
+ import { inspectCapabilities } from "../core/project/capabilities";
13
+
14
+ const repoRoot = path.resolve(__dirname, "..", "..");
15
+
16
+ function model(name: string, fields: string, extra = ""): string {
17
+ const pascal = name.replace(/(^|-)([a-z])/g, (_, __, char: string) => char.toUpperCase());
18
+ return `import { z } from 'zod/v4';
19
+ export const ${pascal} = z.object({
20
+ id: z.string(),
21
+ ${fields}
22
+ });
23
+ export type ${pascal} = z.infer<typeof ${pascal}>;
24
+ ${extra}`;
25
+ }
26
+
27
+ describe("foreign-key inference", () => {
28
+ let root: string;
29
+ const write = (name: string, source: string) => {
30
+ const file = path.join(root, "shared", "models", `${name}.ts`);
31
+ fs.mkdirSync(path.dirname(file), { recursive: true });
32
+ fs.writeFileSync(file, source);
33
+ return file;
34
+ };
35
+
36
+ beforeEach(() => {
37
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-fk-"));
38
+ });
39
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
40
+
41
+ it("does not treat userId as a foreign key when no User model exists", async () => {
42
+ const info = await parseModelFile(write("probe-item", model("probe-item", " userId: z.string(),\n title: z.string(),", "export const partitionKey = '/userId';\n")));
43
+ const userId = info.fields.find((field) => field.name === "userId")!;
44
+ expect(userId.isForeignKey).toBeUndefined();
45
+ expect(userId.referencedModel).toBeUndefined();
46
+ expect(info.parserWarnings).toEqual([]);
47
+
48
+ const list = generateListPage(info, "@sample/shared");
49
+ const form = generateFormComponent(info, "@sample/shared");
50
+ expect(list).not.toMatch(/api\/user|userMap/);
51
+ expect(form).not.toMatch(/api\/user|userOptions/);
52
+ const schema = JSON.parse(generateOpenApiDocument([info], info)).components.schemas;
53
+ expect(schema.ProbeItem.properties.userId).toEqual({ type: "string" });
54
+ expect(Object.keys(schema)).toEqual(["ProbeItem"]);
55
+ });
56
+
57
+ it("infers a foreign key when the referenced model exists", async () => {
58
+ write("category", model("category", " name: z.string(),"));
59
+ const info = await parseModelFile(write("todo", model("todo", " categoryId: z.string(),")));
60
+ expect(info.fields.find((field) => field.name === "categoryId")).toMatchObject({ isForeignKey: true, referencedModel: "Category" });
61
+ expect(generateListPage(info, "@sample/shared")).toContain("/api/category");
62
+ });
63
+
64
+ it("supports explicit references, opt-out, and warns about missing targets", async () => {
65
+ write("user", model("user", " name: z.string(),"));
66
+ const info = await parseModelFile(write("post", model(
67
+ "post",
68
+ " authorId: z.string(),\n userId: z.string(),\n reviewerId: z.string(),\n tagIds: z.array(z.string()),",
69
+ "export const references = { authorId: 'User', userId: false, reviewerId: 'Reviewer', tagIds: 'Tag', missingId: 'User' };\n"
70
+ )));
71
+ const field = (name: string) => info.fields.find((candidate) => candidate.name === name)!;
72
+ expect(field("authorId")).toMatchObject({ isForeignKey: true, referencedModel: "User" });
73
+ expect(field("userId").isForeignKey).toBeUndefined();
74
+ expect(field("reviewerId").isForeignKey).toBeUndefined();
75
+ expect(field("tagIds").isForeignKey).toBeUndefined();
76
+ expect(info.parserWarnings).toEqual([
77
+ "reference-model-not-found: Post.reviewerId references Reviewer, but shared/models has no Reviewer model; it is treated as a plain field.",
78
+ "reference-field-unsupported: Post.tagIds must be a single string id to reference Tag.",
79
+ "reference-field-not-found: Post.references declares missingId, but the schema has no such field.",
80
+ ]);
81
+ });
82
+
83
+ it("declares one lookup per referenced model when several fields reference it", async () => {
84
+ write("user", model("user", " name: z.string(),"));
85
+ const info = await parseModelFile(write("review", model(
86
+ "review",
87
+ " authorId: z.string(),\n reviewerId: z.string(),",
88
+ "export const references = { authorId: 'User', reviewerId: 'User' };\n"
89
+ )));
90
+ const count = (source: string, text: string) => source.split(text).length - 1;
91
+ for (const source of [generateListPage(info, "@sample/shared"), generateDetailPage(info, "@sample/shared")]) {
92
+ expect(count(source, "const [userMap, setUserMap]")).toBe(1);
93
+ expect(count(source, "fetch('/api/user')")).toBe(1);
94
+ expect(source).toContain("userMap[item.authorId]".replace("item", source.includes("ListPage") ? "item" : "review"));
95
+ }
96
+ const form = generateFormComponent(info, "@sample/shared");
97
+ expect(count(form, "const [userOptions, setUserOptions]")).toBe(1);
98
+ expect(count(form, "fetch('/api/user')")).toBe(1);
99
+ expect(form).toContain('id="authorId"');
100
+ expect(form).toContain('id="reviewerId"');
101
+ });
102
+
103
+ it("documents the references declaration in capabilities", () => {
104
+ expect(inspectCapabilities().modelDeclarations.map((declaration) => declaration.name)).toContain("references");
105
+ });
106
+ });
107
+
108
+ describe("dynamic schema evaluation metadata", () => {
109
+ let root: string;
110
+ beforeEach(() => {
111
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-dynamic-"));
112
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "sample", type: "module" }));
113
+ fs.mkdirSync(path.join(root, "node_modules"), { recursive: true });
114
+ fs.symlinkSync(path.join(repoRoot, "node_modules", "zod"), path.join(root, "node_modules", "zod"), "junction");
115
+ });
116
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
117
+
118
+ it("extracts formats, integers, constraints, and inline objects from Zod v4 at runtime", async () => {
119
+ const file = path.join(root, "shared", "models", "probe-item.ts");
120
+ fs.mkdirSync(path.dirname(file), { recursive: true });
121
+ // startsWith() is outside the static subset, which forces runtime evaluation.
122
+ fs.writeFileSync(file, model("probe-item", [
123
+ " userId: z.string().startsWith('u'),",
124
+ " title: z.string().min(1).max(5),",
125
+ " code: z.string().regex(/^a\\d$/i),",
126
+ " tags: z.array(z.string().min(2)).min(1),",
127
+ " score: z.number().gt(0).lte(1),",
128
+ " edge: z.number().gt(0).min(0).lt(1).max(1),",
129
+ " count: z.int(),",
130
+ " publishedAt: z.iso.datetime().optional(),",
131
+ " createdAt: z.string().optional(),",
132
+ " nested: z.object({ x: z.string() }).optional(),",
133
+ ].join("\n")));
134
+
135
+ const info = await parseModelFile(file);
136
+ const field = (name: string) => info.fields.find((candidate) => candidate.name === name)!;
137
+ expect(info.parserMode).toBe("dynamic");
138
+ expect(field("userId").isForeignKey).toBeUndefined();
139
+ expect(field("title").constraints).toEqual({ minLength: 1, maxLength: 5 });
140
+ expect(field("code").constraints).toEqual({ pattern: "^a\\d$", patternFlags: "i" });
141
+ expect(field("tags")).toMatchObject({ isArray: true, constraints: { minLength: 2, minItems: 1 } });
142
+ expect(field("score").constraints).toEqual({ minimum: 0, exclusiveMinimum: true, maximum: 1 });
143
+ expect(field("edge").constraints).toEqual({ minimum: 0, exclusiveMinimum: true, maximum: 1, exclusiveMaximum: true });
144
+ expect(field("count").isInteger).toBe(true);
145
+ expect(field("publishedAt")).toMatchObject({ format: "date-time", isOptional: true });
146
+ expect(field("createdAt").format).toBeUndefined();
147
+ expect(field("nested")).toMatchObject({ type: "object", isOptional: true, objectFields: [expect.objectContaining({ name: "x", type: "string" })] });
148
+ });
149
+
150
+ it("extracts the same metadata from Zod v3 schemas", async () => {
151
+ const file = path.join(root, "shared", "models", "legacy-item.ts");
152
+ fs.mkdirSync(path.dirname(file), { recursive: true });
153
+ fs.writeFileSync(file, model("legacy-item", [
154
+ " title: z.string().startsWith('t').min(2),",
155
+ " score: z.number().int().positive(),",
156
+ " publishedAt: z.string().datetime().optional(),",
157
+ " kind: z.enum(['a', 'b']),",
158
+ " nested: z.object({ x: z.string().max(3) }),",
159
+ ].join("\n")).replace("from 'zod/v4'", "from 'zod'"));
160
+
161
+ const info = await parseModelFile(file);
162
+ const field = (name: string) => info.fields.find((candidate) => candidate.name === name)!;
163
+ expect(info.parserMode).toBe("dynamic");
164
+ expect(field("title").constraints).toEqual({ minLength: 2 });
165
+ expect(field("score")).toMatchObject({ isInteger: true, constraints: { minimum: 0, exclusiveMinimum: true } });
166
+ expect(field("publishedAt").format).toBe("date-time");
167
+ expect(field("kind").enumValues).toEqual(["a", "b"]);
168
+ expect(field("nested").objectFields).toEqual([expect.objectContaining({ name: "x", constraints: { maxLength: 3 } })]);
169
+ });
170
+ });
171
+
172
+ describe("Python schema assets", () => {
173
+ let root: string;
174
+ const originalCwd = process.cwd();
175
+ beforeEach(() => {
176
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-python-schema-"));
177
+ });
178
+ afterEach(() => {
179
+ process.chdir(originalCwd);
180
+ fs.rmSync(root, { recursive: true, force: true });
181
+ });
182
+
183
+ it("keeps declared types and constraints instead of guessing from names", async () => {
184
+ const file = path.join(root, "shared", "models", "probe-item.ts");
185
+ fs.mkdirSync(path.dirname(file), { recursive: true });
186
+ fs.writeFileSync(file, model("probe-item", [
187
+ " title: z.string().min(1),",
188
+ " code: z.string().regex(/^a\\d$/i).optional(),",
189
+ " tags: z.array(z.string().min(2)).max(3),",
190
+ " score: z.number().min(0).max(1),",
191
+ " count: z.number().int(),",
192
+ " publishedAt: z.iso.datetime().optional(),",
193
+ " createdAt: z.string().optional(),",
194
+ " nested: z.object({ x: z.string() }).optional(),",
195
+ ].join("\n")));
196
+ const info = await parseModelFile(file);
197
+ process.chdir(root);
198
+ await generateLanguageSchemaArtifacts([info], info, "functions", "python", new FileOperationSession("commit", root));
199
+ const modelsDir = path.join(root, "functions", "generated", "python-models", "backend_models", "models");
200
+ const source = fs.readFileSync(path.join(modelsDir, "probe_item.py"), "utf8");
201
+
202
+ expect(source).toContain("created_at: Optional[StrictStr] = Field(default=None, alias=\"createdAt\")");
203
+ expect(source).toContain("published_at: Optional[datetime] = Field(default=None, alias=\"publishedAt\")");
204
+ expect(source).toContain("title: StrictStr = Field(min_length=1)");
205
+ expect(source).toContain("tags: List[Annotated[StrictStr, Field(min_length=2)]] = Field(max_length=3)");
206
+ expect(source).toContain("score: Union[StrictFloat, StrictInt] = Field(ge=0, le=1)");
207
+ expect(source).toContain("count: StrictInt");
208
+ expect(source).toContain("nested: Optional[ProbeItemNested] = Field(default=None)");
209
+ expect(source).toContain('re.search("^a\\\\d$", item, re.IGNORECASE)');
210
+ expect(source).toContain("return self.model_dump_json(by_alias=True, exclude_none=True)");
211
+ expect(fs.readFileSync(path.join(modelsDir, "probe_item_nested.py"), "utf8")).toContain("class ProbeItemNested(BaseModel):");
212
+ });
213
+ });
214
+
215
+ describe("Python regex translation", () => {
216
+ it("translates JavaScript named groups and back-references", () => {
217
+ expect(toPythonRegex("^(?<code>[A-Z]+)-\\k<code>$")).toEqual({ pattern: "^(?P<code>[A-Z]+)-(?P=code)$" });
218
+ expect(toPythonRegex("^a\\d$", "i")).toEqual({ pattern: "^a\\d$" });
219
+ });
220
+
221
+ it.each([
222
+ ["\\p{L}+", "", /Unicode property/],
223
+ ["(?<=a)b", "", /lookbehind/],
224
+ ["[^]", "", /empty character classes/],
225
+ ["\\u{1F600}", "u", /flag\(s\) u|code point/],
226
+ ["abc", "y", /flag\(s\) y/],
227
+ ])("reports %s /%s as not enforceable", (pattern, flags, reason) => {
228
+ const result = toPythonRegex(pattern, flags);
229
+ expect("unsupported" in result && result.unsupported).toMatch(reason);
230
+ });
231
+
232
+ it("skips unenforceable patterns with a warning and validates raw strings before conversion", async () => {
233
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-python-regex-"));
234
+ const originalCwd = process.cwd();
235
+ try {
236
+ const file = path.join(root, "shared", "models", "regex-item.ts");
237
+ fs.mkdirSync(path.dirname(file), { recursive: true });
238
+ fs.writeFileSync(file, model("regex-item", [
239
+ " code: z.string().regex(/^(?<code>[A-Z]+)$/),",
240
+ " letters: z.string().regex(/^\\p{L}+$/u),",
241
+ " when: z.string().datetime().regex(/Z$/),",
242
+ ].join("\n")));
243
+ const info = await parseModelFile(file);
244
+ expect(getPythonRegexWarnings(info)).toEqual([
245
+ expect.stringMatching(/^python-regex-not-enforced: RegexItem\.letters uses Unicode property escapes/),
246
+ ]);
247
+ process.chdir(root);
248
+ const session = new FileOperationSession("commit", root);
249
+ await generateLanguageSchemaArtifacts([info], info, "functions", "python", session);
250
+ expect(session.warnings).toEqual(getPythonRegexWarnings(info));
251
+ const source = fs.readFileSync(path.join(root, "functions", "generated", "python-models", "backend_models", "models", "regex_item.py"), "utf8");
252
+ expect(source).toContain('re.search("^(?P<code>[A-Z]+)$", item, 0)');
253
+ expect(source).not.toContain("letters_validate_pattern");
254
+ expect(source).toContain("@field_validator('when', mode='before')");
255
+ expect(source).toContain("if isinstance(item, str) and not re.search");
256
+ } finally {
257
+ process.chdir(originalCwd);
258
+ fs.rmSync(root, { recursive: true, force: true });
259
+ }
260
+ });
261
+ });
262
+
263
+ const pydanticPython = ["python", "python3"].find((command) => spawnSync(command, ["-c", "import pydantic"], { stdio: "ignore" }).status === 0);
264
+ const itWithPydantic = pydanticPython ? it : it.skip;
265
+
266
+ describe("generated Python model runtime", () => {
267
+ itWithPydantic("accepts valid regex and timestamp values and rejects invalid ones", async () => {
268
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-python-runtime-"));
269
+ const originalCwd = process.cwd();
270
+ try {
271
+ const file = path.join(root, "shared", "models", "regex-item.ts");
272
+ fs.mkdirSync(path.dirname(file), { recursive: true });
273
+ fs.writeFileSync(file, model("regex-item", [
274
+ " code: z.string().regex(/^(?<code>[A-Z]+)$/),",
275
+ " when: z.iso.datetime().regex(/Z$/),",
276
+ " tags: z.array(z.string().regex(/^t/)),",
277
+ ].join("\n")));
278
+ const info = await parseModelFile(file);
279
+ process.chdir(root);
280
+ await generateLanguageSchemaArtifacts([info], info, "functions", "python", new FileOperationSession("commit", root));
281
+ process.chdir(originalCwd);
282
+ const script = [
283
+ "import sys",
284
+ "sys.path.insert(0, 'generated/python-models')",
285
+ "from backend_models.models.regex_item import RegexItem",
286
+ "ok = {'id': '1', 'code': 'ABC', 'when': '2024-05-06T07:08:09Z', 'tags': ['t1']}",
287
+ "RegexItem.from_dict(ok)",
288
+ "for bad in ({**ok, 'code': 'abc'}, {**ok, 'when': '2024-05-06T07:08:09+09:00'}, {**ok, 'tags': ['x']}):",
289
+ " try:",
290
+ " RegexItem.from_dict(bad)",
291
+ " except ValueError:",
292
+ " continue",
293
+ " raise SystemExit('accepted ' + repr(bad))",
294
+ "print('ok')",
295
+ ].join("\n");
296
+ const run = spawnSync(pydanticPython!, ["-c", script], { cwd: path.join(root, "functions"), encoding: "utf8" });
297
+ if (run.status !== 0) throw new Error(`${run.stdout}\n${run.stderr}`);
298
+ expect(run.stdout.trim()).toBe("ok");
299
+ } finally {
300
+ process.chdir(originalCwd);
301
+ fs.rmSync(root, { recursive: true, force: true });
302
+ }
303
+ }, 60000);
304
+ });
305
+
306
+ describe("user-scoped data warning", () => {
307
+ const info = (partitionKey: string, fields: string[]) => ({
308
+ name: "ProbeItem",
309
+ partitionKey,
310
+ fields: fields.map((name) => ({ name, type: "string", isOptional: false, isArray: false })),
311
+ }) as unknown as Parameters<typeof buildUserScopedDataWarning>[0];
312
+
313
+ it("warns when the partition key is a per-user id and auth is not configured", () => {
314
+ expect(buildUserScopedDataWarning(info("/userId", ["id", "userId"]), undefined))
315
+ .toMatch(/^user-scoped-data-without-auth: ProbeItem\.userId is the partition key and looks like a per-user owner id/);
316
+ expect(buildUserScopedDataWarning(info("/userId", ["id", "userId"]), { provider: "none" })).toBeDefined();
317
+ });
318
+
319
+ it("warns for owner-like fields that are not the partition key", () => {
320
+ expect(buildUserScopedDataWarning(info("/id", ["id", "learnerId"]), undefined))
321
+ .toMatch(/ProbeItem\.learnerId looks like a per-user owner id/);
322
+ });
323
+
324
+ it("stays quiet when authentication is configured or nothing looks user-scoped", () => {
325
+ expect(buildUserScopedDataWarning(info("/userId", ["id", "userId"]), { provider: "swa" })).toBeUndefined();
326
+ expect(buildUserScopedDataWarning(info("/userId", ["id", "userId"]), { schemes: { admin: { provider: "swa" } } } as never)).toBeUndefined();
327
+ expect(buildUserScopedDataWarning(info("/tenantId", ["id", "tenantId", "categoryId"]), undefined)).toBeUndefined();
328
+ });
329
+ });
@@ -0,0 +1,54 @@
1
+ import { Command } from "commander";
2
+ import { ensureSwallowKitProject } from "../../core/config";
3
+ import {
4
+ MCP_CONFIG_PATH,
5
+ MCP_LATEST,
6
+ lookupPublishedLatestVersion,
7
+ readMcpConfigVersion,
8
+ resolveMcpPinVersion,
9
+ writeMcpConfigVersion,
10
+ } from "../../core/project/mcp-config";
11
+ import { resolveProjectSwallowKit } from "../../mcp/version-selection";
12
+ import { parseSemver } from "../../utils/semver";
13
+ import { getSwallowKitVersion } from "../../version";
14
+
15
+ export interface ConfigureMcpResult {
16
+ previousVersion: string | null;
17
+ version: string;
18
+ reason: string;
19
+ }
20
+
21
+ export function configureMcp(
22
+ options: { version?: string; cwd?: string },
23
+ lookupLatest: (packageName: string) => string | null = lookupPublishedLatestVersion
24
+ ): ConfigureMcpResult {
25
+ const cwd = options.cwd ?? process.cwd();
26
+ const requested = (options.version ?? "auto").trim();
27
+ const previousVersion = readMcpConfigVersion(cwd);
28
+
29
+ let decision: { version: string; reason: string };
30
+ if (requested === "auto") {
31
+ const projectVersion = resolveProjectSwallowKit(cwd)?.version ?? getSwallowKitVersion();
32
+ decision = resolveMcpPinVersion(projectVersion, lookupLatest("swallowkit"));
33
+ } else if (requested === MCP_LATEST) {
34
+ decision = { version: MCP_LATEST, reason: "Explicitly requested latest." };
35
+ } else if (parseSemver(requested)) {
36
+ decision = { version: requested, reason: "Explicitly pinned version." };
37
+ } else {
38
+ throw new Error(`--version must be "auto", "latest", or a semantic version (got "${requested}").`);
39
+ }
40
+
41
+ writeMcpConfigVersion(cwd, decision.version);
42
+ return { previousVersion, ...decision };
43
+ }
44
+
45
+ export const configureMcpCommand = new Command("configure-mcp")
46
+ .description(`Select the SwallowKit MCP version used by ${MCP_CONFIG_PATH} (auto: latest unless the project is newer)`)
47
+ .option("--version <version>", "auto | latest | <semver>", "auto")
48
+ .action((options: { version?: string }) => {
49
+ ensureSwallowKitProject("configure-mcp");
50
+ const result = configureMcp({ version: options.version });
51
+ console.log(`${MCP_CONFIG_PATH}: SWALLOWKIT_MCP_VERSION ${result.previousVersion ?? "(unset)"} -> ${result.version}`);
52
+ console.log(`Reason: ${result.reason}`);
53
+ console.log("Restart the MCP server (or reload your agent) to apply the change.");
54
+ });