swallowkit 1.0.0-beta.55 → 1.0.0-beta.57
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.
- package/dist/cli/commands/add-auth.d.ts.map +1 -1
- package/dist/cli/commands/add-auth.js +154 -157
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/init.d.ts +5 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +178 -25
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +7 -2
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +113 -123
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +127 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +8 -4
- package/dist/core/config.js.map +1 -1
- package/dist/core/operations/auth-operations.d.ts +57 -0
- package/dist/core/operations/auth-operations.d.ts.map +1 -0
- package/dist/core/operations/auth-operations.js +269 -0
- package/dist/core/operations/auth-operations.js.map +1 -0
- package/dist/core/operations/create-model.d.ts.map +1 -1
- package/dist/core/operations/create-model.js +23 -13
- package/dist/core/operations/create-model.js.map +1 -1
- package/dist/core/operations/file-session.d.ts +56 -0
- package/dist/core/operations/file-session.d.ts.map +1 -0
- package/dist/core/operations/file-session.js +203 -0
- package/dist/core/operations/file-session.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +52 -0
- package/dist/core/operations/provision-operations.d.ts.map +1 -0
- package/dist/core/operations/provision-operations.js +211 -0
- package/dist/core/operations/provision-operations.js.map +1 -0
- package/dist/core/operations/runtime.d.ts +5 -0
- package/dist/core/operations/runtime.d.ts.map +1 -1
- package/dist/core/operations/runtime.js +23 -0
- package/dist/core/operations/runtime.js.map +1 -1
- package/dist/core/operations/scaffold-apply.d.ts +21 -0
- package/dist/core/operations/scaffold-apply.d.ts.map +1 -0
- package/dist/core/operations/scaffold-apply.js +57 -0
- package/dist/core/operations/scaffold-apply.js.map +1 -0
- package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
- package/dist/core/operations/scaffold-machine.js +1 -13
- package/dist/core/operations/scaffold-machine.js.map +1 -1
- package/dist/core/operations/scaffold-plan.d.ts +43 -0
- package/dist/core/operations/scaffold-plan.d.ts.map +1 -0
- package/dist/core/operations/scaffold-plan.js +187 -0
- package/dist/core/operations/scaffold-plan.js.map +1 -0
- package/dist/core/project/artifacts.d.ts +58 -0
- package/dist/core/project/artifacts.d.ts.map +1 -0
- package/dist/core/project/artifacts.js +179 -0
- package/dist/core/project/artifacts.js.map +1 -0
- package/dist/core/project/boundaries.d.ts +50 -0
- package/dist/core/project/boundaries.d.ts.map +1 -0
- package/dist/core/project/boundaries.js +117 -0
- package/dist/core/project/boundaries.js.map +1 -0
- package/dist/core/project/drift.d.ts +29 -0
- package/dist/core/project/drift.d.ts.map +1 -0
- package/dist/core/project/drift.js +188 -0
- package/dist/core/project/drift.js.map +1 -0
- package/dist/core/project/infra.d.ts +49 -0
- package/dist/core/project/infra.d.ts.map +1 -0
- package/dist/core/project/infra.js +167 -0
- package/dist/core/project/infra.js.map +1 -0
- package/dist/core/project/state.d.ts +15 -0
- package/dist/core/project/state.d.ts.map +1 -0
- package/dist/core/project/state.js +106 -0
- package/dist/core/project/state.js.map +1 -0
- package/dist/core/project/workflows.d.ts +43 -0
- package/dist/core/project/workflows.d.ts.map +1 -0
- package/dist/core/project/workflows.js +255 -0
- package/dist/core/project/workflows.js.map +1 -0
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +24 -27
- package/dist/core/scaffold/auth-generator.js.map +1 -1
- package/dist/core/scaffold/functions-generator.js +9 -9
- package/dist/core/scaffold/native-schema-generator.d.ts +7 -1
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +104 -47
- package/dist/core/scaffold/native-schema-generator.js.map +1 -1
- package/dist/core/scaffold/nextjs-generator.js +6 -6
- package/dist/core/scaffold/ui-generator.js +19 -19
- package/dist/core/scaffold/ui-generator.js.map +1 -1
- package/dist/core/verify/index.d.ts +62 -0
- package/dist/core/verify/index.d.ts.map +1 -0
- package/dist/core/verify/index.js +343 -0
- package/dist/core/verify/index.js.map +1 -0
- package/dist/machine/contracts.d.ts +8 -0
- package/dist/machine/contracts.d.ts.map +1 -1
- package/dist/machine/errors.d.ts +4 -1
- package/dist/machine/errors.d.ts.map +1 -1
- package/dist/machine/errors.js +9 -1
- package/dist/machine/errors.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +350 -16
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +202 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/types/index.d.ts +14 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +17 -17
- package/src/__tests__/__snapshots__/nextjs-generator.test.ts.snap +6 -6
- package/src/__tests__/__snapshots__/ui-generator.test.ts.snap +11 -11
- package/src/__tests__/agent-loop-phase34.test.ts +449 -0
- package/src/__tests__/file-session.test.ts +98 -0
- package/src/__tests__/init.test.ts +50 -0
- package/src/__tests__/mcp.test.ts +113 -5
- package/src/__tests__/plan-apply.test.ts +461 -0
- package/src/__tests__/scaffold.test.ts +2 -2
- package/src/cli/commands/add-auth.ts +164 -164
- package/src/cli/commands/init.ts +186 -24
- package/src/cli/commands/scaffold.ts +136 -130
- package/src/cli/index.ts +148 -2
- package/src/core/config.ts +5 -4
- package/src/core/operations/auth-operations.ts +353 -0
- package/src/core/operations/create-model.ts +25 -13
- package/src/core/operations/file-session.ts +222 -0
- package/src/core/operations/provision-operations.ts +297 -0
- package/src/core/operations/runtime.ts +23 -0
- package/src/core/operations/scaffold-apply.ts +106 -0
- package/src/core/operations/scaffold-machine.ts +2 -16
- package/src/core/operations/scaffold-plan.ts +215 -0
- package/src/core/project/artifacts.ts +205 -0
- package/src/core/project/boundaries.ts +170 -0
- package/src/core/project/drift.ts +202 -0
- package/src/core/project/infra.ts +196 -0
- package/src/core/project/state.ts +77 -0
- package/src/core/project/workflows.ts +249 -0
- package/src/core/scaffold/auth-generator.ts +24 -27
- package/src/core/scaffold/functions-generator.ts +9 -9
- package/src/core/scaffold/native-schema-generator.ts +152 -70
- package/src/core/scaffold/nextjs-generator.ts +6 -6
- package/src/core/scaffold/ui-generator.ts +19 -19
- package/src/core/verify/index.ts +392 -0
- package/src/machine/contracts.ts +16 -0
- package/src/machine/errors.ts +12 -1
- package/src/machine/index.ts +444 -15
- package/src/mcp/index.ts +198 -2
- package/src/types/index.ts +17 -0
|
@@ -81,6 +81,110 @@ describe("SwallowKit MCP tool definitions", () => {
|
|
|
81
81
|
expect(JSON.parse(result.content[0].text)).toEqual({ createdFiles: ["functions/src/todo.ts"] });
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
it("exposes the agent-loop tools (plan/apply/artifacts/drift/verify/explain)", () => {
|
|
85
|
+
const names = buildSwallowKitToolDefinitions(jest.fn() as unknown as MachineCliRunner).map((tool) => tool.name);
|
|
86
|
+
expect(names).toEqual(
|
|
87
|
+
expect.arrayContaining([
|
|
88
|
+
"swallowkit_plan_scaffold",
|
|
89
|
+
"swallowkit_apply_scaffold",
|
|
90
|
+
"swallowkit_inspect_artifacts",
|
|
91
|
+
"swallowkit_inspect_drift",
|
|
92
|
+
"swallowkit_verify_project",
|
|
93
|
+
"swallowkit_explain_failure",
|
|
94
|
+
])
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("delegates plan_scaffold and passes through status and nextActions", async () => {
|
|
99
|
+
const runner = jest.fn(async () => ({
|
|
100
|
+
stdout: JSON.stringify({
|
|
101
|
+
ok: true,
|
|
102
|
+
command: "plan-scaffold",
|
|
103
|
+
status: "requires-human",
|
|
104
|
+
nextActions: [{ command: "swallowkit machine apply scaffold --plan abc --approve", description: "Apply." }],
|
|
105
|
+
data: { planId: "abc", requiresApproval: true, operations: [] },
|
|
106
|
+
}),
|
|
107
|
+
stderr: "",
|
|
108
|
+
exitCode: 0,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
const tool = buildSwallowKitToolDefinitions(runner as MachineCliRunner).find(
|
|
112
|
+
(candidate) => candidate.name === "swallowkit_plan_scaffold"
|
|
113
|
+
);
|
|
114
|
+
const result = await tool!.handler({ model: "todo", apiOnly: true });
|
|
115
|
+
|
|
116
|
+
expect(runner).toHaveBeenCalledWith(["plan", "scaffold", "todo", "--api-only"]);
|
|
117
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
118
|
+
expect(parsed.status).toBe("requires-human");
|
|
119
|
+
expect(parsed.planId).toBe("abc");
|
|
120
|
+
expect(parsed.nextActions).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("delegates apply_scaffold with plan and approve flags", async () => {
|
|
124
|
+
const runner = jest.fn(async () => ({
|
|
125
|
+
stdout: JSON.stringify({
|
|
126
|
+
ok: true,
|
|
127
|
+
command: "apply-scaffold",
|
|
128
|
+
status: "complete",
|
|
129
|
+
data: { createdFiles: [] },
|
|
130
|
+
}),
|
|
131
|
+
stderr: "",
|
|
132
|
+
exitCode: 0,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const tool = buildSwallowKitToolDefinitions(runner as MachineCliRunner).find(
|
|
136
|
+
(candidate) => candidate.name === "swallowkit_apply_scaffold"
|
|
137
|
+
);
|
|
138
|
+
await tool!.handler({ planId: "abc", approve: true });
|
|
139
|
+
|
|
140
|
+
expect(runner).toHaveBeenCalledWith(["apply", "scaffold", "--plan", "abc", "--approve"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("surfaces machine error code, status, and details to the agent", async () => {
|
|
144
|
+
const runner = jest.fn(async () => ({
|
|
145
|
+
stdout: JSON.stringify({
|
|
146
|
+
ok: false,
|
|
147
|
+
command: "apply-scaffold",
|
|
148
|
+
status: "blocked",
|
|
149
|
+
error: {
|
|
150
|
+
code: "stale-plan",
|
|
151
|
+
message: "Plan is stale.",
|
|
152
|
+
details: { changedFiles: ["shared/models/todo.ts"] },
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
stderr: "",
|
|
156
|
+
exitCode: 1,
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
const tool = buildSwallowKitToolDefinitions(runner as MachineCliRunner).find(
|
|
160
|
+
(candidate) => candidate.name === "swallowkit_apply_scaffold"
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await expect(tool!.handler({ planId: "abc" })).rejects.toThrow(
|
|
164
|
+
/\[stale-plan\] Plan is stale\. \(status: blocked\)[\s\S]*changedFiles/
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("delegates verify_project with selected checks", async () => {
|
|
169
|
+
const runner = jest.fn(async () => ({
|
|
170
|
+
stdout: JSON.stringify({
|
|
171
|
+
ok: true,
|
|
172
|
+
command: "verify-project",
|
|
173
|
+
status: "complete",
|
|
174
|
+
data: { summary: { done: true } },
|
|
175
|
+
}),
|
|
176
|
+
stderr: "",
|
|
177
|
+
exitCode: 0,
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
const tool = buildSwallowKitToolDefinitions(runner as MachineCliRunner).find(
|
|
181
|
+
(candidate) => candidate.name === "swallowkit_verify_project"
|
|
182
|
+
);
|
|
183
|
+
await tool!.handler({ checks: ["structure", "drift"] });
|
|
184
|
+
|
|
185
|
+
expect(runner).toHaveBeenCalledWith(["verify", "project", "--checks", "structure,drift"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
84
188
|
itWhenBuiltEntrypointExists("keeps the built MCP entrypoint alive long enough to complete the handshake", async () => {
|
|
85
189
|
await new Promise<void>((resolve, reject) => {
|
|
86
190
|
const child = spawn(process.execPath, [builtMcpEntrypoint], {
|
|
@@ -105,13 +209,17 @@ describe("SwallowKit MCP tool definitions", () => {
|
|
|
105
209
|
}
|
|
106
210
|
};
|
|
107
211
|
|
|
108
|
-
|
|
109
|
-
expectedShutdown = true;
|
|
110
|
-
child.kill();
|
|
111
|
-
}, 1000);
|
|
212
|
+
let timer: NodeJS.Timeout | undefined;
|
|
112
213
|
|
|
113
214
|
child.stderr.on("data", (chunk) => {
|
|
114
215
|
stderr += chunk.toString();
|
|
216
|
+
// Start the aliveness window once the entrypoint has actually booted.
|
|
217
|
+
if (!timer && /\[swallowkit-mcp\] version: /.test(stderr)) {
|
|
218
|
+
timer = setTimeout(() => {
|
|
219
|
+
expectedShutdown = true;
|
|
220
|
+
child.kill();
|
|
221
|
+
}, 1000);
|
|
222
|
+
}
|
|
115
223
|
});
|
|
116
224
|
|
|
117
225
|
child.on("error", (error) => {
|
|
@@ -135,7 +243,7 @@ describe("SwallowKit MCP tool definitions", () => {
|
|
|
135
243
|
);
|
|
136
244
|
});
|
|
137
245
|
});
|
|
138
|
-
});
|
|
246
|
+
}, 20000);
|
|
139
247
|
|
|
140
248
|
itWhenBuiltEntrypointExists("preserves a runtime dynamic execa import in the built MCP entrypoint", () => {
|
|
141
249
|
const source = fs.readFileSync(builtMcpEntrypoint, "utf8");
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as childProcess from "child_process";
|
|
5
|
+
import { EventEmitter } from "events";
|
|
6
|
+
import { runMachineCli } from "../machine";
|
|
7
|
+
|
|
8
|
+
jest.mock("child_process", () => {
|
|
9
|
+
const actual = jest.requireActual<typeof import("child_process")>("child_process");
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
spawn: jest.fn(),
|
|
13
|
+
spawnSync: jest.fn(),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const repoRoot = process.cwd();
|
|
18
|
+
|
|
19
|
+
function writeFile(filePath: string, content: string): void {
|
|
20
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
21
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createModelSource(name: string, extraField = ""): string {
|
|
25
|
+
return `import { z } from 'zod/v4';
|
|
26
|
+
|
|
27
|
+
export const ${name} = z.object({
|
|
28
|
+
id: z.string(),
|
|
29
|
+
name: z.string().min(1),${extraField}
|
|
30
|
+
createdAt: z.string().optional(),
|
|
31
|
+
updatedAt: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type ${name} = z.infer<typeof ${name}>;
|
|
35
|
+
|
|
36
|
+
export const displayName = '${name}';
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createProjectFixture(rootDir: string): void {
|
|
41
|
+
writeFile(path.join(rootDir, "package.json"), JSON.stringify({ name: "sample-app" }, null, 2));
|
|
42
|
+
writeFile(
|
|
43
|
+
path.join(rootDir, "swallowkit.config.js"),
|
|
44
|
+
`module.exports = {
|
|
45
|
+
database: {
|
|
46
|
+
connectionString: 'AccountEndpoint=https://example.local;',
|
|
47
|
+
},
|
|
48
|
+
backend: {
|
|
49
|
+
language: 'typescript',
|
|
50
|
+
},
|
|
51
|
+
api: {
|
|
52
|
+
endpoint: '/api/_swallowkit',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
`
|
|
56
|
+
);
|
|
57
|
+
writeFile(path.join(rootDir, "shared", "package.json"), JSON.stringify({ name: "@sample-app/shared" }, null, 2));
|
|
58
|
+
writeFile(path.join(rootDir, "shared", "index.ts"), "export {};\n");
|
|
59
|
+
writeFile(path.join(rootDir, "shared", "models", "todo.ts"), createModelSource("Todo"));
|
|
60
|
+
fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true });
|
|
61
|
+
fs.symlinkSync(
|
|
62
|
+
path.join(repoRoot, "node_modules", "zod"),
|
|
63
|
+
path.join(rootDir, "node_modules", "zod"),
|
|
64
|
+
"junction"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createNativeBackendFixture(rootDir: string, backendLanguage: "csharp" | "python"): void {
|
|
69
|
+
createProjectFixture(rootDir);
|
|
70
|
+
writeFile(
|
|
71
|
+
path.join(rootDir, "swallowkit.config.js"),
|
|
72
|
+
`module.exports = {
|
|
73
|
+
database: {
|
|
74
|
+
connectionString: 'AccountEndpoint=https://example.local;',
|
|
75
|
+
},
|
|
76
|
+
backend: {
|
|
77
|
+
language: '${backendLanguage}',
|
|
78
|
+
},
|
|
79
|
+
api: {
|
|
80
|
+
endpoint: '/api/_swallowkit',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
`
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (backendLanguage === "csharp") {
|
|
87
|
+
writeFile(path.join(rootDir, "functions", "SwallowKit.Functions.csproj"), "<Project />\n");
|
|
88
|
+
} else {
|
|
89
|
+
writeFile(
|
|
90
|
+
path.join(rootDir, "functions", "function_app.py"),
|
|
91
|
+
"from azure.functions import FunctionApp\n\napp = FunctionApp()\n\n# SwallowKit scaffold registrations\n"
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mockSuccessfulNativeCodegen(): () => void {
|
|
97
|
+
const spawnSyncMock = childProcess.spawnSync as unknown as any;
|
|
98
|
+
const spawnMock = childProcess.spawn as unknown as any;
|
|
99
|
+
spawnSyncMock.mockReturnValue({ status: 0 });
|
|
100
|
+
spawnMock.mockImplementation(() => {
|
|
101
|
+
const child = new EventEmitter() as childProcess.ChildProcess;
|
|
102
|
+
process.nextTick(() => child.emit("close", 0));
|
|
103
|
+
return child;
|
|
104
|
+
});
|
|
105
|
+
return () => {
|
|
106
|
+
spawnMock.mockReset();
|
|
107
|
+
spawnSyncMock.mockReset();
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function runMachine(argv: string[]): Promise<{ response: any; exitCode: number }> {
|
|
112
|
+
const writes: string[] = [];
|
|
113
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
114
|
+
const originalExitCode = process.exitCode;
|
|
115
|
+
|
|
116
|
+
(process.stdout.write as unknown as (chunk: string | Uint8Array) => boolean) = ((chunk: string | Uint8Array) => {
|
|
117
|
+
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8"));
|
|
118
|
+
return true;
|
|
119
|
+
}) as typeof process.stdout.write;
|
|
120
|
+
|
|
121
|
+
process.exitCode = 0;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await runMachineCli(argv);
|
|
125
|
+
return {
|
|
126
|
+
response: JSON.parse(writes.join("")),
|
|
127
|
+
exitCode: process.exitCode || 0,
|
|
128
|
+
};
|
|
129
|
+
} finally {
|
|
130
|
+
process.stdout.write = originalWrite;
|
|
131
|
+
process.exitCode = originalExitCode;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function machineArgs(...args: string[]): string[] {
|
|
136
|
+
return ["node", "swallowkit", "machine", ...args];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe("plan / apply / drift / verify machine commands", () => {
|
|
140
|
+
const originalCwd = process.cwd();
|
|
141
|
+
let tempDir: string;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-agent-"));
|
|
145
|
+
process.chdir(tempDir);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
process.chdir(originalCwd);
|
|
150
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("plans a scaffold without writing files and reports operations", async () => {
|
|
154
|
+
createProjectFixture(tempDir);
|
|
155
|
+
|
|
156
|
+
const { response, exitCode } = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
|
|
157
|
+
|
|
158
|
+
expect(exitCode).toBe(0);
|
|
159
|
+
expect(response.ok).toBe(true);
|
|
160
|
+
expect(response.command).toBe("plan-scaffold");
|
|
161
|
+
expect(response.status).toBe("complete");
|
|
162
|
+
expect(response.data.planId).toMatch(/^[0-9a-f]{12}$/);
|
|
163
|
+
expect(response.data.requiresApproval).toBe(false);
|
|
164
|
+
expect(response.data.operations).toEqual(
|
|
165
|
+
expect.arrayContaining([
|
|
166
|
+
expect.objectContaining({ path: "app/api/todo/route.ts", action: "create", ownership: "managed" }),
|
|
167
|
+
expect.objectContaining({ path: "functions/src/todo.ts", action: "create" }),
|
|
168
|
+
])
|
|
169
|
+
);
|
|
170
|
+
expect(response.nextActions[0].command).toContain(`apply scaffold --plan ${response.data.planId}`);
|
|
171
|
+
|
|
172
|
+
// No generated files were written
|
|
173
|
+
expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
|
|
174
|
+
expect(fs.existsSync(path.join(tempDir, "functions"))).toBe(false);
|
|
175
|
+
// But the plan state was persisted
|
|
176
|
+
expect(
|
|
177
|
+
fs.existsSync(path.join(tempDir, ".swallowkit", "state", "plans", `${response.data.planId}.json`))
|
|
178
|
+
).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("applies a previously computed plan and records the artifact ledger", async () => {
|
|
182
|
+
createProjectFixture(tempDir);
|
|
183
|
+
|
|
184
|
+
const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
|
|
185
|
+
const planId = plan.response.data.planId;
|
|
186
|
+
|
|
187
|
+
const { response, exitCode } = await runMachine(machineArgs("apply", "scaffold", "--plan", planId));
|
|
188
|
+
|
|
189
|
+
expect(exitCode).toBe(0);
|
|
190
|
+
expect(response.ok).toBe(true);
|
|
191
|
+
expect(response.command).toBe("apply-scaffold");
|
|
192
|
+
expect(response.status).toBe("complete");
|
|
193
|
+
expect(response.data.createdFiles).toEqual(
|
|
194
|
+
expect.arrayContaining(["app/api/todo/route.ts", "functions/src/todo.ts"])
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Artifact ledger created and tracks the generated files
|
|
198
|
+
const ledgerPath = path.join(tempDir, ".swallowkit", "artifacts.json");
|
|
199
|
+
expect(fs.existsSync(ledgerPath)).toBe(true);
|
|
200
|
+
const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf-8"));
|
|
201
|
+
expect(ledger.artifacts).toEqual(
|
|
202
|
+
expect.arrayContaining([
|
|
203
|
+
expect.objectContaining({ path: "app/api/todo/route.ts", ownership: "managed", sourceModel: "Todo" }),
|
|
204
|
+
])
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Applied plan state is cleaned up
|
|
208
|
+
expect(fs.existsSync(path.join(tempDir, ".swallowkit", "state", "plans", `${planId}.json`))).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it.each([
|
|
212
|
+
{
|
|
213
|
+
backendLanguage: "csharp" as const,
|
|
214
|
+
modelPath: "functions/generated/csharp-models/src/SwallowKitBackendModels/Model/Todo.cs",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
backendLanguage: "python" as const,
|
|
218
|
+
modelPath: "functions/generated/python-models/backend_models/models/todo.py",
|
|
219
|
+
},
|
|
220
|
+
])(
|
|
221
|
+
"includes $backendLanguage native schema artifacts in plan, ledger, and approval checks",
|
|
222
|
+
async ({ backendLanguage, modelPath }) => {
|
|
223
|
+
createNativeBackendFixture(tempDir, backendLanguage);
|
|
224
|
+
const restoreCodegenMocks = mockSuccessfulNativeCodegen();
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
|
|
228
|
+
|
|
229
|
+
expect(plan.response).toEqual(expect.objectContaining({ ok: true, status: "complete" }));
|
|
230
|
+
expect(plan.exitCode).toBe(0);
|
|
231
|
+
expect(plan.response.data.warnings).not.toEqual(
|
|
232
|
+
expect.arrayContaining([expect.stringContaining("external-codegen")])
|
|
233
|
+
);
|
|
234
|
+
expect(plan.response.data.operations).toEqual(
|
|
235
|
+
expect.arrayContaining([
|
|
236
|
+
expect.objectContaining({ path: "functions/openapi/todo.openapi.json", action: "create" }),
|
|
237
|
+
expect.objectContaining({ path: modelPath, action: "create", ownership: "managed" }),
|
|
238
|
+
])
|
|
239
|
+
);
|
|
240
|
+
expect(fs.existsSync(path.join(tempDir, "functions", "openapi"))).toBe(false);
|
|
241
|
+
expect(fs.existsSync(path.join(tempDir, modelPath))).toBe(false);
|
|
242
|
+
expect(childProcess.spawn).not.toHaveBeenCalled();
|
|
243
|
+
|
|
244
|
+
const applied = await runMachine(
|
|
245
|
+
machineArgs("apply", "scaffold", "--plan", plan.response.data.planId)
|
|
246
|
+
);
|
|
247
|
+
expect(applied.exitCode).toBe(0);
|
|
248
|
+
expect(fs.existsSync(path.join(tempDir, modelPath))).toBe(true);
|
|
249
|
+
|
|
250
|
+
const ledger = JSON.parse(
|
|
251
|
+
fs.readFileSync(path.join(tempDir, ".swallowkit", "artifacts.json"), "utf-8")
|
|
252
|
+
);
|
|
253
|
+
expect(ledger.artifacts).toEqual(
|
|
254
|
+
expect.arrayContaining([
|
|
255
|
+
expect.objectContaining({ path: modelPath, ownership: "managed", generator: "native-schema" }),
|
|
256
|
+
])
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
fs.appendFileSync(path.join(tempDir, modelPath), "\n// customized\n");
|
|
260
|
+
const denied = await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
|
|
261
|
+
expect(denied.exitCode).toBe(1);
|
|
262
|
+
expect(denied.response.error.code).toBe("approval-required");
|
|
263
|
+
expect(denied.response.error.details.conflicts).toEqual(
|
|
264
|
+
expect.arrayContaining([
|
|
265
|
+
expect.objectContaining({ path: modelPath, conflictReason: "modified-after-generation" }),
|
|
266
|
+
])
|
|
267
|
+
);
|
|
268
|
+
} finally {
|
|
269
|
+
restoreCodegenMocks();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
it("leaves the project untouched when the native toolchain validation fails on apply", async () => {
|
|
275
|
+
createNativeBackendFixture(tempDir, "csharp");
|
|
276
|
+
const spawnSyncMock = childProcess.spawnSync as unknown as any;
|
|
277
|
+
// dotnet --version が失敗 → .NET SDK 不在としてプリフライトで拒否される
|
|
278
|
+
spawnSyncMock.mockReturnValue({ status: 1 });
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const { response, exitCode } = await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
|
|
282
|
+
|
|
283
|
+
expect(exitCode).toBe(1);
|
|
284
|
+
expect(response.ok).toBe(false);
|
|
285
|
+
expect(response.error.message).toContain(".NET SDK is required");
|
|
286
|
+
|
|
287
|
+
expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
|
|
288
|
+
expect(fs.existsSync(path.join(tempDir, "lib"))).toBe(false);
|
|
289
|
+
expect(fs.existsSync(path.join(tempDir, "functions", "openapi"))).toBe(false);
|
|
290
|
+
expect(fs.existsSync(path.join(tempDir, "functions", "generated"))).toBe(false);
|
|
291
|
+
expect(fs.existsSync(path.join(tempDir, "functions", "Crud"))).toBe(false);
|
|
292
|
+
expect(fs.existsSync(path.join(tempDir, ".swallowkit", "artifacts.json"))).toBe(false);
|
|
293
|
+
} finally {
|
|
294
|
+
spawnSyncMock.mockReset();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rejects a stale plan when files change after planning", async () => {
|
|
299
|
+
createProjectFixture(tempDir);
|
|
300
|
+
|
|
301
|
+
const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
|
|
302
|
+
const planId = plan.response.data.planId;
|
|
303
|
+
|
|
304
|
+
// Model schema changes after the plan was computed
|
|
305
|
+
writeFile(
|
|
306
|
+
path.join(tempDir, "shared", "models", "todo.ts"),
|
|
307
|
+
createModelSource("Todo", "\n priority: z.number().optional(),")
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const { response, exitCode } = await runMachine(machineArgs("apply", "scaffold", "--plan", planId));
|
|
311
|
+
|
|
312
|
+
expect(exitCode).toBe(1);
|
|
313
|
+
expect(response.ok).toBe(false);
|
|
314
|
+
expect(response.error.code).toBe("stale-plan");
|
|
315
|
+
expect(response.status).toBe("blocked");
|
|
316
|
+
expect(response.error.details.changedFiles).toContain("shared/models/todo.ts");
|
|
317
|
+
// Nothing was applied
|
|
318
|
+
expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("rejects a native backend plan when a nested model changes after planning", async () => {
|
|
322
|
+
createNativeBackendFixture(tempDir, "csharp");
|
|
323
|
+
writeFile(path.join(tempDir, "shared", "models", "member.ts"), createModelSource("Member"));
|
|
324
|
+
writeFile(
|
|
325
|
+
path.join(tempDir, "shared", "models", "todo.ts"),
|
|
326
|
+
`import { z } from 'zod/v4';
|
|
327
|
+
import { Member } from './member';
|
|
328
|
+
|
|
329
|
+
export const Todo = z.object({
|
|
330
|
+
id: z.string(),
|
|
331
|
+
name: z.string().min(1),
|
|
332
|
+
member: Member,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
export type Todo = z.infer<typeof Todo>;
|
|
336
|
+
`
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
|
|
340
|
+
expect(plan.response).toEqual(expect.objectContaining({ ok: true, status: "complete" }));
|
|
341
|
+
expect(plan.response.data.fingerprints["shared/models/member.ts"]).toMatch(/^[0-9a-f]{64}$/);
|
|
342
|
+
|
|
343
|
+
writeFile(
|
|
344
|
+
path.join(tempDir, "shared", "models", "member.ts"),
|
|
345
|
+
createModelSource("Member", "\n role: z.string().optional(),")
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const applied = await runMachine(
|
|
349
|
+
machineArgs("apply", "scaffold", "--plan", plan.response.data.planId)
|
|
350
|
+
);
|
|
351
|
+
expect(applied.exitCode).toBe(1);
|
|
352
|
+
expect(applied.response.error.code).toBe("stale-plan");
|
|
353
|
+
expect(applied.response.error.details.changedFiles).toContain("shared/models/member.ts");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("requires approval to overwrite files modified after generation, then applies with --approve", async () => {
|
|
357
|
+
createProjectFixture(tempDir);
|
|
358
|
+
|
|
359
|
+
await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
|
|
360
|
+
|
|
361
|
+
// User modifies a managed artifact
|
|
362
|
+
const routePath = path.join(tempDir, "app", "api", "todo", "route.ts");
|
|
363
|
+
const customized = `// customized by user\n${fs.readFileSync(routePath, "utf-8")}`;
|
|
364
|
+
fs.writeFileSync(routePath, customized, "utf-8");
|
|
365
|
+
|
|
366
|
+
const denied = await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
|
|
367
|
+
|
|
368
|
+
expect(denied.exitCode).toBe(1);
|
|
369
|
+
expect(denied.response.ok).toBe(false);
|
|
370
|
+
expect(denied.response.error.code).toBe("approval-required");
|
|
371
|
+
expect(denied.response.status).toBe("requires-human");
|
|
372
|
+
expect(denied.response.error.details.conflicts).toEqual(
|
|
373
|
+
expect.arrayContaining([
|
|
374
|
+
expect.objectContaining({
|
|
375
|
+
path: "app/api/todo/route.ts",
|
|
376
|
+
action: "overwrite",
|
|
377
|
+
conflictReason: "modified-after-generation",
|
|
378
|
+
}),
|
|
379
|
+
])
|
|
380
|
+
);
|
|
381
|
+
// The customization is untouched
|
|
382
|
+
expect(fs.readFileSync(routePath, "utf-8")).toBe(customized);
|
|
383
|
+
|
|
384
|
+
const approved = await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only", "--approve"));
|
|
385
|
+
|
|
386
|
+
expect(approved.exitCode).toBe(0);
|
|
387
|
+
expect(approved.response.ok).toBe(true);
|
|
388
|
+
expect(approved.response.data.approvedConflicts).toEqual(
|
|
389
|
+
expect.arrayContaining([expect.objectContaining({ path: "app/api/todo/route.ts" })])
|
|
390
|
+
);
|
|
391
|
+
expect(fs.readFileSync(routePath, "utf-8")).not.toContain("customized by user");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("inspects artifacts and detects drift after manual edits and schema changes", async () => {
|
|
395
|
+
createProjectFixture(tempDir);
|
|
396
|
+
await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
|
|
397
|
+
|
|
398
|
+
const artifacts = await runMachine(machineArgs("inspect", "artifacts"));
|
|
399
|
+
expect(artifacts.response.ok).toBe(true);
|
|
400
|
+
expect(artifacts.response.data.ledgerFound).toBe(true);
|
|
401
|
+
expect(artifacts.response.data.artifacts).toEqual(
|
|
402
|
+
expect.arrayContaining([
|
|
403
|
+
expect.objectContaining({ path: "functions/src/todo.ts", exists: true, modified: false }),
|
|
404
|
+
])
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Clean project → no error/warning drift about artifacts
|
|
408
|
+
const cleanDrift = await runMachine(machineArgs("inspect", "drift"));
|
|
409
|
+
expect(cleanDrift.response.ok).toBe(true);
|
|
410
|
+
expect(
|
|
411
|
+
cleanDrift.response.data.findings.filter((f: any) => f.kind === "artifact-modified" && f.severity !== "info")
|
|
412
|
+
).toEqual([]);
|
|
413
|
+
|
|
414
|
+
// Manual edit to a managed artifact + schema change
|
|
415
|
+
fs.appendFileSync(path.join(tempDir, "functions", "src", "todo.ts"), "\n// manual edit\n");
|
|
416
|
+
writeFile(
|
|
417
|
+
path.join(tempDir, "shared", "models", "todo.ts"),
|
|
418
|
+
createModelSource("Todo", "\n priority: z.number().optional(),")
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const drift = await runMachine(machineArgs("inspect", "drift"));
|
|
422
|
+
expect(drift.response.ok).toBe(true);
|
|
423
|
+
expect(drift.response.data.findings).toEqual(
|
|
424
|
+
expect.arrayContaining([
|
|
425
|
+
expect.objectContaining({ kind: "artifact-modified", path: "functions/src/todo.ts", severity: "warning" }),
|
|
426
|
+
expect.objectContaining({ kind: "schema-drift", entity: "Todo" }),
|
|
427
|
+
])
|
|
428
|
+
);
|
|
429
|
+
expect(drift.response.data.findings.every((f: any) => typeof f.repairAction === "string")).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("verifies the project and explains failures", async () => {
|
|
433
|
+
createProjectFixture(tempDir);
|
|
434
|
+
await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
|
|
435
|
+
|
|
436
|
+
const verify = await runMachine(machineArgs("verify", "project", "--checks", "structure,drift"));
|
|
437
|
+
|
|
438
|
+
expect(verify.exitCode).toBe(0);
|
|
439
|
+
expect(verify.response.ok).toBe(true);
|
|
440
|
+
expect(verify.response.command).toBe("verify-project");
|
|
441
|
+
expect(verify.response.status).toBe("complete");
|
|
442
|
+
expect(verify.response.data.summary.done).toBe(true);
|
|
443
|
+
expect(verify.response.data.checks.map((check: any) => check.id)).toEqual(["structure", "drift"]);
|
|
444
|
+
|
|
445
|
+
// explain failure with no failing checks returns an empty list
|
|
446
|
+
const explain = await runMachine(machineArgs("explain", "failure"));
|
|
447
|
+
expect(explain.response.ok).toBe(true);
|
|
448
|
+
expect(explain.response.data.failures).toEqual([]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("returns no-verification-result when explain failure runs before verify", async () => {
|
|
452
|
+
createProjectFixture(tempDir);
|
|
453
|
+
|
|
454
|
+
const { response, exitCode } = await runMachine(machineArgs("explain", "failure"));
|
|
455
|
+
|
|
456
|
+
expect(exitCode).toBe(1);
|
|
457
|
+
expect(response.ok).toBe(false);
|
|
458
|
+
expect(response.error.code).toBe("no-verification-result");
|
|
459
|
+
expect(response.status).toBe("blocked");
|
|
460
|
+
});
|
|
461
|
+
});
|
|
@@ -167,7 +167,7 @@ describe("native schema generators", () => {
|
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
it("builds NSwag arguments for model-only C# generation", () => {
|
|
170
|
-
const outputPath = path.join(
|
|
170
|
+
const outputPath = path.join(os.tmpdir(), "swallowkit-csharp-codegen-example", "Contracts.cs");
|
|
171
171
|
const args = getCSharpNativeGeneratorArgs("C:\\temp\\todo.openapi.json", outputPath);
|
|
172
172
|
|
|
173
173
|
expect(args.slice(0, 4)).toEqual(["tool", "run", "nswag", "openapi2csclient"]);
|
|
@@ -178,7 +178,7 @@ describe("native schema generators", () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
it("builds datamodel-code-generator arguments for python assets", () => {
|
|
181
|
-
const outputPath = path.join(
|
|
181
|
+
const outputPath = path.join(os.tmpdir(), "swallowkit-python-codegen-example", "models.py");
|
|
182
182
|
const args = getPythonNativeGeneratorArgs("C:\\temp\\todo.openapi.json", outputPath);
|
|
183
183
|
|
|
184
184
|
expect(args).toEqual(
|