swallowkit 2.0.0-alpha.3 → 2.0.0-alpha.5

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 (159) hide show
  1. package/dist/cli/commands/add-auth.js +37 -6
  2. package/dist/cli/commands/add-auth.js.map +1 -1
  3. package/dist/cli/commands/configure-mcp.d.ts +12 -0
  4. package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
  5. package/dist/cli/commands/configure-mcp.js +42 -0
  6. package/dist/cli/commands/configure-mcp.js.map +1 -0
  7. package/dist/cli/commands/init.d.ts +6 -1
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/commands/init.js +19 -5
  10. package/dist/cli/commands/init.js.map +1 -1
  11. package/dist/cli/commands/migrations.d.ts.map +1 -1
  12. package/dist/cli/commands/migrations.js +25 -0
  13. package/dist/cli/commands/migrations.js.map +1 -1
  14. package/dist/cli/commands/provision.d.ts +2 -0
  15. package/dist/cli/commands/provision.d.ts.map +1 -1
  16. package/dist/cli/commands/provision.js +45 -16
  17. package/dist/cli/commands/provision.js.map +1 -1
  18. package/dist/cli/commands/scaffold.d.ts +4 -0
  19. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  20. package/dist/cli/commands/scaffold.js +85 -3
  21. package/dist/cli/commands/scaffold.js.map +1 -1
  22. package/dist/cli/index.d.ts +2 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +29 -4
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/core/operations/create-model.d.ts +2 -0
  27. package/dist/core/operations/create-model.d.ts.map +1 -1
  28. package/dist/core/operations/create-model.js +60 -8
  29. package/dist/core/operations/create-model.js.map +1 -1
  30. package/dist/core/operations/model-migration.d.ts +22 -0
  31. package/dist/core/operations/model-migration.d.ts.map +1 -0
  32. package/dist/core/operations/model-migration.js +36 -0
  33. package/dist/core/operations/model-migration.js.map +1 -0
  34. package/dist/core/operations/provision-operations.d.ts +19 -1
  35. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  36. package/dist/core/operations/provision-operations.js +62 -35
  37. package/dist/core/operations/provision-operations.js.map +1 -1
  38. package/dist/core/operations/scaffold-apply.d.ts +1 -0
  39. package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
  40. package/dist/core/operations/scaffold-apply.js +1 -0
  41. package/dist/core/operations/scaffold-apply.js.map +1 -1
  42. package/dist/core/operations/scaffold-machine.d.ts +1 -0
  43. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  44. package/dist/core/operations/scaffold-machine.js.map +1 -1
  45. package/dist/core/operations/scaffold-plan.d.ts +2 -0
  46. package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
  47. package/dist/core/operations/scaffold-plan.js +3 -1
  48. package/dist/core/operations/scaffold-plan.js.map +1 -1
  49. package/dist/core/project/boundaries.d.ts +5 -2
  50. package/dist/core/project/boundaries.d.ts.map +1 -1
  51. package/dist/core/project/boundaries.js +51 -20
  52. package/dist/core/project/boundaries.js.map +1 -1
  53. package/dist/core/project/capabilities.d.ts +2 -0
  54. package/dist/core/project/capabilities.d.ts.map +1 -1
  55. package/dist/core/project/capabilities.js +29 -5
  56. package/dist/core/project/capabilities.js.map +1 -1
  57. package/dist/core/project/mcp-config.d.ts +26 -0
  58. package/dist/core/project/mcp-config.d.ts.map +1 -0
  59. package/dist/core/project/mcp-config.js +117 -0
  60. package/dist/core/project/mcp-config.js.map +1 -0
  61. package/dist/core/project/react-project.js +1 -1
  62. package/dist/core/project/workflows.d.ts.map +1 -1
  63. package/dist/core/project/workflows.js +23 -5
  64. package/dist/core/project/workflows.js.map +1 -1
  65. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  66. package/dist/core/scaffold/auth-generator.js +18 -19
  67. package/dist/core/scaffold/auth-generator.js.map +1 -1
  68. package/dist/core/scaffold/model-parser.d.ts +23 -0
  69. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  70. package/dist/core/scaffold/model-parser.js +407 -137
  71. package/dist/core/scaffold/model-parser.js.map +1 -1
  72. package/dist/core/scaffold/native-schema-generator.d.ts +13 -0
  73. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  74. package/dist/core/scaffold/native-schema-generator.js +289 -76
  75. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  76. package/dist/core/scaffold/openapi-generator.d.ts +5 -0
  77. package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
  78. package/dist/core/scaffold/openapi-generator.js +53 -3
  79. package/dist/core/scaffold/openapi-generator.js.map +1 -1
  80. package/dist/core/scaffold/ui-generator.d.ts +0 -3
  81. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  82. package/dist/core/scaffold/ui-generator.js +68 -7
  83. package/dist/core/scaffold/ui-generator.js.map +1 -1
  84. package/dist/core/verify/index.d.ts +4 -3
  85. package/dist/core/verify/index.d.ts.map +1 -1
  86. package/dist/core/verify/index.js +122 -5
  87. package/dist/core/verify/index.js.map +1 -1
  88. package/dist/machine/contracts.d.ts +1 -0
  89. package/dist/machine/contracts.d.ts.map +1 -1
  90. package/dist/machine/errors.d.ts +3 -2
  91. package/dist/machine/errors.d.ts.map +1 -1
  92. package/dist/machine/errors.js +2 -1
  93. package/dist/machine/errors.js.map +1 -1
  94. package/dist/machine/index.d.ts.map +1 -1
  95. package/dist/machine/index.js +42 -17
  96. package/dist/machine/index.js.map +1 -1
  97. package/dist/mcp/index.d.ts +4 -2
  98. package/dist/mcp/index.d.ts.map +1 -1
  99. package/dist/mcp/index.js +154 -15
  100. package/dist/mcp/index.js.map +1 -1
  101. package/dist/mcp/version-selection.d.ts +55 -0
  102. package/dist/mcp/version-selection.d.ts.map +1 -0
  103. package/dist/mcp/version-selection.js +152 -0
  104. package/dist/mcp/version-selection.js.map +1 -0
  105. package/dist/utils/azure-cli.d.ts +44 -0
  106. package/dist/utils/azure-cli.d.ts.map +1 -0
  107. package/dist/utils/azure-cli.js +164 -0
  108. package/dist/utils/azure-cli.js.map +1 -0
  109. package/dist/utils/semver.d.ts +14 -0
  110. package/dist/utils/semver.d.ts.map +1 -0
  111. package/dist/utils/semver.js +61 -0
  112. package/dist/utils/semver.js.map +1 -0
  113. package/package.json +6 -3
  114. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
  115. package/src/__tests__/agent-loop-phase34.test.ts +124 -0
  116. package/src/__tests__/azure-cli.test.ts +199 -0
  117. package/src/__tests__/boundaries.test.ts +42 -0
  118. package/src/__tests__/csharp-schema.test.ts +261 -0
  119. package/src/__tests__/generated-project-matrix.test.ts +789 -0
  120. package/src/__tests__/mcp-version.test.ts +262 -0
  121. package/src/__tests__/mcp.test.ts +64 -3
  122. package/src/__tests__/multi-auth.test.ts +115 -4
  123. package/src/__tests__/plan-apply.test.ts +170 -0
  124. package/src/__tests__/schema-analysis.test.ts +329 -0
  125. package/src/cli/commands/add-auth.ts +36 -6
  126. package/src/cli/commands/configure-mcp.ts +54 -0
  127. package/src/cli/commands/init.ts +23 -8
  128. package/src/cli/commands/migrations.ts +22 -0
  129. package/src/cli/commands/provision.ts +53 -17
  130. package/src/cli/commands/scaffold.ts +105 -3
  131. package/src/cli/index.ts +28 -5
  132. package/src/core/operations/create-model.ts +51 -13
  133. package/src/core/operations/model-migration.ts +52 -0
  134. package/src/core/operations/provision-operations.ts +86 -42
  135. package/src/core/operations/scaffold-apply.ts +2 -0
  136. package/src/core/operations/scaffold-machine.ts +1 -0
  137. package/src/core/operations/scaffold-plan.ts +5 -1
  138. package/src/core/project/agent-templates/AGENTS.md +67 -6
  139. package/src/core/project/agent-templates/CLAUDE.md +7 -1
  140. package/src/core/project/agent-templates/copilot-instructions.md +7 -1
  141. package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
  142. package/src/core/project/boundaries.ts +62 -20
  143. package/src/core/project/capabilities.ts +32 -5
  144. package/src/core/project/mcp-config.ts +84 -0
  145. package/src/core/project/react-project.ts +1 -1
  146. package/src/core/project/workflows.ts +23 -5
  147. package/src/core/scaffold/auth-generator.ts +18 -19
  148. package/src/core/scaffold/model-parser.ts +413 -129
  149. package/src/core/scaffold/native-schema-generator.ts +313 -78
  150. package/src/core/scaffold/openapi-generator.ts +63 -5
  151. package/src/core/scaffold/ui-generator.ts +70 -8
  152. package/src/core/verify/index.ts +118 -6
  153. package/src/machine/contracts.ts +1 -0
  154. package/src/machine/errors.ts +4 -2
  155. package/src/machine/index.ts +45 -20
  156. package/src/mcp/index.ts +173 -18
  157. package/src/mcp/version-selection.ts +163 -0
  158. package/src/utils/azure-cli.ts +154 -0
  159. package/src/utils/semver.ts +59 -0
@@ -0,0 +1,789 @@
1
+ /**
2
+ * Generated project matrix.
3
+ *
4
+ * Builds realistic consumer skeletons (the same builders as `init`), applies authentication,
5
+ * plans/applies scaffold through the machine interface, and then *compiles* the result:
6
+ * - TypeScript: standalone Functions, BFF, and UI are type-checked with the project's tsconfig.
7
+ * Only npm packages that are not installed in this repository are replaced by `any` stubs
8
+ * generated from the actual imports; project modules and the shared package are real.
9
+ * - Python: every backend file is parsed, and project-local imports must resolve to real names.
10
+ * - C#: `dotnet build` of the standalone Functions project (when the .NET SDK is available).
11
+ *
12
+ * Each combination of frontend × backend × authentication × model authPolicy must either
13
+ * be rejected before any file is written, or produce code that compiles.
14
+ */
15
+
16
+ import * as fs from "fs";
17
+ import * as os from "os";
18
+ import * as path from "path";
19
+ import * as childProcess from "child_process";
20
+ import { builtinModules } from "module";
21
+ import { EventEmitter } from "events";
22
+ import * as ts from "typescript";
23
+ import { runMachineCli } from "../machine";
24
+ import { addAuthCommand } from "../cli/commands/add-auth";
25
+ import { buildGeneratedProjectDependencies, buildGeneratedProjectDevDependencies, buildSwallowKitConfigSource, createAzureFunctionsProject, createSharedPackage } from "../cli/commands/init";
26
+ import { createReactBffProject, createReactProject } from "../core/project/react-project";
27
+ import { BackendLanguage, FrontendFramework } from "../types";
28
+
29
+ jest.mock("child_process", () => {
30
+ const actual = jest.requireActual<typeof import("child_process")>("child_process");
31
+ return { ...actual, execFile: jest.fn(), spawn: jest.fn(), spawnSync: jest.fn() };
32
+ });
33
+
34
+ /**
35
+ * Jest caches file contents per test file, so a swallowkit.config.js rewritten by add-auth (or by
36
+ * this test) would be re-required with stale content. The CLI runs each command in a fresh process;
37
+ * mirror that by always evaluating the config file from disk.
38
+ */
39
+ jest.mock("module", () => {
40
+ const actual = jest.requireActual<typeof import("module")>("module");
41
+ const nodeFs = jest.requireActual<typeof import("fs")>("fs");
42
+ return {
43
+ ...actual,
44
+ createRequire(filename: string) {
45
+ const base = actual.createRequire(filename);
46
+ const fresh = ((id: string) => {
47
+ if (!/[\\/]swallowkit\.config\.js$/.test(id)) return base(id);
48
+ const module = { exports: {} as unknown };
49
+ new Function("module", "exports", "require", nodeFs.readFileSync(id, "utf-8"))(module, module.exports, base);
50
+ return module.exports;
51
+ }) as NodeJS.Require;
52
+ return Object.assign(fresh, { cache: {}, resolve: base.resolve });
53
+ },
54
+ };
55
+ });
56
+
57
+ const real = jest.requireActual<typeof import("child_process")>("child_process");
58
+ const repoRoot = process.cwd();
59
+ const PROJECT = "matrix-app";
60
+ const SHARED = `@${PROJECT}/shared`;
61
+
62
+ type Provider = "swa" | "external-token" | "custom-jwt";
63
+ interface AuthSetup {
64
+ id: string;
65
+ kind: "none" | "legacy" | "named";
66
+ providers: Array<{ provider: Provider; scheme?: string }>;
67
+ defaultPolicy?: string;
68
+ }
69
+ type PolicyId = "none" | "roles" | "rw-roles" | "named" | "rw-named" | "read-named-only" | "rw-mixed";
70
+
71
+ const AUTH_SETUPS: AuthSetup[] = [
72
+ { id: "no-auth", kind: "none", providers: [] },
73
+ { id: "legacy-swa", kind: "legacy", providers: [{ provider: "swa" }] },
74
+ { id: "legacy-external-token", kind: "legacy", providers: [{ provider: "external-token" }] },
75
+ { id: "legacy-custom-jwt", kind: "legacy", providers: [{ provider: "custom-jwt" }] },
76
+ { id: "legacy-swa-named-default-policy", kind: "legacy", providers: [{ provider: "swa" }], defaultPolicy: "readers" },
77
+ { id: "named-swa", kind: "named", providers: [{ provider: "swa", scheme: "admin" }] },
78
+ { id: "named-external-token", kind: "named", providers: [{ provider: "external-token", scheme: "partner" }] },
79
+ { id: "named-custom-jwt", kind: "named", providers: [{ provider: "custom-jwt", scheme: "staff" }] },
80
+ {
81
+ id: "named-swa+external-default-policy",
82
+ kind: "named",
83
+ providers: [{ provider: "swa", scheme: "admin" }, { provider: "external-token", scheme: "partner" }],
84
+ defaultPolicy: "readers",
85
+ },
86
+ ];
87
+
88
+ const POLICIES: Record<PolicyId, string | null> = {
89
+ none: null,
90
+ roles: "{ roles: ['admin'] }",
91
+ "rw-roles": "{ read: ['reader'], write: ['admin'] }",
92
+ named: "{ policy: 'adminOnly' }",
93
+ "rw-named": "{ read: 'readers', write: 'adminOnly' }",
94
+ // Partially named policies: the unnamed operation falls back to role guards.
95
+ "read-named-only": "{ read: 'readers' }",
96
+ "rw-mixed": "{ read: 'readers', write: ['admin'] }",
97
+ };
98
+
99
+ interface MatrixCase {
100
+ name: string;
101
+ frontend: FrontendFramework;
102
+ backend: BackendLanguage;
103
+ auth: AuthSetup;
104
+ policy: PolicyId;
105
+ /** init writes swallowkit.config.js; swallowkit.config.json is also supported and takes precedence. */
106
+ configFormat?: "js" | "json";
107
+ }
108
+
109
+ /** The contract: either a precise prerequisite error, or a project that compiles. */
110
+ function expectedBlock(auth: AuthSetup, policy: PolicyId): string | null {
111
+ const referencesNamedPolicy = ["named", "rw-named", "read-named-only", "rw-mixed"].includes(policy) ||
112
+ (policy === "none" && Boolean(auth.defaultPolicy));
113
+ // Every operation resolves to a named policy (named schemes only have the multi-scheme router).
114
+ const fullyNamed = policy === "named" || policy === "rw-named" || (policy === "none" && auth.kind === "named");
115
+ if (auth.kind === "none") return policy === "none" ? null : "auth-policy-without-auth";
116
+ if (auth.kind === "named" && !fullyNamed) return "auth-policy-requires-named-policy";
117
+ // Named policies are routed by the multi-scheme router, which only exists for named auth.schemes.
118
+ if (auth.kind === "legacy" && referencesNamedPolicy) return "auth-named-policy-requires-named-scheme";
119
+ return null;
120
+ }
121
+
122
+ // Frontend-specific code (UI, BFF) is independent of the backend language and vice versa,
123
+ // so the full auth × policy grid runs for TypeScript on both frontends and for C#/Python on React,
124
+ // plus a Next.js smoke case per native backend.
125
+ const CASES: MatrixCase[] = [];
126
+ for (const auth of AUTH_SETUPS) {
127
+ for (const policy of Object.keys(POLICIES) as PolicyId[]) {
128
+ const combos: Array<[FrontendFramework, BackendLanguage]> = [
129
+ ["nextjs", "typescript"], ["react", "typescript"], ["react", "csharp"], ["react", "python"],
130
+ ];
131
+ for (const [frontend, backend] of combos) {
132
+ CASES.push({ name: `${frontend}/${backend}/${auth.id}/${policy}`, frontend, backend, auth, policy });
133
+ }
134
+ }
135
+ }
136
+ const setup = (id: string) => AUTH_SETUPS.find((candidate) => candidate.id === id)!;
137
+ for (const backend of ["csharp", "python"] as const) {
138
+ for (const auth of [setup("no-auth"), setup("legacy-swa"), setup("named-swa")]) {
139
+ const policy: PolicyId = auth.kind === "named" ? "named" : auth.kind === "legacy" ? "roles" : "none";
140
+ CASES.push({ name: `nextjs/${backend}/${auth.id}/${policy}`, frontend: "nextjs", backend, auth, policy });
141
+ }
142
+ }
143
+ for (const backend of ["typescript", "csharp", "python"] as const) {
144
+ for (const auth of [setup("legacy-swa"), setup("legacy-custom-jwt"), setup("named-swa"), setup("named-custom-jwt")]) {
145
+ const policy: PolicyId = auth.kind === "named" ? "rw-named" : "rw-roles";
146
+ CASES.push({ name: `react/${backend}/${auth.id}/${policy}/json-config`, frontend: "react", backend, auth, policy, configFormat: "json" });
147
+ }
148
+ }
149
+
150
+ const MODEL_SOURCE = `import { z } from 'zod/v4';
151
+ export const ProbeItem = z.object({
152
+ id: z.string(),
153
+ userId: z.string(),
154
+ title: z.string().min(1),
155
+ tags: z.array(z.string()),
156
+ score: z.number().min(0).max(1),
157
+ kind: z.enum(['a', 'b']),
158
+ nested: z.object({ x: z.string() }).optional(),
159
+ createdAt: z.string().optional(),
160
+ updatedAt: z.string().optional(),
161
+ });
162
+ export type ProbeItem = z.infer<typeof ProbeItem>;
163
+ export const partitionKey = '/userId';
164
+ export const displayName = 'Probe';
165
+ `;
166
+
167
+ function write(file: string, content: string): void {
168
+ fs.mkdirSync(path.dirname(file), { recursive: true });
169
+ fs.writeFileSync(file, content, "utf-8");
170
+ }
171
+
172
+ async function createSkeleton(root: string, frontend: FrontendFramework, backend: BackendLanguage, configFormat: "js" | "json"): Promise<void> {
173
+ fs.mkdirSync(root, { recursive: true });
174
+ if (frontend === "react") {
175
+ createReactProject(root, PROJECT, "pnpm");
176
+ } else {
177
+ // create-next-app's manifest (SwallowKit init adds its own dependencies below).
178
+ write(path.join(root, "package.json"), JSON.stringify({
179
+ name: PROJECT, private: true,
180
+ dependencies: { next: "16.0.0", react: "19.0.0", "react-dom": "19.0.0" },
181
+ devDependencies: { typescript: "^5", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", tailwindcss: "^4", "@tailwindcss/postcss": "^4" },
182
+ }, null, 2));
183
+ // Mirrors create-next-app plus the exclusions SwallowKit init adds.
184
+ write(path.join(root, "tsconfig.json"), JSON.stringify({
185
+ compilerOptions: {
186
+ target: "ES2017", lib: ["dom", "dom.iterable", "esnext"], allowJs: true, skipLibCheck: true, strict: true,
187
+ noEmit: true, esModuleInterop: true, module: "esnext", moduleResolution: "bundler", resolveJsonModule: true,
188
+ isolatedModules: true, jsx: "react-jsx", incremental: false, paths: { "@/*": ["./*"] },
189
+ },
190
+ include: ["**/*.ts", "**/*.tsx"],
191
+ exclude: ["node_modules", "functions", "shared"],
192
+ }, null, 2));
193
+ write(path.join(root, "app", "layout.tsx"), `import type { ReactNode } from 'react';
194
+ export default function RootLayout({ children }: { children: ReactNode }) {
195
+ return <html lang="en"><body>{children}</body></html>;
196
+ }
197
+ `);
198
+ write(path.join(root, "lib", "scaffold-config.ts"), `export interface ScaffoldModel { name: string; path: string; label: string; }
199
+ export const scaffoldConfig = {
200
+ models: [] as ScaffoldModel[]
201
+ };
202
+ `);
203
+ }
204
+ if (configFormat === "json") {
205
+ write(path.join(root, "swallowkit.config.json"), JSON.stringify({ frontend: { framework: frontend }, backend: { language: backend } }, null, 2));
206
+ } else {
207
+ write(path.join(root, "swallowkit.config.js"), buildSwallowKitConfigSource(backend, frontend));
208
+ }
209
+ const manifestPath = path.join(root, "package.json");
210
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
211
+ manifest.dependencies = { ...manifest.dependencies, ...buildGeneratedProjectDependencies(PROJECT, "pnpm", frontend) };
212
+ manifest.devDependencies = { ...manifest.devDependencies, ...buildGeneratedProjectDevDependencies(frontend) };
213
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
214
+ await createSharedPackage(root, PROJECT);
215
+ await createAzureFunctionsProject(root, "pnpm", backend);
216
+ if (frontend === "react") createReactBffProject(root, PROJECT, "pnpm");
217
+ fs.symlinkSync(path.join(repoRoot, "node_modules"), path.join(root, "node_modules"), "junction");
218
+ }
219
+
220
+ async function applyAuth(root: string, auth: AuthSetup): Promise<void> {
221
+ for (const { provider, scheme } of auth.providers) {
222
+ await addAuthCommand({ provider, scheme, allowedProviders: provider === "swa" ? ["github"] : undefined });
223
+ }
224
+ if (auth.kind === "none") return;
225
+ const schemes = auth.kind === "legacy" ? ["default"] : auth.providers.map((entry) => entry.scheme!);
226
+ const policies = {
227
+ adminOnly: { schemes: schemes.slice(0, 1), roles: ["admin"] },
228
+ readers: { schemes, roles: ["reader"] },
229
+ };
230
+ const jsonPath = path.join(root, "swallowkit.config.json");
231
+ if (fs.existsSync(jsonPath)) {
232
+ const config = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
233
+ if (!config.auth) throw new Error("add-auth did not record auth in swallowkit.config.json");
234
+ config.auth.authorization = {
235
+ ...config.auth.authorization,
236
+ ...(auth.defaultPolicy ? { defaultPolicy: auth.defaultPolicy } : {}),
237
+ policies: { ...config.auth.authorization?.policies, ...policies },
238
+ };
239
+ fs.writeFileSync(jsonPath, JSON.stringify(config, null, 2));
240
+ return;
241
+ }
242
+ // Named policies are hand-edited after apply auth; append the equivalent edit to the JS config.
243
+ fs.appendFileSync(path.join(root, "swallowkit.config.js"), `
244
+ module.exports.auth.authorization = {
245
+ ...module.exports.auth.authorization,
246
+ ${auth.defaultPolicy ? `defaultPolicy: ${JSON.stringify(auth.defaultPolicy)},` : ""}
247
+ policies: { ...(module.exports.auth.authorization || {}).policies, ...${JSON.stringify(policies)} },
248
+ };
249
+ `);
250
+ }
251
+
252
+ function mockNativeToolchainValidation(): void {
253
+ // Only toolchain *validation* shells out; generation is in-process and stays real.
254
+ (childProcess.spawnSync as unknown as ReturnType<typeof jest.fn>).mockReturnValue({ status: 0, stdout: "", stderr: "" });
255
+ (childProcess.spawn as unknown as ReturnType<typeof jest.fn>).mockImplementation(() => {
256
+ const child = new EventEmitter() as childProcess.ChildProcess;
257
+ process.nextTick(() => child.emit("close", 0));
258
+ return child;
259
+ });
260
+ (childProcess.execFile as unknown as ReturnType<typeof jest.fn>).mockImplementation(
261
+ (_c: string, _a: string[], _o: object, callback: (e: Error | null, out: string, err: string) => void) =>
262
+ process.nextTick(() => callback(null, "", ""))
263
+ );
264
+ }
265
+
266
+ async function runMachine(argv: string[]): Promise<any> {
267
+ const writes: string[] = [];
268
+ const originalWrite = process.stdout.write.bind(process.stdout);
269
+ const originalExitCode = process.exitCode;
270
+ (process.stdout.write as unknown) = (chunk: string | Uint8Array) => {
271
+ writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8"));
272
+ return true;
273
+ };
274
+ try {
275
+ await runMachineCli(["node", "swallowkit", "machine", ...argv]);
276
+ return JSON.parse(writes.join(""));
277
+ } finally {
278
+ process.stdout.write = originalWrite;
279
+ process.exitCode = originalExitCode;
280
+ }
281
+ }
282
+
283
+ function snapshotTree(root: string): Map<string, string> {
284
+ const files = new Map<string, string>();
285
+ const walk = (dir: string) => {
286
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
287
+ if (entry.name === "node_modules" || entry.name === ".swallowkit") continue;
288
+ const full = path.join(dir, entry.name);
289
+ if (entry.isDirectory()) walk(full);
290
+ else files.set(path.relative(root, full), fs.readFileSync(full, "utf-8"));
291
+ }
292
+ };
293
+ walk(root);
294
+ return files;
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------------------------
298
+ // Toolchains: CI sets SWALLOWKIT_REQUIRE_TOOLCHAINS=1 so a missing Python/.NET fails instead of skipping.
299
+ // Locally, SWALLOWKIT_TEST_SKIP_DOTNET=1 skips the slower dotnet builds.
300
+ // ---------------------------------------------------------------------------------------------
301
+
302
+ const REQUIRE_TOOLCHAINS = process.env.SWALLOWKIT_REQUIRE_TOOLCHAINS === "1";
303
+
304
+ type TestFn = (title: string, fn: () => void | Promise<void>) => void;
305
+
306
+ function toolchainGate(available: boolean, name: string, optOut = false): TestFn {
307
+ if (available && !optOut) return (title, fn) => it(title, fn);
308
+ if (REQUIRE_TOOLCHAINS && !optOut) {
309
+ return (title) => it(title, () => { throw new Error(`${name} is required (SWALLOWKIT_REQUIRE_TOOLCHAINS=1) but was not found`); });
310
+ }
311
+ return (title, fn) => it.skip(title, fn);
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------------------------
315
+ // TypeScript checking
316
+ // ---------------------------------------------------------------------------------------------
317
+
318
+ const sourceFileCache = new Map<string, ts.SourceFile | undefined>();
319
+ let previousProgram: ts.Program | undefined;
320
+
321
+ function createCachingHost(options: ts.CompilerOptions): ts.CompilerHost {
322
+ const host = ts.createCompilerHost(options, true);
323
+ const getSourceFile = host.getSourceFile.bind(host);
324
+ host.getSourceFile = (fileName, languageVersionOrOptions, onError, shouldCreate) => {
325
+ const shareable = fileName.includes(`${path.sep}node_modules${path.sep}`) || fileName.includes("/node_modules/") ||
326
+ path.dirname(fileName) === path.dirname(ts.getDefaultLibFilePath(options));
327
+ if (!shareable) return getSourceFile(fileName, languageVersionOrOptions, onError, shouldCreate);
328
+ const version = typeof languageVersionOrOptions === "object"
329
+ ? `${languageVersionOrOptions.languageVersion}:${languageVersionOrOptions.impliedNodeFormat}`
330
+ : String(languageVersionOrOptions);
331
+ // Resolve through the symlink so every case shares the same cached declaration files.
332
+ const key = `${fs.existsSync(fileName) ? fs.realpathSync(fileName) : fileName}|${version}`;
333
+ if (!sourceFileCache.has(key)) sourceFileCache.set(key, getSourceFile(fileName, languageVersionOrOptions, onError, shouldCreate));
334
+ return sourceFileCache.get(key);
335
+ };
336
+ return host;
337
+ }
338
+
339
+ /** Declare `any`-typed stubs for exactly the bindings imported from packages that are not installed here. */
340
+ function buildMissingPackageStubs(files: string[], options: ts.CompilerOptions): string {
341
+ const modules = new Map<string, { names: Set<string>; hasDefault: boolean }>();
342
+ const needsStub = (specifier: string, file: string) => {
343
+ const local = specifier.startsWith(".") || specifier.startsWith("@/") || specifier === SHARED || specifier.startsWith(`${SHARED}/`);
344
+ if (local) return false;
345
+ const resolved = ts.resolveModuleName(specifier, file, options, ts.sys).resolvedModule;
346
+ // Untyped JavaScript packages (TS7016) are stubbed like missing ones.
347
+ return !resolved || ![".ts", ".tsx", ".d.ts", ".mts", ".cts", ".d.mts", ".d.cts"].includes(resolved.extension);
348
+ };
349
+ const entryFor = (specifier: string) => {
350
+ const entry = modules.get(specifier) ?? { names: new Set<string>(), hasDefault: false };
351
+ modules.set(specifier, entry);
352
+ return entry;
353
+ };
354
+ for (const file of files) {
355
+ const source = ts.createSourceFile(file, fs.readFileSync(file, "utf-8"), ts.ScriptTarget.Latest, true);
356
+ const visit = (node: ts.Node) => {
357
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
358
+ const specifier = node.moduleSpecifier.text;
359
+ if (needsStub(specifier, file)) {
360
+ const entry = entryFor(specifier);
361
+ if (ts.isImportDeclaration(node)) {
362
+ const clause = node.importClause;
363
+ if (clause?.name) entry.hasDefault = true;
364
+ if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
365
+ for (const element of clause.namedBindings.elements) entry.names.add((element.propertyName ?? element.name).text);
366
+ }
367
+ } else if (node.exportClause && ts.isNamedExports(node.exportClause)) {
368
+ for (const element of node.exportClause.elements) entry.names.add((element.propertyName ?? element.name).text);
369
+ }
370
+ }
371
+ } else if (ts.isCallExpression(node) && node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0]) &&
372
+ (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === "require"))) {
373
+ // `const { A } = await import('pkg')` / `require('pkg')`
374
+ const specifier = node.arguments[0].text;
375
+ if (needsStub(specifier, file)) {
376
+ const entry = entryFor(specifier);
377
+ entry.hasDefault = true;
378
+ let parent: ts.Node = node.parent;
379
+ while (parent && (ts.isAwaitExpression(parent) || ts.isParenthesizedExpression(parent))) parent = parent.parent;
380
+ if (parent && ts.isVariableDeclaration(parent) && ts.isObjectBindingPattern(parent.name)) {
381
+ for (const element of parent.name.elements) {
382
+ const name = element.propertyName ?? element.name;
383
+ if (ts.isIdentifier(name)) entry.names.add(name.text);
384
+ }
385
+ }
386
+ }
387
+ }
388
+ ts.forEachChild(node, visit);
389
+ };
390
+ visit(source);
391
+ }
392
+ return [...modules].map(([name, { names, hasDefault }]) => [
393
+ `declare module ${JSON.stringify(name)} {`,
394
+ ...[...names].filter((n) => n !== "default").map((n) => ` export const ${n}: any;\n export type ${n}<A = any, B = any, C = any> = any;`),
395
+ hasDefault || names.has("default") ? " const value: any;\n export default value;" : "",
396
+ "}",
397
+ ].join("\n")).join("\n");
398
+ }
399
+
400
+ function listSources(dir: string, pattern = /\.(ts|tsx)$/): string[] {
401
+ if (!fs.existsSync(dir)) return [];
402
+ if (fs.statSync(dir).isFile()) return pattern.test(dir) ? [dir] : [];
403
+ return fs.readdirSync(dir, { recursive: true, withFileTypes: true })
404
+ .filter((entry) => entry.isFile() && pattern.test(entry.name) && !/[\\/](node_modules|dist)([\\/]|$)/.test(entry.parentPath))
405
+ .map((entry) => path.join(entry.parentPath, entry.name));
406
+ }
407
+
408
+ const NODE_BUILTINS = new Set(builtinModules);
409
+
410
+ /** npm packages imported by generated code that the generated project does not declare (would fail after a clean install). */
411
+ function undeclaredPackages(sourceRoots: string[], packageJsons: string[], extraAllowed: string[] = []): string[] {
412
+ const declared = new Set(extraAllowed);
413
+ for (const file of packageJsons) {
414
+ if (!fs.existsSync(file)) continue;
415
+ const pkg = JSON.parse(fs.readFileSync(file, "utf-8"));
416
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) Object.keys(pkg[field] ?? {}).forEach((name) => declared.add(name));
417
+ }
418
+ const missing = new Set<string>();
419
+ for (const file of sourceRoots.flatMap((root) => listSources(root))) {
420
+ const source = ts.createSourceFile(file, fs.readFileSync(file, "utf-8"), ts.ScriptTarget.Latest, true);
421
+ const check = (specifier: string) => {
422
+ if (specifier.startsWith(".") || specifier.startsWith("@/") || specifier.startsWith("node:") || NODE_BUILTINS.has(specifier.split("/")[0])) return;
423
+ const parts = specifier.split("/");
424
+ const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
425
+ if (!declared.has(name)) missing.add(`${path.basename(file)} imports '${name}'`);
426
+ };
427
+ const visit = (node: ts.Node) => {
428
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) &&
429
+ !(ts.isImportDeclaration(node) && node.importClause?.isTypeOnly)) check(node.moduleSpecifier.text);
430
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) {
431
+ check(node.arguments[0].text);
432
+ }
433
+ ts.forEachChild(node, visit);
434
+ };
435
+ visit(source);
436
+ }
437
+ return [...missing].sort();
438
+ }
439
+
440
+ function typecheck(tsconfigPath: string, sharedIndex: string, packageOverrides: Record<string, string> = {}): string[] {
441
+ const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, { noEmit: true }, {
442
+ ...ts.sys,
443
+ onUnRecoverableConfigFileDiagnostic: (diagnostic) => { throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); },
444
+ })!;
445
+ const options: ts.CompilerOptions = {
446
+ ...parsed.options,
447
+ noEmit: true,
448
+ incremental: false,
449
+ // Workspace installs link the shared package to its built dist/ (outside rootDir); type-check its source instead.
450
+ rootDir: undefined,
451
+ paths: {
452
+ ...parsed.options.paths,
453
+ [SHARED]: [sharedIndex],
454
+ [`${SHARED}/*`]: [path.join(path.dirname(sharedIndex), "*")],
455
+ ...Object.fromEntries(Object.entries(packageOverrides).map(([name, target]) => [name, [target]])),
456
+ },
457
+ pathsBasePath: path.dirname(tsconfigPath),
458
+ };
459
+ const rootNames = parsed.fileNames.filter((file) => !/[\\/](node_modules|dist)[\\/]/.test(file));
460
+ const stubFile = path.join(path.dirname(tsconfigPath), "__matrix_missing_packages__.d.ts");
461
+ fs.writeFileSync(stubFile, buildMissingPackageStubs(rootNames, options));
462
+ const program = ts.createProgram({ rootNames: [...rootNames, stubFile], options, host: createCachingHost(options), oldProgram: previousProgram });
463
+ previousProgram = program;
464
+ const diagnostics = ts.getPreEmitDiagnostics(program);
465
+ fs.rmSync(stubFile);
466
+ return diagnostics.map((diagnostic) => {
467
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
468
+ if (!diagnostic.file) return `TS${diagnostic.code}: ${message}`;
469
+ const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
470
+ return `${path.relative(path.dirname(tsconfigPath), diagnostic.file.fileName)}(${line + 1},${character + 1}): TS${diagnostic.code}: ${message}`;
471
+ });
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------------------------
475
+ // Python checking
476
+ // ---------------------------------------------------------------------------------------------
477
+
478
+ const PYTHON = ["python3", "python"].find((command) => {
479
+ const probe = real.spawnSync(command, ["-c", "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)"], { encoding: "utf-8" });
480
+ return probe.status === 0;
481
+ });
482
+
483
+ const PYTHON_CHECKER = String.raw`
484
+ import ast, json, os, sys
485
+ root = sys.argv[1]
486
+ errors = []
487
+ modules = {}
488
+ for base, dirs, files in os.walk(root):
489
+ dirs[:] = [d for d in dirs if d not in (".venv", ".codegen-venv", "__pycache__", "node_modules")]
490
+ for name in files:
491
+ if name.endswith(".py"):
492
+ full = os.path.join(base, name)
493
+ rel = os.path.relpath(full, root)
494
+ parts = rel[:-3].split(os.sep)
495
+ if parts[-1] == "__init__":
496
+ parts = parts[:-1]
497
+ try:
498
+ with open(full, encoding="utf-8") as handle:
499
+ modules[".".join(parts)] = (rel, ast.parse(handle.read(), rel))
500
+ except SyntaxError as exc:
501
+ errors.append(f"{rel}:{exc.lineno}: {exc.__class__.__name__}: {exc.msg}")
502
+
503
+ def defined(tree):
504
+ names = set()
505
+ for node in tree.body:
506
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
507
+ names.add(node.name)
508
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
509
+ for target in (node.targets if isinstance(node, ast.Assign) else [node.target]):
510
+ for sub in ast.walk(target):
511
+ if isinstance(sub, ast.Name):
512
+ names.add(sub.id)
513
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
514
+ for alias in node.names:
515
+ names.add((alias.asname or alias.name).split(".")[0])
516
+ return names
517
+
518
+ local_roots = {name.split(".")[0] for name in modules}
519
+ external = set()
520
+ for module, (rel, tree) in modules.items():
521
+ for node in ast.walk(tree):
522
+ names = []
523
+ if isinstance(node, ast.Import):
524
+ names = [alias.name for alias in node.names]
525
+ elif isinstance(node, ast.ImportFrom) and not node.level and node.module:
526
+ names = [node.module]
527
+ for name in names:
528
+ top = name.split(".")[0]
529
+ if top not in local_roots and top not in sys.stdlib_module_names and top != "__future__":
530
+ external.add(".".join(name.split(".")[:2]) if top == "azure" else top)
531
+ package = module.split(".") if rel.endswith("__init__.py") else module.split(".")[:-1]
532
+ for node in ast.walk(tree):
533
+ if isinstance(node, ast.ImportFrom):
534
+ if node.level:
535
+ target = ".".join(package[: len(package) - node.level + 1] + ([node.module] if node.module else []))
536
+ else:
537
+ target = node.module or ""
538
+ if target.split(".")[0] not in local_roots:
539
+ continue
540
+ if target not in modules:
541
+ errors.append(f"{rel}:{node.lineno}: missing module '{target}'")
542
+ continue
543
+ names = defined(modules[target][1])
544
+ for alias in node.names:
545
+ if alias.name != "*" and alias.name not in names and f"{target}.{alias.name}" not in modules:
546
+ errors.append(f"{rel}:{node.lineno}: '{target}' has no name '{alias.name}'")
547
+ elif isinstance(node, ast.Import):
548
+ for alias in node.names:
549
+ if alias.name.split(".")[0] in local_roots and alias.name not in modules:
550
+ errors.append(f"{rel}:{node.lineno}: missing module '{alias.name}'")
551
+ print(json.dumps({"errors": errors, "external": sorted(external)}))
552
+ `;
553
+
554
+ /** Import name -> distribution that provides it (only names generated code uses). */
555
+ const PYTHON_DISTRIBUTIONS: Record<string, string> = {
556
+ "azure.functions": "azure-functions",
557
+ "azure.cosmos": "azure-cosmos",
558
+ "azure.identity": "azure-identity",
559
+ jwt: "PyJWT",
560
+ bcrypt: "bcrypt",
561
+ mysql: "mysql-connector-python",
562
+ psycopg2: "psycopg2-binary",
563
+ pymssql: "pymssql",
564
+ pydantic: "pydantic",
565
+ // A required dependency of pydantic v2, so declaring pydantic installs it.
566
+ typing_extensions: "pydantic",
567
+ requests: "requests",
568
+ httpx: "httpx",
569
+ yaml: "PyYAML",
570
+ };
571
+
572
+ function checkPython(functionsRoot: string): string[] {
573
+ const result = real.spawnSync(PYTHON!, ["-c", PYTHON_CHECKER, functionsRoot], { encoding: "utf-8" });
574
+ if (result.status !== 0) throw new Error(`Python checker failed: ${result.stderr}`);
575
+ const { errors, external } = JSON.parse(result.stdout) as { errors: string[]; external: string[] };
576
+ const requirementsPath = path.join(functionsRoot, "requirements.txt");
577
+ const requirements = new Set((fs.existsSync(requirementsPath) ? fs.readFileSync(requirementsPath, "utf-8") : "")
578
+ .split(/\r?\n/).map((line) => line.trim().split(/[<>=!~;[ ]/)[0].toLowerCase()).filter(Boolean));
579
+ for (const name of external) {
580
+ const distribution = PYTHON_DISTRIBUTIONS[name];
581
+ if (!distribution) errors.push(`imports '${name}', which has no known distribution mapping (add it to PYTHON_DISTRIBUTIONS)`);
582
+ else if (!requirements.has(distribution.toLowerCase())) errors.push(`imports '${name}' but requirements.txt does not declare ${distribution}`);
583
+ }
584
+ return errors;
585
+ }
586
+
587
+ // ---------------------------------------------------------------------------------------------
588
+ // C# checking
589
+ // ---------------------------------------------------------------------------------------------
590
+
591
+ const DOTNET = real.spawnSync("dotnet", ["--version"], { encoding: "utf-8" }).status === 0;
592
+
593
+ function dotnetBuild(functionsRoot: string): Promise<string[]> {
594
+ return new Promise((resolve) => {
595
+ const child = real.spawn("dotnet", ["build", "--nologo", "-v", "q", "-clp:NoSummary", "-p:TreatWarningsAsErrors=false"], {
596
+ cwd: functionsRoot,
597
+ env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: "1", DOTNET_NOLOGO: "1", MSBUILDDISABLENODEREUSE: "1" },
598
+ });
599
+ let output = "";
600
+ child.stdout.on("data", (chunk) => { output += chunk; });
601
+ child.stderr.on("data", (chunk) => { output += chunk; });
602
+ child.on("close", (code) => {
603
+ const errors = [...new Set(output.split(/\r?\n/).filter((line) => /error [A-Z]+\d+/.test(line))
604
+ .map((line) => line.replace(functionsRoot + path.sep, "").replace(/\s*\[[^\]]+\.csproj\]\s*$/, "").trim()))];
605
+ resolve(code === 0 ? [] : errors.length > 0 ? errors : [output.slice(-4000)]);
606
+ });
607
+ });
608
+ }
609
+
610
+ async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
611
+ const results: R[] = new Array(items.length);
612
+ let next = 0;
613
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
614
+ while (next < items.length) {
615
+ const index = next++;
616
+ results[index] = await fn(items[index]);
617
+ }
618
+ }));
619
+ return results;
620
+ }
621
+
622
+ // ---------------------------------------------------------------------------------------------
623
+ // Matrix
624
+ // ---------------------------------------------------------------------------------------------
625
+
626
+ interface CaseResult {
627
+ root: string;
628
+ planStatus?: string;
629
+ errorCode?: string;
630
+ errorMessage?: string;
631
+ applyOk?: boolean;
632
+ replanChanges?: string[];
633
+ unchanged?: boolean;
634
+ setupError?: string;
635
+ }
636
+
637
+ describe("generated project matrix", () => {
638
+ const originalCwd = process.cwd();
639
+ const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-matrix-"));
640
+ const results = new Map<string, CaseResult>();
641
+
642
+ beforeAll(async () => {
643
+ const quiet = [jest.spyOn(console, "log"), jest.spyOn(console, "warn"), jest.spyOn(console, "info")]
644
+ .map((spy) => spy.mockImplementation(() => undefined));
645
+ try {
646
+ for (const [index, testCase] of CASES.entries()) {
647
+ const root = path.join(workRoot, String(index), PROJECT);
648
+ const result: CaseResult = { root };
649
+ results.set(testCase.name, result);
650
+ try {
651
+ await createSkeleton(root, testCase.frontend, testCase.backend, testCase.configFormat ?? "js");
652
+ process.chdir(root);
653
+ mockNativeToolchainValidation();
654
+ await applyAuth(root, testCase.auth);
655
+ const policy = POLICIES[testCase.policy];
656
+ write(path.join(root, "shared", "models", "probe-item.ts"), `${MODEL_SOURCE}${policy ? `export const authPolicy = ${policy};\n` : ""}`);
657
+ const before = snapshotTree(root);
658
+ const plan = await runMachine(["plan", "scaffold", "probe-item", "--skip-infra"]);
659
+ result.planStatus = plan.status;
660
+ if (!plan.ok) {
661
+ result.errorCode = plan.error.code;
662
+ result.errorMessage = plan.error.message;
663
+ const after = snapshotTree(root);
664
+ result.unchanged = after.size === before.size && [...before].every(([file, content]) => after.get(file) === content);
665
+ continue;
666
+ }
667
+ const apply = await runMachine(["apply", "scaffold", "--plan", plan.data.planId]);
668
+ result.applyOk = apply.ok;
669
+ if (!apply.ok) result.errorMessage = `${apply.error.code}: ${apply.error.message}`;
670
+ else {
671
+ const replan = await runMachine(["plan", "scaffold", "probe-item", "--skip-infra"]);
672
+ result.replanChanges = replan.ok
673
+ ? replan.data.operations.filter((op: { action: string }) => op.action !== "skip").map((op: { path: string; action: string }) => `${op.action} ${op.path}`)
674
+ : [`${replan.error.code}: ${replan.error.message}`];
675
+ }
676
+ } catch (error) {
677
+ result.setupError = error instanceof Error ? error.stack ?? error.message : String(error);
678
+ } finally {
679
+ process.chdir(originalCwd);
680
+ }
681
+ }
682
+ } finally {
683
+ quiet.forEach((spy) => spy.mockRestore());
684
+ }
685
+ }, 10 * 60 * 1000);
686
+
687
+ afterAll(() => {
688
+ process.chdir(originalCwd);
689
+ if (!process.env.SWALLOWKIT_KEEP_MATRIX) fs.rmSync(workRoot, { recursive: true, force: true });
690
+ });
691
+
692
+ describe.each(CASES)("$name", (testCase) => {
693
+ const blocked = expectedBlock(testCase.auth, testCase.policy);
694
+
695
+ if (blocked) {
696
+ it(`is blocked with ${blocked} before writing anything`, () => {
697
+ const result = results.get(testCase.name)!;
698
+ expect(result.setupError).toBeUndefined();
699
+ expect({ status: result.planStatus, code: result.errorCode }).toEqual({ status: "blocked", code: blocked });
700
+ expect(result.unchanged).toBe(true);
701
+ });
702
+ return;
703
+ }
704
+
705
+ it("plans and applies scaffold", () => {
706
+ const result = results.get(testCase.name)!;
707
+ expect(result.setupError).toBeUndefined();
708
+ expect({ status: result.planStatus, error: result.errorMessage, applied: result.applyOk })
709
+ .toEqual({ status: "complete", error: undefined, applied: true });
710
+ });
711
+
712
+ it("is idempotent: re-planning right after apply changes nothing", () => {
713
+ const result = results.get(testCase.name)!;
714
+ if (!result.applyOk) return;
715
+ expect(result.replanChanges).toEqual([]);
716
+ });
717
+
718
+ it("type-checks the UI and BFF", () => {
719
+ const result = results.get(testCase.name)!;
720
+ if (!result.applyOk) return; // reported by the scaffold assertion above
721
+ const sharedIndex = path.join(result.root, "shared", "index.ts");
722
+ const diagnostics = [
723
+ ...typecheck(path.join(result.root, "tsconfig.json"), sharedIndex),
724
+ ...(testCase.frontend === "react"
725
+ // The managed BFF pins @azure/functions v3 (classic function.json model), unlike the v4 standalone backend.
726
+ ? typecheck(path.join(result.root, "bff", "tsconfig.json"), sharedIndex, { "@azure/functions": path.join(repoRoot, "node_modules", "azure-functions-v3") })
727
+ : []),
728
+ ];
729
+ expect(diagnostics).toEqual([]);
730
+ });
731
+
732
+ it("declares every npm package that generated UI/BFF code imports", () => {
733
+ const result = results.get(testCase.name)!;
734
+ if (!result.applyOk) return;
735
+ const root = result.root;
736
+ // Next.js bundles the BFF (app/api) with the UI; the React BFF is its own workspace package.
737
+ const frontendRoots = testCase.frontend === "react" ? ["src", "lib"] : ["app", "lib", "proxy.ts"];
738
+ expect(undeclaredPackages(frontendRoots.map((dir) => path.join(root, dir)), [path.join(root, "package.json")], ["vite"])).toEqual([]);
739
+ if (testCase.frontend === "react") {
740
+ expect(undeclaredPackages([path.join(root, "bff", "src")], [path.join(root, "bff", "package.json")])).toEqual([]);
741
+ }
742
+ });
743
+
744
+ if (testCase.backend === "typescript") {
745
+ it("declares every npm package that the TypeScript Functions import", () => {
746
+ const result = results.get(testCase.name)!;
747
+ if (!result.applyOk) return;
748
+ expect(undeclaredPackages([path.join(result.root, "functions", "src")], [path.join(result.root, "functions", "package.json")])).toEqual([]);
749
+ });
750
+
751
+ it("type-checks the standalone TypeScript Functions", () => {
752
+ const result = results.get(testCase.name)!;
753
+ if (!result.applyOk) return;
754
+ expect(listSources(path.join(result.root, "functions", "src")).length).toBeGreaterThan(0);
755
+ expect(typecheck(path.join(result.root, "functions", "tsconfig.json"), path.join(result.root, "shared", "index.ts"))).toEqual([]);
756
+ });
757
+ }
758
+
759
+ if (testCase.backend === "python") {
760
+ toolchainGate(Boolean(PYTHON), "Python >= 3.10")("parses Python Functions and resolves every project-local import and requirement", () => {
761
+ const result = results.get(testCase.name)!;
762
+ if (!result.applyOk) return;
763
+ expect(checkPython(path.join(result.root, "functions"))).toEqual([]);
764
+ });
765
+ }
766
+ });
767
+
768
+ describe("C# standalone Functions compile with dotnet build", () => {
769
+ const csharpCases = CASES.filter((testCase) => testCase.backend === "csharp" && !expectedBlock(testCase.auth, testCase.policy));
770
+ const builds = new Map<string, string[]>();
771
+ const skipDotnet = process.env.SWALLOWKIT_TEST_SKIP_DOTNET === "1";
772
+ const itWithDotnet = toolchainGate(DOTNET, ".NET SDK", skipDotnet);
773
+
774
+ beforeAll(async () => {
775
+ if (!DOTNET || skipDotnet) return;
776
+ const buildable = csharpCases.filter((testCase) => results.get(testCase.name)?.applyOk);
777
+ const outputs = await mapWithConcurrency(buildable, Math.max(1, Math.min(4, os.cpus().length - 1)),
778
+ (testCase) => dotnetBuild(path.join(results.get(testCase.name)!.root, "functions")));
779
+ buildable.forEach((testCase, index) => builds.set(testCase.name, outputs[index]));
780
+ }, 20 * 60 * 1000);
781
+
782
+ for (const { name } of csharpCases) {
783
+ itWithDotnet(name, () => {
784
+ if (!results.get(name)?.applyOk) return; // reported by the scaffold assertion
785
+ expect(builds.get(name)).toEqual([]);
786
+ });
787
+ }
788
+ });
789
+ });