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
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3/4 agent-loop tests:
|
|
3
|
+
* - inspect boundaries / inspect infra
|
|
4
|
+
* - plan/apply auth(承認フロー含む)
|
|
5
|
+
* - plan/apply provision(az 実行なしの安全パスのみ)
|
|
6
|
+
* - verify のカスタムチェック読込
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as os from "os";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import * as childProcess from "child_process";
|
|
13
|
+
import { runMachineCli } from "../machine";
|
|
14
|
+
import { inspectInfra } from "../core/project/infra";
|
|
15
|
+
import { loadCustomVerifyChecks } from "../core/verify";
|
|
16
|
+
import { buildAgentSkills, buildWorkflowDocs, writeAgentSkills, writeWorkflowDocs } from "../core/project/workflows";
|
|
17
|
+
|
|
18
|
+
jest.mock("child_process", () => {
|
|
19
|
+
const actual = jest.requireActual<typeof import("child_process")>("child_process");
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
spawn: jest.fn(),
|
|
23
|
+
spawnSync: jest.fn(),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const repoRoot = process.cwd();
|
|
28
|
+
|
|
29
|
+
function writeFile(filePath: string, content: string): void {
|
|
30
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
31
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createProjectFixture(rootDir: string): void {
|
|
35
|
+
writeFile(path.join(rootDir, "package.json"), JSON.stringify({ name: "sample-app" }, null, 2));
|
|
36
|
+
writeFile(
|
|
37
|
+
path.join(rootDir, "swallowkit.config.js"),
|
|
38
|
+
`module.exports = {
|
|
39
|
+
database: {
|
|
40
|
+
connectionString: 'AccountEndpoint=https://example.local;',
|
|
41
|
+
},
|
|
42
|
+
backend: {
|
|
43
|
+
language: 'typescript',
|
|
44
|
+
},
|
|
45
|
+
api: {
|
|
46
|
+
endpoint: '/api/_swallowkit',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
`
|
|
50
|
+
);
|
|
51
|
+
writeFile(path.join(rootDir, "shared", "package.json"), JSON.stringify({ name: "@sample-app/shared" }, null, 2));
|
|
52
|
+
writeFile(path.join(rootDir, "shared", "index.ts"), "export {};\n");
|
|
53
|
+
writeFile(path.join(rootDir, "functions", "package.json"), JSON.stringify({ name: "functions" }, null, 2));
|
|
54
|
+
fs.mkdirSync(path.join(rootDir, "node_modules"), { recursive: true });
|
|
55
|
+
fs.symlinkSync(
|
|
56
|
+
path.join(repoRoot, "node_modules", "zod"),
|
|
57
|
+
path.join(rootDir, "node_modules", "zod"),
|
|
58
|
+
"junction"
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const SAMPLE_MAIN_BICEP = `targetScope = 'resourceGroup'
|
|
63
|
+
|
|
64
|
+
@description('Project name')
|
|
65
|
+
param projectName string
|
|
66
|
+
|
|
67
|
+
@description('Cosmos DB mode')
|
|
68
|
+
@allowed(['freetier', 'serverless'])
|
|
69
|
+
param cosmosDbMode string = 'serverless'
|
|
70
|
+
|
|
71
|
+
param enableVNet bool = false
|
|
72
|
+
|
|
73
|
+
module logAnalytics 'modules/loganalytics.bicep' = {
|
|
74
|
+
name: 'logAnalytics'
|
|
75
|
+
params: {
|
|
76
|
+
name: 'log-x'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module cosmosDbServerless 'modules/cosmosdb-serverless.bicep' = if (cosmosDbMode == 'serverless') {
|
|
81
|
+
name: 'cosmosDb'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module todoContainer 'containers/todo-container.bicep' = {
|
|
85
|
+
name: 'todoContainer'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
output cosmosDbAccountName string = 'x'
|
|
89
|
+
output vnetEnabled bool = enableVNet
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
async function runMachine(argv: string[]): Promise<{ response: any; exitCode: number }> {
|
|
93
|
+
const writes: string[] = [];
|
|
94
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
95
|
+
const originalExitCode = process.exitCode;
|
|
96
|
+
|
|
97
|
+
(process.stdout.write as unknown as (chunk: string | Uint8Array) => boolean) = ((chunk: string | Uint8Array) => {
|
|
98
|
+
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8"));
|
|
99
|
+
return true;
|
|
100
|
+
}) as typeof process.stdout.write;
|
|
101
|
+
|
|
102
|
+
process.exitCode = 0;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await runMachineCli(argv);
|
|
106
|
+
return {
|
|
107
|
+
response: JSON.parse(writes.join("")),
|
|
108
|
+
exitCode: process.exitCode || 0,
|
|
109
|
+
};
|
|
110
|
+
} finally {
|
|
111
|
+
process.stdout.write = originalWrite;
|
|
112
|
+
process.exitCode = originalExitCode;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("agent loop phase 3/4", () => {
|
|
117
|
+
const originalCwd = process.cwd();
|
|
118
|
+
let tempDir: string;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-p34-"));
|
|
122
|
+
process.chdir(tempDir);
|
|
123
|
+
(childProcess.spawnSync as unknown as any).mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
process.chdir(originalCwd);
|
|
128
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
129
|
+
jest.clearAllMocks();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns the responsibility boundary contract via inspect boundaries", async () => {
|
|
133
|
+
createProjectFixture(tempDir);
|
|
134
|
+
const { response, exitCode } = await runMachine(["node", "swallowkit", "machine", "inspect", "boundaries"]);
|
|
135
|
+
|
|
136
|
+
expect(exitCode).toBe(0);
|
|
137
|
+
expect(response.ok).toBe(true);
|
|
138
|
+
expect(response.data.contractVersion).toBe(1);
|
|
139
|
+
expect(response.data.ownershipPolicies.map((p: any) => p.ownership)).toEqual(
|
|
140
|
+
expect.arrayContaining(["managed", "generated-once", "user-owned", "extension-point", "metadata"])
|
|
141
|
+
);
|
|
142
|
+
const managed = response.data.ownershipPolicies.find((p: any) => p.ownership === "managed");
|
|
143
|
+
expect(managed.zone).toBe("deterministic");
|
|
144
|
+
expect(managed.editPolicy).toBe("regenerate-via-plan-apply");
|
|
145
|
+
expect(response.data.conventionRules.some((r: any) => r.pattern === "*")).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("inspects Bicep infra assets deterministically", () => {
|
|
149
|
+
createProjectFixture(tempDir);
|
|
150
|
+
writeFile(path.join(tempDir, "infra", "main.bicep"), SAMPLE_MAIN_BICEP);
|
|
151
|
+
writeFile(path.join(tempDir, "infra", "main.parameters.json"), "{}\n");
|
|
152
|
+
writeFile(path.join(tempDir, "infra", "modules", "loganalytics.bicep"), "// module\n");
|
|
153
|
+
writeFile(path.join(tempDir, "infra", "containers", "todo-container.bicep"), "// wired\n");
|
|
154
|
+
writeFile(path.join(tempDir, "infra", "containers", "orphan-container.bicep"), "// not wired\n");
|
|
155
|
+
|
|
156
|
+
const inspection = inspectInfra(tempDir);
|
|
157
|
+
|
|
158
|
+
expect(inspection.mainBicep.exists).toBe(true);
|
|
159
|
+
expect(inspection.mainBicep.params.map((p) => p.name)).toEqual(["projectName", "cosmosDbMode", "enableVNet"]);
|
|
160
|
+
const cosmosMode = inspection.mainBicep.params.find((p) => p.name === "cosmosDbMode");
|
|
161
|
+
expect(cosmosMode?.allowed).toEqual(["freetier", "serverless"]);
|
|
162
|
+
expect(cosmosMode?.description).toBe("Cosmos DB mode");
|
|
163
|
+
|
|
164
|
+
const serverless = inspection.mainBicep.modules.find((m) => m.symbolicName === "cosmosDbServerless");
|
|
165
|
+
expect(serverless?.condition).toBe("cosmosDbMode == 'serverless'");
|
|
166
|
+
expect(serverless?.deploymentName).toBe("cosmosDb");
|
|
167
|
+
|
|
168
|
+
expect(inspection.mainBicep.outputs).toEqual([
|
|
169
|
+
{ name: "cosmosDbAccountName", type: "string" },
|
|
170
|
+
{ name: "vnetEnabled", type: "bool" },
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
expect(inspection.containers).toEqual([
|
|
174
|
+
{ file: "infra/containers/orphan-container.bicep", wired: false },
|
|
175
|
+
{ file: "infra/containers/todo-container.bicep", wired: true },
|
|
176
|
+
]);
|
|
177
|
+
expect(inspection.warnings.some((w) => w.startsWith("container-not-wired"))).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("plans auth without writing files, applies it, and enforces approval on conflicts", async () => {
|
|
181
|
+
createProjectFixture(tempDir);
|
|
182
|
+
|
|
183
|
+
// 1. Plan — ディスクへの書き込みなし
|
|
184
|
+
const plan1 = await runMachine(["node", "swallowkit", "machine", "plan", "auth", "--provider", "custom-jwt"]);
|
|
185
|
+
expect(plan1.exitCode).toBe(0);
|
|
186
|
+
expect(plan1.response.ok).toBe(true);
|
|
187
|
+
expect(plan1.response.data.planType).toBe("auth");
|
|
188
|
+
expect(plan1.response.data.requiresApproval).toBe(false);
|
|
189
|
+
expect(plan1.response.status).toBe("complete");
|
|
190
|
+
const plannedPaths = plan1.response.data.operations.map((op: any) => op.path);
|
|
191
|
+
expect(plannedPaths).toEqual(expect.arrayContaining(["shared/models/auth.ts", "proxy.ts", "app/login/page.tsx"]));
|
|
192
|
+
expect(fs.existsSync(path.join(tempDir, "proxy.ts"))).toBe(false);
|
|
193
|
+
expect(fs.existsSync(path.join(tempDir, "shared", "models", "auth.ts"))).toBe(false);
|
|
194
|
+
|
|
195
|
+
// 2. Apply — 実際に書き込み、台帳に記録される
|
|
196
|
+
const apply1 = await runMachine([
|
|
197
|
+
"node", "swallowkit", "machine", "apply", "auth", "--plan", plan1.response.data.planId,
|
|
198
|
+
]);
|
|
199
|
+
expect(apply1.exitCode).toBe(0);
|
|
200
|
+
expect(apply1.response.ok).toBe(true);
|
|
201
|
+
expect(apply1.response.status).toBe("complete");
|
|
202
|
+
expect(fs.existsSync(path.join(tempDir, "proxy.ts"))).toBe(true);
|
|
203
|
+
expect(fs.existsSync(path.join(tempDir, "shared", "models", "auth.ts"))).toBe(true);
|
|
204
|
+
const ledger = JSON.parse(fs.readFileSync(path.join(tempDir, ".swallowkit", "artifacts.json"), "utf-8"));
|
|
205
|
+
expect(ledger.artifacts.some((a: any) => a.path === "proxy.ts" && a.generator === "add-auth")).toBe(true);
|
|
206
|
+
|
|
207
|
+
// 3. managed ファイルを手編集後の plan は競合として検出される
|
|
208
|
+
fs.appendFileSync(path.join(tempDir, "lib", "auth", "auth-context.tsx"), "\n// hand edit\n");
|
|
209
|
+
const plan2 = await runMachine(["node", "swallowkit", "machine", "plan", "auth", "--provider", "custom-jwt"]);
|
|
210
|
+
expect(plan2.response.ok).toBe(true);
|
|
211
|
+
expect(plan2.response.data.requiresApproval).toBe(true);
|
|
212
|
+
expect(plan2.response.status).toBe("requires-human");
|
|
213
|
+
const conflict = plan2.response.data.conflicts.find((c: any) => c.path === "lib/auth/auth-context.tsx");
|
|
214
|
+
expect(conflict?.conflictReason).toBe("modified-after-generation");
|
|
215
|
+
|
|
216
|
+
// 4. 承認なしの apply は requires-human で停止
|
|
217
|
+
const applyNoApprove = await runMachine(["node", "swallowkit", "machine", "apply", "auth", "--provider", "custom-jwt"]);
|
|
218
|
+
expect(applyNoApprove.response.ok).toBe(false);
|
|
219
|
+
expect(applyNoApprove.response.error.code).toBe("approval-required");
|
|
220
|
+
expect(applyNoApprove.response.status).toBe("requires-human");
|
|
221
|
+
expect(fs.readFileSync(path.join(tempDir, "lib", "auth", "auth-context.tsx"), "utf-8")).toContain("// hand edit");
|
|
222
|
+
|
|
223
|
+
// 5. --approve で上書き適用できる
|
|
224
|
+
const applyApproved = await runMachine([
|
|
225
|
+
"node", "swallowkit", "machine", "apply", "auth", "--provider", "custom-jwt", "--approve",
|
|
226
|
+
]);
|
|
227
|
+
expect(applyApproved.response.ok).toBe(true);
|
|
228
|
+
expect(applyApproved.response.data.approvedConflicts.length).toBeGreaterThan(0);
|
|
229
|
+
expect(fs.readFileSync(path.join(tempDir, "lib", "auth", "auth-context.tsx"), "utf-8")).not.toContain("// hand edit");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("plans provision locally and always requires human approval to apply", async () => {
|
|
233
|
+
createProjectFixture(tempDir);
|
|
234
|
+
writeFile(path.join(tempDir, "infra", "main.bicep"), SAMPLE_MAIN_BICEP);
|
|
235
|
+
writeFile(path.join(tempDir, "infra", "main.parameters.json"), "{}\n");
|
|
236
|
+
|
|
237
|
+
const plan = await runMachine([
|
|
238
|
+
"node", "swallowkit", "machine", "plan", "provision",
|
|
239
|
+
"--resource-group", "rg-sample",
|
|
240
|
+
"--location", "japaneast",
|
|
241
|
+
"--swa-location", "eastasia",
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
expect(plan.exitCode).toBe(0);
|
|
245
|
+
expect(plan.response.ok).toBe(true);
|
|
246
|
+
expect(plan.response.status).toBe("requires-human");
|
|
247
|
+
expect(plan.response.data.requiresApproval).toBe(true);
|
|
248
|
+
// spawnSync がモックで失敗を返すため az CLI は「利用不可」として警告される
|
|
249
|
+
expect(plan.response.data.azCliAvailable).toBe(false);
|
|
250
|
+
expect(plan.response.data.warnings.some((w: string) => w.startsWith("az-cli-not-found"))).toBe(true);
|
|
251
|
+
expect(plan.response.data.commands.some((c: string) => c.includes("az deployment group create"))).toBe(true);
|
|
252
|
+
|
|
253
|
+
// 承認なしの apply は requires-human
|
|
254
|
+
const applyNoApprove = await runMachine([
|
|
255
|
+
"node", "swallowkit", "machine", "apply", "provision", "--plan", plan.response.data.planId,
|
|
256
|
+
]);
|
|
257
|
+
expect(applyNoApprove.response.ok).toBe(false);
|
|
258
|
+
expect(applyNoApprove.response.error.code).toBe("approval-required");
|
|
259
|
+
expect(applyNoApprove.response.status).toBe("requires-human");
|
|
260
|
+
|
|
261
|
+
// 存在しない plan は blocked
|
|
262
|
+
const applyMissing = await runMachine([
|
|
263
|
+
"node", "swallowkit", "machine", "apply", "provision", "--plan", "does-not-exist", "--approve",
|
|
264
|
+
]);
|
|
265
|
+
expect(applyMissing.response.ok).toBe(false);
|
|
266
|
+
expect(applyMissing.response.error.code).toBe("plan-not-found");
|
|
267
|
+
expect(applyMissing.response.status).toBe("blocked");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("rejects invalid provision inputs", async () => {
|
|
271
|
+
createProjectFixture(tempDir);
|
|
272
|
+
writeFile(path.join(tempDir, "infra", "main.bicep"), SAMPLE_MAIN_BICEP);
|
|
273
|
+
|
|
274
|
+
const badRg = await runMachine([
|
|
275
|
+
"node", "swallowkit", "machine", "plan", "provision",
|
|
276
|
+
"--resource-group", "rg; rm -rf /",
|
|
277
|
+
"--location", "japaneast",
|
|
278
|
+
"--swa-location", "eastasia",
|
|
279
|
+
]);
|
|
280
|
+
expect(badRg.response.ok).toBe(false);
|
|
281
|
+
expect(badRg.response.error.code).toBe("invalid-arguments");
|
|
282
|
+
|
|
283
|
+
const badLocation = await runMachine([
|
|
284
|
+
"node", "swallowkit", "machine", "plan", "provision",
|
|
285
|
+
"--resource-group", "rg-sample",
|
|
286
|
+
"--location", "Japan East",
|
|
287
|
+
"--swa-location", "eastasia",
|
|
288
|
+
]);
|
|
289
|
+
expect(badLocation.response.ok).toBe(false);
|
|
290
|
+
expect(badLocation.response.error.code).toBe("invalid-arguments");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("loads custom verify checks from swallowkit.config.json and rejects invalid ids", () => {
|
|
294
|
+
writeFile(
|
|
295
|
+
path.join(tempDir, "swallowkit.config.json"),
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
verify: {
|
|
298
|
+
checks: [
|
|
299
|
+
{ id: "smoke-api", title: "API smoke", command: "node scripts/smoke.js" },
|
|
300
|
+
{ id: "Bad_Id", command: "echo bad" },
|
|
301
|
+
{ id: "typecheck", command: "echo clash-with-builtin" },
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const checks = loadCustomVerifyChecks(tempDir);
|
|
308
|
+
expect(checks).toHaveLength(1);
|
|
309
|
+
expect(checks[0]).toMatchObject({ id: "smoke-api", command: "node scripts/smoke.js" });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("workflow docs & agent skills", () => {
|
|
313
|
+
it("builds workflow docs including provision and an index referencing skills", () => {
|
|
314
|
+
const docs = buildWorkflowDocs("pnpm exec");
|
|
315
|
+
const fileNames = docs.map((d) => d.fileName);
|
|
316
|
+
expect(fileNames).toEqual(
|
|
317
|
+
expect.arrayContaining(["README.md", "add-model.md", "modify-model.md", "verify-and-repair.md", "provision.md"])
|
|
318
|
+
);
|
|
319
|
+
const index = docs.find((d) => d.fileName === "README.md")!;
|
|
320
|
+
expect(index.content).toContain(".github/skills/");
|
|
321
|
+
expect(index.content).toContain("swallowkit-provision");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("builds agent skills that follow the agentskills.io spec", () => {
|
|
325
|
+
const skills = buildAgentSkills("pnpm exec");
|
|
326
|
+
expect(skills.map((s) => s.skillName)).toEqual([
|
|
327
|
+
"swallowkit-add-model",
|
|
328
|
+
"swallowkit-modify-model",
|
|
329
|
+
"swallowkit-verify-repair",
|
|
330
|
+
"swallowkit-provision",
|
|
331
|
+
]);
|
|
332
|
+
for (const skill of skills) {
|
|
333
|
+
// name: 小文字英数字とハイフンのみ・連続ハイフンなし・64文字以内
|
|
334
|
+
expect(skill.skillName).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
|
335
|
+
expect(skill.skillName.length).toBeLessThanOrEqual(64);
|
|
336
|
+
// frontmatter: name はディレクトリ名と一致、description は 1024 文字以内
|
|
337
|
+
const frontmatter = skill.content.match(/^---\n([\s\S]*?)\n---\n/);
|
|
338
|
+
expect(frontmatter).not.toBeNull();
|
|
339
|
+
expect(frontmatter![1]).toContain(`name: ${skill.skillName}`);
|
|
340
|
+
const description = frontmatter![1].match(/description: (.+)/)![1];
|
|
341
|
+
expect(description.length).toBeGreaterThan(0);
|
|
342
|
+
expect(description.length).toBeLessThanOrEqual(1024);
|
|
343
|
+
// 本文にランブックが含まれる
|
|
344
|
+
expect(skill.content).toContain("# Workflow:");
|
|
345
|
+
expect(skill.content).toContain("pnpm exec swallowkit machine");
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("writes skills to .github/skills/<name>/SKILL.md and runbooks to .swallowkit/workflows/", () => {
|
|
350
|
+
const skillPaths = writeAgentSkills(tempDir, "npx");
|
|
351
|
+
expect(skillPaths).toEqual([
|
|
352
|
+
".github/skills/swallowkit-add-model/SKILL.md",
|
|
353
|
+
".github/skills/swallowkit-modify-model/SKILL.md",
|
|
354
|
+
".github/skills/swallowkit-verify-repair/SKILL.md",
|
|
355
|
+
".github/skills/swallowkit-provision/SKILL.md",
|
|
356
|
+
]);
|
|
357
|
+
for (const rel of skillPaths) {
|
|
358
|
+
expect(fs.existsSync(path.join(tempDir, rel))).toBe(true);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const workflowPaths = writeWorkflowDocs(tempDir, "npx");
|
|
362
|
+
expect(workflowPaths).toContain(".swallowkit/workflows/provision.md");
|
|
363
|
+
for (const rel of workflowPaths) {
|
|
364
|
+
expect(fs.existsSync(path.join(tempDir, rel))).toBe(true);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// provision スキルは承認ゲートを明記する
|
|
368
|
+
const provisionSkill = fs.readFileSync(
|
|
369
|
+
path.join(tempDir, ".github", "skills", "swallowkit-provision", "SKILL.md"),
|
|
370
|
+
"utf-8"
|
|
371
|
+
);
|
|
372
|
+
expect(provisionSkill).toContain("requires-human");
|
|
373
|
+
expect(provisionSkill).toContain("--approve");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("robustness (irregular inputs)", () => {
|
|
378
|
+
it("returns not-a-swallowkit-project (blocked) for write commands outside a project", async () => {
|
|
379
|
+
// fixture なし = プロジェクトマーカーなし
|
|
380
|
+
const planResult = await runMachine(["node", "swallowkit", "machine", "plan", "scaffold", "todo"]);
|
|
381
|
+
expect(planResult.response.ok).toBe(false);
|
|
382
|
+
expect(planResult.response.status).toBe("blocked");
|
|
383
|
+
expect(planResult.response.error.code).toBe("not-a-swallowkit-project");
|
|
384
|
+
|
|
385
|
+
const generateResult = await runMachine(["node", "swallowkit", "machine", "generate", "model", "todo"]);
|
|
386
|
+
expect(generateResult.response.ok).toBe(false);
|
|
387
|
+
expect(generateResult.response.status).toBe("blocked");
|
|
388
|
+
expect(generateResult.response.error.code).toBe("not-a-swallowkit-project");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("degrades corrupted plan state to plan-not-found instead of crashing", async () => {
|
|
392
|
+
createProjectFixture(tempDir);
|
|
393
|
+
const planFile = path.join(tempDir, ".swallowkit", "state", "plans", "corrupt99.json");
|
|
394
|
+
writeFile(planFile, "{not valid json!!!");
|
|
395
|
+
|
|
396
|
+
const result = await runMachine([
|
|
397
|
+
"node", "swallowkit", "machine", "apply", "scaffold", "--plan", "corrupt99", "--approve",
|
|
398
|
+
]);
|
|
399
|
+
expect(result.response.ok).toBe(false);
|
|
400
|
+
expect(result.response.status).toBe("blocked");
|
|
401
|
+
expect(result.response.error.code).toBe("plan-not-found");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("degrades corrupted last-verify state to no-verification-result", async () => {
|
|
405
|
+
createProjectFixture(tempDir);
|
|
406
|
+
writeFile(path.join(tempDir, ".swallowkit", "state", "last-verify.json"), "not json");
|
|
407
|
+
|
|
408
|
+
const result = await runMachine(["node", "swallowkit", "machine", "explain", "failure"]);
|
|
409
|
+
expect(result.response.ok).toBe(false);
|
|
410
|
+
expect(result.response.status).toBe("blocked");
|
|
411
|
+
expect(result.response.error.code).toBe("no-verification-result");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("returns help text as a success envelope for --help", async () => {
|
|
415
|
+
const result = await runMachine(["node", "swallowkit", "machine", "--help"]);
|
|
416
|
+
expect(result.response.ok).toBe(true);
|
|
417
|
+
expect(result.response.command).toBe("machine-help");
|
|
418
|
+
expect(result.response.data.help).toContain("Usage:");
|
|
419
|
+
expect(result.exitCode).toBe(0);
|
|
420
|
+
|
|
421
|
+
const subResult = await runMachine(["node", "swallowkit", "machine", "plan", "scaffold", "--help"]);
|
|
422
|
+
expect(subResult.response.ok).toBe(true);
|
|
423
|
+
expect(subResult.response.data.help).toContain("plan scaffold");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("returns invalid-command with usage details for unknown commands and missing required options", async () => {
|
|
427
|
+
const unknown = await runMachine(["node", "swallowkit", "machine", "frobnicate"]);
|
|
428
|
+
expect(unknown.response.ok).toBe(false);
|
|
429
|
+
expect(unknown.response.error.code).toBe("invalid-command");
|
|
430
|
+
expect(unknown.exitCode).toBe(1);
|
|
431
|
+
|
|
432
|
+
createProjectFixture(tempDir);
|
|
433
|
+
const missingOption = await runMachine(["node", "swallowkit", "machine", "plan", "provision"]);
|
|
434
|
+
expect(missingOption.response.ok).toBe(false);
|
|
435
|
+
expect(missingOption.response.error.code).toBe("invalid-command");
|
|
436
|
+
expect(missingOption.response.error.message).toContain("--resource-group");
|
|
437
|
+
expect(missingOption.exitCode).toBe(1);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("preserves the full multi-line error message from legacy console.error paths", async () => {
|
|
441
|
+
createProjectFixture(tempDir);
|
|
442
|
+
// 存在しないモデル → scaffold 内部の console.error + process.exit 経路
|
|
443
|
+
const result = await runMachine(["node", "swallowkit", "machine", "plan", "scaffold", "no-such-model"]);
|
|
444
|
+
expect(result.response.ok).toBe(false);
|
|
445
|
+
// 断片ではなく "Model file not found" を含む全文が返ること
|
|
446
|
+
expect(result.response.error.message).toContain("Model file not found");
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import {
|
|
5
|
+
FileOperationSession,
|
|
6
|
+
hashContent,
|
|
7
|
+
runWithFileSession,
|
|
8
|
+
getActiveFileSession,
|
|
9
|
+
} from "../core/operations/file-session";
|
|
10
|
+
|
|
11
|
+
describe("FileOperationSession", () => {
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-session-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("writes files to disk in commit mode and records create operations", () => {
|
|
23
|
+
const session = new FileOperationSession("commit", tempDir);
|
|
24
|
+
const target = path.join(tempDir, "nested", "dir", "file.ts");
|
|
25
|
+
|
|
26
|
+
const op = session.writeFile(target, "export const a = 1;\n", {
|
|
27
|
+
ownership: "managed",
|
|
28
|
+
generator: "scaffold",
|
|
29
|
+
sourceModel: "Todo",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(fs.readFileSync(target, "utf-8")).toBe("export const a = 1;\n");
|
|
33
|
+
expect(op.action).toBe("create");
|
|
34
|
+
expect(op.path).toBe("nested/dir/file.ts");
|
|
35
|
+
expect(op.previousHash).toBeNull();
|
|
36
|
+
expect(op.newHash).toBe(hashContent("export const a = 1;\n"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not touch the disk in collect mode", () => {
|
|
40
|
+
const session = new FileOperationSession("collect", tempDir);
|
|
41
|
+
const target = path.join(tempDir, "virtual.ts");
|
|
42
|
+
|
|
43
|
+
session.writeFile(target, "content", { ownership: "managed", generator: "scaffold" });
|
|
44
|
+
|
|
45
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
46
|
+
expect(session.operations).toHaveLength(1);
|
|
47
|
+
expect(session.operations[0].action).toBe("create");
|
|
48
|
+
// overlay read-back
|
|
49
|
+
expect(session.readFile(target)).toBe("content");
|
|
50
|
+
expect(session.fileExists(target)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("records skip when content is unchanged", () => {
|
|
54
|
+
const target = path.join(tempDir, "same.ts");
|
|
55
|
+
fs.writeFileSync(target, "unchanged\n", "utf-8");
|
|
56
|
+
|
|
57
|
+
const session = new FileOperationSession("commit", tempDir);
|
|
58
|
+
const op = session.writeFile(target, "unchanged\n", { ownership: "managed", generator: "scaffold" });
|
|
59
|
+
|
|
60
|
+
expect(op.action).toBe("skip");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("records append for extension-point modifications and modify for managed", () => {
|
|
64
|
+
const extensionTarget = path.join(tempDir, "ext.ts");
|
|
65
|
+
const managedTarget = path.join(tempDir, "managed.ts");
|
|
66
|
+
fs.writeFileSync(extensionTarget, "base\n", "utf-8");
|
|
67
|
+
fs.writeFileSync(managedTarget, "base\n", "utf-8");
|
|
68
|
+
|
|
69
|
+
const session = new FileOperationSession("commit", tempDir);
|
|
70
|
+
const appendOp = session.writeFile(extensionTarget, "base\nmore\n", {
|
|
71
|
+
ownership: "extension-point",
|
|
72
|
+
generator: "scaffold",
|
|
73
|
+
});
|
|
74
|
+
const modifyOp = session.writeFile(managedTarget, "regenerated\n", {
|
|
75
|
+
ownership: "managed",
|
|
76
|
+
generator: "scaffold",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(appendOp.action).toBe("append");
|
|
80
|
+
expect(modifyOp.action).toBe("modify");
|
|
81
|
+
expect(modifyOp.previousHash).toBe(hashContent("base\n"));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("normalizes CRLF for hashing", () => {
|
|
85
|
+
expect(hashContent("a\r\nb\r\n")).toBe(hashContent("a\nb\n"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("scopes the active session with runWithFileSession", async () => {
|
|
89
|
+
expect(getActiveFileSession()).toBeNull();
|
|
90
|
+
const session = new FileOperationSession("collect", tempDir);
|
|
91
|
+
|
|
92
|
+
await runWithFileSession(session, async () => {
|
|
93
|
+
expect(getActiveFileSession()).toBe(session);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(getActiveFileSession()).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getGitHubFunctionsWorkflow,
|
|
17
17
|
injectSwallowKitNextConfig,
|
|
18
18
|
parseIgnoredBuilds,
|
|
19
|
+
approveBuildsViaWorkspaceYaml,
|
|
19
20
|
buildCosmosDbFreeTierBicepSource,
|
|
20
21
|
buildFunctionsHostKeyBicepExpression,
|
|
21
22
|
buildStaticWebAppConfigBicepSource,
|
|
@@ -236,6 +237,55 @@ describe("parseIgnoredBuilds", () => {
|
|
|
236
237
|
});
|
|
237
238
|
});
|
|
238
239
|
|
|
240
|
+
describe("approveBuildsViaWorkspaceYaml", () => {
|
|
241
|
+
let tempDir: string;
|
|
242
|
+
|
|
243
|
+
beforeEach(() => {
|
|
244
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sk-allowbuilds-"));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
afterEach(() => {
|
|
248
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const yamlPath = () => path.join(tempDir, "pnpm-workspace.yaml");
|
|
252
|
+
|
|
253
|
+
it("appends an allowBuilds map to an existing workspace file", () => {
|
|
254
|
+
fs.writeFileSync(yamlPath(), "packages:\n - shared\n - functions\n", "utf-8");
|
|
255
|
+
approveBuildsViaWorkspaceYaml(tempDir, ["esbuild", "protobufjs"]);
|
|
256
|
+
const content = fs.readFileSync(yamlPath(), "utf-8");
|
|
257
|
+
expect(content).toContain("packages:");
|
|
258
|
+
expect(content).toContain("allowBuilds:\n esbuild: true\n protobufjs: true");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("replaces pnpm's placeholder entries with true", () => {
|
|
262
|
+
fs.writeFileSync(
|
|
263
|
+
yamlPath(),
|
|
264
|
+
"packages:\n - shared\nallowBuilds:\n esbuild: set this to true or false\n",
|
|
265
|
+
"utf-8"
|
|
266
|
+
);
|
|
267
|
+
approveBuildsViaWorkspaceYaml(tempDir, ["esbuild", "protobufjs"]);
|
|
268
|
+
const content = fs.readFileSync(yamlPath(), "utf-8");
|
|
269
|
+
expect(content).toContain("esbuild: true");
|
|
270
|
+
expect(content).toContain("protobufjs: true");
|
|
271
|
+
expect(content).not.toContain("set this to true or false");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("quotes scoped package names and creates the file when missing", () => {
|
|
275
|
+
approveBuildsViaWorkspaceYaml(tempDir, ["@scope/pkg"]);
|
|
276
|
+
const content = fs.readFileSync(yamlPath(), "utf-8");
|
|
277
|
+
expect(content).toContain("allowBuilds:\n '@scope/pkg': true");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("keeps existing entries untouched", () => {
|
|
281
|
+
fs.writeFileSync(yamlPath(), "allowBuilds:\n core-js: false\n", "utf-8");
|
|
282
|
+
approveBuildsViaWorkspaceYaml(tempDir, ["esbuild"]);
|
|
283
|
+
const content = fs.readFileSync(yamlPath(), "utf-8");
|
|
284
|
+
expect(content).toContain("core-js: false");
|
|
285
|
+
expect(content).toContain("esbuild: true");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
239
289
|
describe("buildCosmosDbFreeTierBicepSource", () => {
|
|
240
290
|
it("keeps database-level shared throughput at 1000 RU/s for Free Tier", () => {
|
|
241
291
|
const source = buildCosmosDbFreeTierBicepSource();
|