swallowkit 1.0.0-beta.55 → 1.0.0-beta.56

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 (135) hide show
  1. package/dist/cli/commands/add-auth.d.ts.map +1 -1
  2. package/dist/cli/commands/add-auth.js +154 -157
  3. package/dist/cli/commands/add-auth.js.map +1 -1
  4. package/dist/cli/commands/init.d.ts +5 -0
  5. package/dist/cli/commands/init.d.ts.map +1 -1
  6. package/dist/cli/commands/init.js +178 -25
  7. package/dist/cli/commands/init.js.map +1 -1
  8. package/dist/cli/commands/scaffold.d.ts +7 -2
  9. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  10. package/dist/cli/commands/scaffold.js +111 -123
  11. package/dist/cli/commands/scaffold.js.map +1 -1
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +127 -2
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/core/config.d.ts +1 -1
  16. package/dist/core/config.d.ts.map +1 -1
  17. package/dist/core/config.js +8 -4
  18. package/dist/core/config.js.map +1 -1
  19. package/dist/core/operations/auth-operations.d.ts +57 -0
  20. package/dist/core/operations/auth-operations.d.ts.map +1 -0
  21. package/dist/core/operations/auth-operations.js +269 -0
  22. package/dist/core/operations/auth-operations.js.map +1 -0
  23. package/dist/core/operations/create-model.d.ts.map +1 -1
  24. package/dist/core/operations/create-model.js +23 -13
  25. package/dist/core/operations/create-model.js.map +1 -1
  26. package/dist/core/operations/file-session.d.ts +53 -0
  27. package/dist/core/operations/file-session.d.ts.map +1 -0
  28. package/dist/core/operations/file-session.js +195 -0
  29. package/dist/core/operations/file-session.js.map +1 -0
  30. package/dist/core/operations/provision-operations.d.ts +52 -0
  31. package/dist/core/operations/provision-operations.d.ts.map +1 -0
  32. package/dist/core/operations/provision-operations.js +211 -0
  33. package/dist/core/operations/provision-operations.js.map +1 -0
  34. package/dist/core/operations/runtime.d.ts +5 -0
  35. package/dist/core/operations/runtime.d.ts.map +1 -1
  36. package/dist/core/operations/runtime.js +23 -0
  37. package/dist/core/operations/runtime.js.map +1 -1
  38. package/dist/core/operations/scaffold-apply.d.ts +21 -0
  39. package/dist/core/operations/scaffold-apply.d.ts.map +1 -0
  40. package/dist/core/operations/scaffold-apply.js +57 -0
  41. package/dist/core/operations/scaffold-apply.js.map +1 -0
  42. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  43. package/dist/core/operations/scaffold-machine.js +1 -13
  44. package/dist/core/operations/scaffold-machine.js.map +1 -1
  45. package/dist/core/operations/scaffold-plan.d.ts +43 -0
  46. package/dist/core/operations/scaffold-plan.d.ts.map +1 -0
  47. package/dist/core/operations/scaffold-plan.js +195 -0
  48. package/dist/core/operations/scaffold-plan.js.map +1 -0
  49. package/dist/core/project/artifacts.d.ts +58 -0
  50. package/dist/core/project/artifacts.d.ts.map +1 -0
  51. package/dist/core/project/artifacts.js +179 -0
  52. package/dist/core/project/artifacts.js.map +1 -0
  53. package/dist/core/project/boundaries.d.ts +50 -0
  54. package/dist/core/project/boundaries.d.ts.map +1 -0
  55. package/dist/core/project/boundaries.js +117 -0
  56. package/dist/core/project/boundaries.js.map +1 -0
  57. package/dist/core/project/drift.d.ts +29 -0
  58. package/dist/core/project/drift.d.ts.map +1 -0
  59. package/dist/core/project/drift.js +188 -0
  60. package/dist/core/project/drift.js.map +1 -0
  61. package/dist/core/project/infra.d.ts +49 -0
  62. package/dist/core/project/infra.d.ts.map +1 -0
  63. package/dist/core/project/infra.js +167 -0
  64. package/dist/core/project/infra.js.map +1 -0
  65. package/dist/core/project/state.d.ts +15 -0
  66. package/dist/core/project/state.d.ts.map +1 -0
  67. package/dist/core/project/state.js +106 -0
  68. package/dist/core/project/state.js.map +1 -0
  69. package/dist/core/project/workflows.d.ts +43 -0
  70. package/dist/core/project/workflows.d.ts.map +1 -0
  71. package/dist/core/project/workflows.js +255 -0
  72. package/dist/core/project/workflows.js.map +1 -0
  73. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  74. package/dist/core/scaffold/auth-generator.js +24 -27
  75. package/dist/core/scaffold/auth-generator.js.map +1 -1
  76. package/dist/core/scaffold/functions-generator.js +9 -9
  77. package/dist/core/scaffold/nextjs-generator.js +6 -6
  78. package/dist/core/scaffold/ui-generator.js +19 -19
  79. package/dist/core/scaffold/ui-generator.js.map +1 -1
  80. package/dist/core/verify/index.d.ts +62 -0
  81. package/dist/core/verify/index.d.ts.map +1 -0
  82. package/dist/core/verify/index.js +343 -0
  83. package/dist/core/verify/index.js.map +1 -0
  84. package/dist/machine/contracts.d.ts +8 -0
  85. package/dist/machine/contracts.d.ts.map +1 -1
  86. package/dist/machine/errors.d.ts +4 -1
  87. package/dist/machine/errors.d.ts.map +1 -1
  88. package/dist/machine/errors.js +9 -1
  89. package/dist/machine/errors.js.map +1 -1
  90. package/dist/machine/index.d.ts.map +1 -1
  91. package/dist/machine/index.js +350 -16
  92. package/dist/machine/index.js.map +1 -1
  93. package/dist/mcp/index.d.ts.map +1 -1
  94. package/dist/mcp/index.js +202 -1
  95. package/dist/mcp/index.js.map +1 -1
  96. package/dist/types/index.d.ts +14 -0
  97. package/dist/types/index.d.ts.map +1 -1
  98. package/package.json +1 -1
  99. package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +17 -17
  100. package/src/__tests__/__snapshots__/nextjs-generator.test.ts.snap +6 -6
  101. package/src/__tests__/__snapshots__/ui-generator.test.ts.snap +11 -11
  102. package/src/__tests__/agent-loop-phase34.test.ts +449 -0
  103. package/src/__tests__/file-session.test.ts +98 -0
  104. package/src/__tests__/init.test.ts +50 -0
  105. package/src/__tests__/mcp.test.ts +113 -5
  106. package/src/__tests__/plan-apply.test.ts +294 -0
  107. package/src/cli/commands/add-auth.ts +164 -164
  108. package/src/cli/commands/init.ts +186 -24
  109. package/src/cli/commands/scaffold.ts +128 -129
  110. package/src/cli/index.ts +148 -2
  111. package/src/core/config.ts +5 -4
  112. package/src/core/operations/auth-operations.ts +353 -0
  113. package/src/core/operations/create-model.ts +25 -13
  114. package/src/core/operations/file-session.ts +210 -0
  115. package/src/core/operations/provision-operations.ts +297 -0
  116. package/src/core/operations/runtime.ts +23 -0
  117. package/src/core/operations/scaffold-apply.ts +106 -0
  118. package/src/core/operations/scaffold-machine.ts +2 -16
  119. package/src/core/operations/scaffold-plan.ts +224 -0
  120. package/src/core/project/artifacts.ts +205 -0
  121. package/src/core/project/boundaries.ts +170 -0
  122. package/src/core/project/drift.ts +202 -0
  123. package/src/core/project/infra.ts +196 -0
  124. package/src/core/project/state.ts +77 -0
  125. package/src/core/project/workflows.ts +249 -0
  126. package/src/core/scaffold/auth-generator.ts +24 -27
  127. package/src/core/scaffold/functions-generator.ts +9 -9
  128. package/src/core/scaffold/nextjs-generator.ts +6 -6
  129. package/src/core/scaffold/ui-generator.ts +19 -19
  130. package/src/core/verify/index.ts +392 -0
  131. package/src/machine/contracts.ts +16 -0
  132. package/src/machine/errors.ts +12 -1
  133. package/src/machine/index.ts +444 -15
  134. package/src/mcp/index.ts +198 -2
  135. 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
- const timer = setTimeout(() => {
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,294 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { runMachineCli } from "../machine";
5
+
6
+ jest.mock("child_process", () => {
7
+ const actual = jest.requireActual<typeof import("child_process")>("child_process");
8
+ return {
9
+ ...actual,
10
+ spawn: jest.fn(),
11
+ spawnSync: jest.fn(),
12
+ };
13
+ });
14
+
15
+ const repoRoot = process.cwd();
16
+
17
+ function writeFile(filePath: string, content: string): void {
18
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
19
+ fs.writeFileSync(filePath, content, "utf-8");
20
+ }
21
+
22
+ function createModelSource(name: string, extraField = ""): string {
23
+ return `import { z } from 'zod/v4';
24
+
25
+ export const ${name} = z.object({
26
+ id: z.string(),
27
+ name: z.string().min(1),${extraField}
28
+ createdAt: z.string().optional(),
29
+ updatedAt: z.string().optional(),
30
+ });
31
+
32
+ export type ${name} = z.infer<typeof ${name}>;
33
+
34
+ export const displayName = '${name}';
35
+ `;
36
+ }
37
+
38
+ function createProjectFixture(rootDir: string): void {
39
+ writeFile(path.join(rootDir, "package.json"), JSON.stringify({ name: "sample-app" }, null, 2));
40
+ writeFile(
41
+ path.join(rootDir, "swallowkit.config.js"),
42
+ `module.exports = {
43
+ database: {
44
+ connectionString: 'AccountEndpoint=https://example.local;',
45
+ },
46
+ backend: {
47
+ language: 'typescript',
48
+ },
49
+ api: {
50
+ endpoint: '/api/_swallowkit',
51
+ },
52
+ };
53
+ `
54
+ );
55
+ writeFile(path.join(rootDir, "shared", "package.json"), JSON.stringify({ name: "@sample-app/shared" }, null, 2));
56
+ writeFile(path.join(rootDir, "shared", "index.ts"), "export {};\n");
57
+ writeFile(path.join(rootDir, "shared", "models", "todo.ts"), createModelSource("Todo"));
58
+ fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true });
59
+ fs.symlinkSync(
60
+ path.join(repoRoot, "node_modules", "zod"),
61
+ path.join(rootDir, "node_modules", "zod"),
62
+ "junction"
63
+ );
64
+ }
65
+
66
+ async function runMachine(argv: string[]): Promise<{ response: any; exitCode: number }> {
67
+ const writes: string[] = [];
68
+ const originalWrite = process.stdout.write.bind(process.stdout);
69
+ const originalExitCode = process.exitCode;
70
+
71
+ (process.stdout.write as unknown as (chunk: string | Uint8Array) => boolean) = ((chunk: string | Uint8Array) => {
72
+ writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8"));
73
+ return true;
74
+ }) as typeof process.stdout.write;
75
+
76
+ process.exitCode = 0;
77
+
78
+ try {
79
+ await runMachineCli(argv);
80
+ return {
81
+ response: JSON.parse(writes.join("")),
82
+ exitCode: process.exitCode || 0,
83
+ };
84
+ } finally {
85
+ process.stdout.write = originalWrite;
86
+ process.exitCode = originalExitCode;
87
+ }
88
+ }
89
+
90
+ function machineArgs(...args: string[]): string[] {
91
+ return ["node", "swallowkit", "machine", ...args];
92
+ }
93
+
94
+ describe("plan / apply / drift / verify machine commands", () => {
95
+ const originalCwd = process.cwd();
96
+ let tempDir: string;
97
+
98
+ beforeEach(() => {
99
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-agent-"));
100
+ process.chdir(tempDir);
101
+ });
102
+
103
+ afterEach(() => {
104
+ process.chdir(originalCwd);
105
+ fs.rmSync(tempDir, { recursive: true, force: true });
106
+ });
107
+
108
+ it("plans a scaffold without writing files and reports operations", async () => {
109
+ createProjectFixture(tempDir);
110
+
111
+ const { response, exitCode } = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
112
+
113
+ expect(exitCode).toBe(0);
114
+ expect(response.ok).toBe(true);
115
+ expect(response.command).toBe("plan-scaffold");
116
+ expect(response.status).toBe("complete");
117
+ expect(response.data.planId).toMatch(/^[0-9a-f]{12}$/);
118
+ expect(response.data.requiresApproval).toBe(false);
119
+ expect(response.data.operations).toEqual(
120
+ expect.arrayContaining([
121
+ expect.objectContaining({ path: "app/api/todo/route.ts", action: "create", ownership: "managed" }),
122
+ expect.objectContaining({ path: "functions/src/todo.ts", action: "create" }),
123
+ ])
124
+ );
125
+ expect(response.nextActions[0].command).toContain(`apply scaffold --plan ${response.data.planId}`);
126
+
127
+ // No generated files were written
128
+ expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
129
+ expect(fs.existsSync(path.join(tempDir, "functions"))).toBe(false);
130
+ // But the plan state was persisted
131
+ expect(
132
+ fs.existsSync(path.join(tempDir, ".swallowkit", "state", "plans", `${response.data.planId}.json`))
133
+ ).toBe(true);
134
+ });
135
+
136
+ it("applies a previously computed plan and records the artifact ledger", async () => {
137
+ createProjectFixture(tempDir);
138
+
139
+ const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
140
+ const planId = plan.response.data.planId;
141
+
142
+ const { response, exitCode } = await runMachine(machineArgs("apply", "scaffold", "--plan", planId));
143
+
144
+ expect(exitCode).toBe(0);
145
+ expect(response.ok).toBe(true);
146
+ expect(response.command).toBe("apply-scaffold");
147
+ expect(response.status).toBe("complete");
148
+ expect(response.data.createdFiles).toEqual(
149
+ expect.arrayContaining(["app/api/todo/route.ts", "functions/src/todo.ts"])
150
+ );
151
+
152
+ // Artifact ledger created and tracks the generated files
153
+ const ledgerPath = path.join(tempDir, ".swallowkit", "artifacts.json");
154
+ expect(fs.existsSync(ledgerPath)).toBe(true);
155
+ const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf-8"));
156
+ expect(ledger.artifacts).toEqual(
157
+ expect.arrayContaining([
158
+ expect.objectContaining({ path: "app/api/todo/route.ts", ownership: "managed", sourceModel: "Todo" }),
159
+ ])
160
+ );
161
+
162
+ // Applied plan state is cleaned up
163
+ expect(fs.existsSync(path.join(tempDir, ".swallowkit", "state", "plans", `${planId}.json`))).toBe(false);
164
+ });
165
+
166
+ it("rejects a stale plan when files change after planning", async () => {
167
+ createProjectFixture(tempDir);
168
+
169
+ const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
170
+ const planId = plan.response.data.planId;
171
+
172
+ // Model schema changes after the plan was computed
173
+ writeFile(
174
+ path.join(tempDir, "shared", "models", "todo.ts"),
175
+ createModelSource("Todo", "\n priority: z.number().optional(),")
176
+ );
177
+
178
+ const { response, exitCode } = await runMachine(machineArgs("apply", "scaffold", "--plan", planId));
179
+
180
+ expect(exitCode).toBe(1);
181
+ expect(response.ok).toBe(false);
182
+ expect(response.error.code).toBe("stale-plan");
183
+ expect(response.status).toBe("blocked");
184
+ expect(response.error.details.changedFiles).toContain("shared/models/todo.ts");
185
+ // Nothing was applied
186
+ expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
187
+ });
188
+
189
+ it("requires approval to overwrite files modified after generation, then applies with --approve", async () => {
190
+ createProjectFixture(tempDir);
191
+
192
+ await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
193
+
194
+ // User modifies a managed artifact
195
+ const routePath = path.join(tempDir, "app", "api", "todo", "route.ts");
196
+ const customized = `// customized by user\n${fs.readFileSync(routePath, "utf-8")}`;
197
+ fs.writeFileSync(routePath, customized, "utf-8");
198
+
199
+ const denied = await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
200
+
201
+ expect(denied.exitCode).toBe(1);
202
+ expect(denied.response.ok).toBe(false);
203
+ expect(denied.response.error.code).toBe("approval-required");
204
+ expect(denied.response.status).toBe("requires-human");
205
+ expect(denied.response.error.details.conflicts).toEqual(
206
+ expect.arrayContaining([
207
+ expect.objectContaining({
208
+ path: "app/api/todo/route.ts",
209
+ action: "overwrite",
210
+ conflictReason: "modified-after-generation",
211
+ }),
212
+ ])
213
+ );
214
+ // The customization is untouched
215
+ expect(fs.readFileSync(routePath, "utf-8")).toBe(customized);
216
+
217
+ const approved = await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only", "--approve"));
218
+
219
+ expect(approved.exitCode).toBe(0);
220
+ expect(approved.response.ok).toBe(true);
221
+ expect(approved.response.data.approvedConflicts).toEqual(
222
+ expect.arrayContaining([expect.objectContaining({ path: "app/api/todo/route.ts" })])
223
+ );
224
+ expect(fs.readFileSync(routePath, "utf-8")).not.toContain("customized by user");
225
+ });
226
+
227
+ it("inspects artifacts and detects drift after manual edits and schema changes", async () => {
228
+ createProjectFixture(tempDir);
229
+ await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
230
+
231
+ const artifacts = await runMachine(machineArgs("inspect", "artifacts"));
232
+ expect(artifacts.response.ok).toBe(true);
233
+ expect(artifacts.response.data.ledgerFound).toBe(true);
234
+ expect(artifacts.response.data.artifacts).toEqual(
235
+ expect.arrayContaining([
236
+ expect.objectContaining({ path: "functions/src/todo.ts", exists: true, modified: false }),
237
+ ])
238
+ );
239
+
240
+ // Clean project → no error/warning drift about artifacts
241
+ const cleanDrift = await runMachine(machineArgs("inspect", "drift"));
242
+ expect(cleanDrift.response.ok).toBe(true);
243
+ expect(
244
+ cleanDrift.response.data.findings.filter((f: any) => f.kind === "artifact-modified" && f.severity !== "info")
245
+ ).toEqual([]);
246
+
247
+ // Manual edit to a managed artifact + schema change
248
+ fs.appendFileSync(path.join(tempDir, "functions", "src", "todo.ts"), "\n// manual edit\n");
249
+ writeFile(
250
+ path.join(tempDir, "shared", "models", "todo.ts"),
251
+ createModelSource("Todo", "\n priority: z.number().optional(),")
252
+ );
253
+
254
+ const drift = await runMachine(machineArgs("inspect", "drift"));
255
+ expect(drift.response.ok).toBe(true);
256
+ expect(drift.response.data.findings).toEqual(
257
+ expect.arrayContaining([
258
+ expect.objectContaining({ kind: "artifact-modified", path: "functions/src/todo.ts", severity: "warning" }),
259
+ expect.objectContaining({ kind: "schema-drift", entity: "Todo" }),
260
+ ])
261
+ );
262
+ expect(drift.response.data.findings.every((f: any) => typeof f.repairAction === "string")).toBe(true);
263
+ });
264
+
265
+ it("verifies the project and explains failures", async () => {
266
+ createProjectFixture(tempDir);
267
+ await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
268
+
269
+ const verify = await runMachine(machineArgs("verify", "project", "--checks", "structure,drift"));
270
+
271
+ expect(verify.exitCode).toBe(0);
272
+ expect(verify.response.ok).toBe(true);
273
+ expect(verify.response.command).toBe("verify-project");
274
+ expect(verify.response.status).toBe("complete");
275
+ expect(verify.response.data.summary.done).toBe(true);
276
+ expect(verify.response.data.checks.map((check: any) => check.id)).toEqual(["structure", "drift"]);
277
+
278
+ // explain failure with no failing checks returns an empty list
279
+ const explain = await runMachine(machineArgs("explain", "failure"));
280
+ expect(explain.response.ok).toBe(true);
281
+ expect(explain.response.data.failures).toEqual([]);
282
+ });
283
+
284
+ it("returns no-verification-result when explain failure runs before verify", async () => {
285
+ createProjectFixture(tempDir);
286
+
287
+ const { response, exitCode } = await runMachine(machineArgs("explain", "failure"));
288
+
289
+ expect(exitCode).toBe(1);
290
+ expect(response.ok).toBe(false);
291
+ expect(response.error.code).toBe("no-verification-result");
292
+ expect(response.status).toBe("blocked");
293
+ });
294
+ });