jui-tools-mcp-server 2.16.0 → 2.17.0

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 (44) hide show
  1. package/.github/workflows/test.yml +2 -1
  2. package/README.md +16 -1
  3. package/dist/index.js +16 -1
  4. package/dist/tools/infra/infra_check.d.ts +10 -0
  5. package/dist/tools/infra/infra_check.js +43 -0
  6. package/dist/tools/infra/infra_common.d.ts +40 -0
  7. package/dist/tools/infra/infra_common.js +44 -0
  8. package/dist/tools/infra/infra_cost.d.ts +3 -0
  9. package/dist/tools/infra/infra_cost.js +118 -0
  10. package/dist/tools/infra/infra_load_validate.d.ts +8 -0
  11. package/dist/tools/infra/infra_load_validate.js +39 -0
  12. package/dist/tools/infra/infra_prices_validate.d.ts +7 -0
  13. package/dist/tools/infra/infra_prices_validate.js +34 -0
  14. package/dist/tools/infra/infra_seed.d.ts +27 -0
  15. package/dist/tools/infra/infra_seed.js +81 -0
  16. package/dist/tools/infra/infra_validate.d.ts +8 -0
  17. package/dist/tools/infra/infra_validate.js +37 -0
  18. package/package.json +1 -1
  19. package/scripts/record-infra-fixtures.sh +78 -0
  20. package/src/index.ts +17 -1
  21. package/src/tools/infra/infra_check.ts +49 -0
  22. package/src/tools/infra/infra_common.ts +69 -0
  23. package/src/tools/infra/infra_cost.ts +122 -0
  24. package/src/tools/infra/infra_load_validate.ts +46 -0
  25. package/src/tools/infra/infra_prices_validate.ts +41 -0
  26. package/src/tools/infra/infra_seed.ts +88 -0
  27. package/src/tools/infra/infra_validate.ts +44 -0
  28. package/tests/fixtures/infra/RECORDED_FROM +1 -0
  29. package/tests/fixtures/infra/check-infra.json +101 -0
  30. package/tests/fixtures/infra/cost-infra.day.json +10080 -0
  31. package/tests/fixtures/infra/cost-infra.month-by-node.json +484 -0
  32. package/tests/fixtures/infra/cost-infra.month.json +381 -0
  33. package/tests/fixtures/infra/cost-infra.refused.json +5 -0
  34. package/tests/fixtures/infra/seed-infra.draft.json +233 -0
  35. package/tests/fixtures/infra/seed-infra.report.json +46 -0
  36. package/tests/fixtures/infra/validate-infra-load.errors.json +39 -0
  37. package/tests/fixtures/infra/validate-infra-load.ok.json +29 -0
  38. package/tests/fixtures/infra/validate-infra-load.unreadable.json +6 -0
  39. package/tests/fixtures/infra/validate-infra-prices.ok.json +9 -0
  40. package/tests/fixtures/infra/validate-infra.errors.json +39 -0
  41. package/tests/fixtures/infra/validate-infra.ok.json +30 -0
  42. package/tests/fixtures/infra/validate-infra.unreadable.json +6 -0
  43. package/tests/tool_count.test.ts +1 -1
  44. package/tests/tools_infra.test.ts +332 -0
@@ -0,0 +1,29 @@
1
+ {
2
+ "ok": true,
3
+ "dir": "docs/infra/load",
4
+ "files": [
5
+ "ordinary.json",
6
+ "sale.json"
7
+ ],
8
+ "problems": [],
9
+ "scenarios": [
10
+ {
11
+ "id": "ordinary",
12
+ "file": "ordinary.json",
13
+ "environment": "production",
14
+ "month": "2026-11",
15
+ "hours": 720,
16
+ "loads": 6,
17
+ "edges": 1
18
+ },
19
+ {
20
+ "id": "sale",
21
+ "file": "sale.json",
22
+ "environment": "production",
23
+ "month": "2026-11",
24
+ "hours": 720,
25
+ "loads": 3,
26
+ "edges": 0
27
+ }
28
+ ]
29
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "ok": false,
3
+ "reason": "no_infra",
4
+ "message": "the infra model cannot be read: no model files in docs/infra (*.json)",
5
+ "problems": []
6
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "ok": true,
3
+ "dir": "docs/infra/prices",
4
+ "files": [
5
+ "example.json"
6
+ ],
7
+ "problems": [],
8
+ "count": 5
9
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "ok": true,
3
+ "dir": "docs/infra",
4
+ "version": 0,
5
+ "files": [
6
+ "external.json",
7
+ "main.json"
8
+ ],
9
+ "environments": [
10
+ {
11
+ "id": "production",
12
+ "label": "本番",
13
+ "kind": "production"
14
+ },
15
+ {
16
+ "id": "staging",
17
+ "label": "ステージング",
18
+ "kind": "staging"
19
+ }
20
+ ],
21
+ "flows": [],
22
+ "problems": [
23
+ {
24
+ "file": "bad.json",
25
+ "path": "/nodes/0/category",
26
+ "code": "schema",
27
+ "message": "must be one of client, compute.vm, compute.container, compute.function, compute.k8s, compute.paas, edge.cdn, edge.dns, edge.waf, load_balancer, gateway.api, gateway.nat, network.ip, database.relational, database.document, database.kv, database.search, cache, storage.object, storage.file, storage.block, queue, stream, scheduler, mail, auth, secrets, monitoring, ci, external.saas, security.iam, security.policy, monitoring.alarm",
28
+ "severity": "error",
29
+ "short": "docs/infra/bad.json: /nodes/0/category がスキーマに合いません"
30
+ }
31
+ ],
32
+ "icons": {},
33
+ "readMs": 2,
34
+ "counts": {
35
+ "groups": 8,
36
+ "nodes": 12,
37
+ "edges": 10
38
+ }
39
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "ok": true,
3
+ "dir": "docs/infra",
4
+ "version": 0,
5
+ "files": [
6
+ "external.json",
7
+ "main.json"
8
+ ],
9
+ "environments": [
10
+ {
11
+ "id": "production",
12
+ "label": "本番",
13
+ "kind": "production"
14
+ },
15
+ {
16
+ "id": "staging",
17
+ "label": "ステージング",
18
+ "kind": "staging"
19
+ }
20
+ ],
21
+ "flows": [],
22
+ "problems": [],
23
+ "icons": {},
24
+ "readMs": 2,
25
+ "counts": {
26
+ "groups": 8,
27
+ "nodes": 12,
28
+ "edges": 10
29
+ }
30
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "ok": false,
3
+ "reason": "no_infra",
4
+ "message": "no model files in docs/infra (*.json)",
5
+ "short": "docs/infra に infra のファイル(*.json)がありません"
6
+ }
@@ -11,7 +11,7 @@ import { join } from "path";
11
11
  import { describe, expect, it } from "vitest";
12
12
 
13
13
  const ROOT = join(__dirname, "..");
14
- const GROUPS = ["spec", "context", "jui", "doc", "api", "test", "viz"]; // README Groups A..G, in order
14
+ const GROUPS = ["spec", "context", "jui", "doc", "api", "test", "viz", "infra"]; // README Groups A..H, in order
15
15
 
16
16
  function registeredIn(group: string): string[] {
17
17
  const dir = join(ROOT, "src", "tools", group);
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Group H (the server room's infra) tool wrappers. child_process is mocked and answers with what jsonui-doc itself
3
+ * printed: tests/fixtures/infra/*.json, recorded by scripts/record-infra-fixtures.sh from jsonui-cli (RECORDED_FROM
4
+ * names the commit). The summaries read named keys of those outputs, so they are the CLI's contract: record again
5
+ * after the CLI's --json changes, and what no longer holds is red here. A key the tool needs and the output lacks is
6
+ * named in the tool's answer, never summarised as an empty room.
7
+ */
8
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
+ import { join } from "path";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ const execFileMock = vi.hoisted(() => vi.fn());
13
+ vi.mock("child_process", () => ({ execFile: execFileMock }));
14
+ vi.mock("node:child_process", () => ({ execFile: execFileMock }));
15
+
16
+ import { ServerConfig } from "../src/config.js";
17
+ import { register as registerInfraValidate } from "../src/tools/infra/infra_validate.js";
18
+ import { register as registerInfraLoadValidate } from "../src/tools/infra/infra_load_validate.js";
19
+ import { register as registerInfraPricesValidate } from "../src/tools/infra/infra_prices_validate.js";
20
+ import { register as registerInfraSeed } from "../src/tools/infra/infra_seed.js";
21
+ import { register as registerInfraCheck } from "../src/tools/infra/infra_check.js";
22
+ import { register as registerInfraCost } from "../src/tools/infra/infra_cost.js";
23
+ import { cleanupTempDirs, createToolHarness, makeFixtureProject, makeTempDir, type ToolHarness } from "./helpers.js";
24
+
25
+ const FIX = join(__dirname, "fixtures", "infra");
26
+ const raw = (name: string) => readFileSync(join(FIX, name), "utf-8");
27
+ const fixture = (name: string) => JSON.parse(raw(name));
28
+
29
+ type ExecCallback = (error: any, stdout: string, stderr: string) => void;
30
+ interface Answer { code?: number; stdout?: string; stderr?: string; seed?: boolean }
31
+
32
+ let calls: { command: string; args: string[]; options: any }[];
33
+ let answer: Answer;
34
+ let harness: ToolHarness;
35
+ let projectDir: string;
36
+
37
+ beforeEach(() => {
38
+ delete process.env.JUI_PROJECT_DIR;
39
+ calls = [];
40
+ answer = {};
41
+ execFileMock.mockReset();
42
+ execFileMock.mockImplementation((command: string, args: string[], options: any, callback: ExecCallback) => {
43
+ calls.push({ command, args, options });
44
+ if (answer.seed) { // seed writes its two files where -o says, as the CLI does
45
+ const out = args[args.indexOf("-o") + 1];
46
+ mkdirSync(out, { recursive: true });
47
+ copyFileSync(join(FIX, "seed-infra.draft.json"), join(out, "draft.json"));
48
+ copyFileSync(join(FIX, "seed-infra.report.json"), join(out, "seed-report.json"));
49
+ }
50
+ const error = answer.code ? Object.assign(new Error(`exit ${answer.code}`), { code: answer.code }) : null;
51
+ callback(error, answer.stdout ?? "", answer.stderr ?? "");
52
+ return { stdin: { end() {} } };
53
+ });
54
+ projectDir = makeTempDir("infra");
55
+ makeFixtureProject(projectDir);
56
+ harness = createToolHarness();
57
+ const config = new ServerConfig();
58
+ for (const register of [registerInfraValidate, registerInfraLoadValidate, registerInfraPricesValidate,
59
+ registerInfraSeed, registerInfraCheck, registerInfraCost]) register(harness.server, config);
60
+ });
61
+
62
+ afterEach(() => {
63
+ delete process.env.JUI_PROJECT_DIR;
64
+ cleanupTempDirs();
65
+ });
66
+
67
+ const call = async (name: string, params: Record<string, unknown> = {}) =>
68
+ JSON.parse(await harness.call(name, { project_dir: projectDir, ...params }));
69
+
70
+ /** The recorded output with one key taken out (at a dotted path). */
71
+ function without(name: string, path: string): string {
72
+ const data = fixture(name);
73
+ const keys = path.split(".");
74
+ let at = data;
75
+ for (const k of keys.slice(0, -1)) at = at[k];
76
+ delete at[keys[keys.length - 1]];
77
+ return JSON.stringify(data);
78
+ }
79
+
80
+ describe("the fixtures", () => {
81
+ it("say which jsonui-cli printed them", () => {
82
+ expect(raw("RECORDED_FROM")).toMatch(/^[0-9a-f]{7,} /);
83
+ });
84
+ });
85
+
86
+ describe("infra_validate", () => {
87
+ it("runs validate infra on the project and counts what the model holds", async () => {
88
+ answer = { stdout: raw("validate-infra.ok.json") };
89
+ const out = await call("infra_validate");
90
+ expect(calls[0].command).toBe("jsonui-doc");
91
+ expect(calls[0].args).toEqual(["validate", "infra", projectDir, "--json"]);
92
+ expect(calls[0].options.cwd).toBe(projectDir);
93
+ const rec = fixture("validate-infra.ok.json");
94
+ expect(out).toMatchObject({ ok: true, exit: 0, readable: true, dir: rec.dir, files: rec.files, counts: rec.counts,
95
+ environments: rec.environments.map((e: any) => e.id), flows: rec.flows.length });
96
+ expect(out.problems).toEqual({ count: 0, errors: 0, warnings: 0, byCode: {}, first: [] });
97
+ });
98
+
99
+ it("an error is exit 1, counted by severity and code, its place and message kept", async () => {
100
+ answer = { code: 1, stdout: raw("validate-infra.errors.json") };
101
+ const out = await call("infra_validate");
102
+ const [p] = fixture("validate-infra.errors.json").problems;
103
+ expect(out.ok).toBe(false);
104
+ expect(out.exit).toBe(1);
105
+ expect(out.problems.errors).toBe(1);
106
+ expect(out.problems.byCode).toEqual({ [p.code]: 1 });
107
+ expect(out.problems.first).toEqual([{ severity: p.severity, code: p.code, file: p.file, path: p.path, message: p.message }]);
108
+ });
109
+
110
+ it("a room that cannot be read says why", async () => {
111
+ answer = { code: 2, stdout: raw("validate-infra.unreadable.json") };
112
+ const rec = fixture("validate-infra.unreadable.json");
113
+ expect(await call("infra_validate")).toEqual({ ok: false, exit: 2, readable: false, reason: rec.reason, message: rec.message });
114
+ });
115
+
116
+ it("names a key the CLI's JSON lacks", async () => {
117
+ answer = { stdout: without("validate-infra.ok.json", "problems") };
118
+ expect(await harness.call("infra_validate", { project_dir: projectDir })).toMatch(/^Error: validate infra: the JSON has no "problems"/);
119
+ });
120
+
121
+ it("says so when the CLI prints no JSON (a jsonui-doc before --json)", async () => {
122
+ answer = { code: 2, stdout: "", stderr: "jsonui-doc: error: unrecognized arguments: --json" };
123
+ expect(await harness.call("infra_validate", { project_dir: projectDir }))
124
+ .toMatch(/printed no JSON \(exit 2\): jsonui-doc: error: unrecognized arguments: --json — it needs jsonui-cli 2\.3\.15/);
125
+ });
126
+ });
127
+
128
+ describe("infra_load_validate", () => {
129
+ it("lists the scenarios as the CLI counts them", async () => {
130
+ answer = { stdout: raw("validate-infra-load.ok.json") };
131
+ const out = await call("infra_load_validate");
132
+ expect(calls[0].args).toEqual(["validate", "infra-load", projectDir, "--json"]);
133
+ const rec = fixture("validate-infra-load.ok.json");
134
+ expect(out.scenarios).toEqual(rec.scenarios);
135
+ expect(out).toMatchObject({ ok: true, exit: 0, dir: rec.dir, files: rec.files });
136
+ });
137
+
138
+ it("a file that is not JSON is an error with its line's place", async () => {
139
+ answer = { code: 1, stdout: raw("validate-infra-load.errors.json") };
140
+ const out = await call("infra_load_validate");
141
+ expect(out.exit).toBe(1);
142
+ expect(out.problems.first[0]).toMatchObject({ code: "json", file: "broken.json", severity: "error" });
143
+ expect(out.scenarios.map((s: any) => s.id)).toEqual(fixture("validate-infra-load.errors.json").scenarios.map((s: any) => s.id));
144
+ });
145
+
146
+ it("no model is not readable, with the reason", async () => {
147
+ answer = { code: 2, stdout: raw("validate-infra-load.unreadable.json") };
148
+ expect(await call("infra_load_validate")).toMatchObject({ ok: false, readable: false, reason: "no_infra" });
149
+ });
150
+
151
+ it("names a scenario's key the CLI's JSON lacks", async () => {
152
+ answer = { stdout: without("validate-infra-load.ok.json", "scenarios.0.hours") };
153
+ expect(await harness.call("infra_load_validate", { project_dir: projectDir })).toMatch(/scenario: the JSON has no "hours"/);
154
+ });
155
+ });
156
+
157
+ describe("infra_prices_validate", () => {
158
+ it("counts the room's prices", async () => {
159
+ answer = { stdout: raw("validate-infra-prices.ok.json") };
160
+ const out = await call("infra_prices_validate");
161
+ expect(calls[0].args).toEqual(["validate", "infra-prices", projectDir, "--json"]);
162
+ expect(out).toMatchObject({ ok: true, count: fixture("validate-infra-prices.ok.json").count });
163
+ });
164
+ });
165
+
166
+ describe("infra_seed", () => {
167
+ it("drafts into a temp folder, reads it back and leaves the project as it was", async () => {
168
+ answer = { seed: true };
169
+ const out = await call("infra_seed");
170
+ const args = calls[0].args;
171
+ expect(args.slice(0, 4)).toEqual(["seed", "infra", projectDir, "-o"]);
172
+ expect(args[4].startsWith(projectDir)).toBe(false);
173
+ expect(existsSync(args[4])).toBe(false); // the temp folder is gone
174
+ expect(existsSync(join(projectDir, "docs/infra/seed"))).toBe(false);
175
+ const draft = fixture("seed-infra.draft.json");
176
+ const report = fixture("seed-infra.report.json");
177
+ expect(out.written).toBeNull();
178
+ expect(out.counts).toEqual({ nodes: draft.nodes.length, groups: draft.groups.length, edges: draft.edges.length, flows: 0 });
179
+ expect(out.nodes.map((n: any) => n.id)).toEqual(draft.nodes.map((n: any) => n.id));
180
+ expect(out.unmatched).toEqual(report.unmatched);
181
+ expect(out.inputs).toEqual(report.inputs);
182
+ });
183
+
184
+ it("write: true writes <infra.dir>/seed, and force replaces", async () => {
185
+ answer = { seed: true };
186
+ const out = await call("infra_seed", { write: true, force: true });
187
+ expect(calls[0].args).toEqual(["seed", "infra", projectDir, "-o", join(projectDir, "docs/infra", "seed"), "--force"]);
188
+ expect(out.written).toBe(join(projectDir, "docs/infra", "seed"));
189
+ });
190
+
191
+ it("a refusal is its message", async () => {
192
+ answer = { code: 2, stderr: "seed infra: docs/infra/seed already has a draft (--force to replace it)" };
193
+ expect(await call("infra_seed", { write: true })).toEqual({ ok: false, exit: 2,
194
+ message: "seed infra: docs/infra/seed already has a draft (--force to replace it)" });
195
+ });
196
+ });
197
+
198
+ describe("infra_check", () => {
199
+ it("runs check infra on the project and keeps each check's status and the results not ok", async () => {
200
+ answer = { stdout: raw("check-infra.json") };
201
+ const out = await call("infra_check");
202
+ expect(calls[0].args).toEqual(["check", "infra", "-p", projectDir, "--json"]);
203
+ const rec = fixture("check-infra.json");
204
+ expect(out.ok).toBe(rec.ok);
205
+ expect(out.checks.map((c: any) => [c.name, c.type, c.status])).toEqual(rec.checks.map((c: any) => [c.name, c.type, c.status]));
206
+ expect(out.checks[0].results).toEqual(rec.checks[0].results.slice(0, 20));
207
+ expect(out.checks[0].summary).toEqual(rec.checks[0].summary);
208
+ expect(out.checks[0].report).toBe(rec.checks[0].report);
209
+ });
210
+
211
+ it("no infra check declared says how to declare one", async () => {
212
+ answer = { code: 2, stdout: JSON.stringify({ ok: false, exit: 2, checks: [], declared: [] }),
213
+ stderr: "No declared check matches 'infra'. Declared:" };
214
+ expect(await call("infra_check")).toMatchObject({ ok: false, exit: 2, checks: [], message: "No declared check matches 'infra'. Declared:" });
215
+ });
216
+
217
+ it("names a check's key the CLI's JSON lacks", async () => {
218
+ answer = { stdout: without("check-infra.json", "checks.0.summary") };
219
+ expect(await harness.call("infra_check", { project_dir: projectDir })).toMatch(/check infra check: the JSON has no "summary"/);
220
+ });
221
+ });
222
+
223
+ describe("infra_cost", () => {
224
+ it("the month: the total, the totals by key adding up to it, the top nodes and what has no price", async () => {
225
+ answer = { stdout: raw("cost-infra.month.json") };
226
+ const out = await call("infra_cost");
227
+ expect(calls[0].args).toEqual(["cost", "infra", projectDir, "--json"]);
228
+ const rec = fixture("cost-infra.month.json");
229
+ expect(out.month.usd).toBe(rec.month.usd);
230
+ expect(out.month.totals.map((t: any) => t.key)).toEqual(["aws"]);
231
+ expect(out.month.totals.reduce((a: number, t: any) => a + t.usd, 0)).toBeCloseTo(rec.month.usd, 6);
232
+ expect(out.month.days).toBe(rec.month.days.length);
233
+ expect(out.month.noPrice).toEqual(rec.month.noPrice);
234
+ expect(out.month.noRule).toEqual(rec.month.noRule);
235
+ expect(out.month.notes).toEqual(rec.month.warnings.map((w: any) => w.message));
236
+ expect(out.topNodes).toEqual(rec.topNodes);
237
+ expect(out.scenarios.map((s: any) => s.id)).toEqual(["ordinary", "sale"]);
238
+ expect(out.day).toBeUndefined();
239
+ });
240
+
241
+ it("by node and a scenario go to the CLI; the rest is one key", async () => {
242
+ answer = { stdout: raw("cost-infra.month-by-node.json") };
243
+ const out = await call("infra_cost", { scenario: "sale", by: "node" });
244
+ expect(calls[0].args).toEqual(["cost", "infra", projectDir, "--json", "--scenario", "sale", "--by", "node"]);
245
+ const rec = fixture("cost-infra.month-by-node.json");
246
+ expect(out.scenario).toBe("sale");
247
+ expect(new Set(out.month.totals.map((t: any) => t.key))).toEqual(
248
+ new Set(rec.month.keys.map((k: any) => (k.key === "__others__" ? "others" : k.key))
249
+ .filter((k: string) => rec.month.days.some((d: any) => (k === "others" ? "__others__" : k) in d.stack))));
250
+ });
251
+
252
+ it("a day: each top node's charge by meter, the price layer, the peak hour — not its 24 hours", async () => {
253
+ answer = { stdout: raw("cost-infra.day.json") };
254
+ const text = await harness.call("infra_cost", { project_dir: projectDir, date: "2026-11-03" });
255
+ expect(calls[0].args).toEqual(["cost", "infra", projectDir, "--json", "--date", "2026-11-03"]);
256
+ expect(text.length).toBeLessThan(raw("cost-infra.day.json").length / 10);
257
+ const out = JSON.parse(text);
258
+ const rec = fixture("cost-infra.day.json").day;
259
+ expect(out.day.usd).toBe(rec.usd);
260
+ expect(out.day.nodes.map((n: any) => n.node)).toEqual(rec.nodes.map((n: any) => n.node));
261
+ const db = out.day.nodes.find((n: any) => n.node === "db-prod");
262
+ const recDb = rec.nodes.find((n: any) => n.node === "db-prod");
263
+ const meterSum = Object.values(db.meters).reduce((a: number, m: any) => a + m.usd, 0);
264
+ expect(meterSum).toBeCloseTo(recDb.usd, 6);
265
+ expect(db.meters.instance).toMatchObject({ sku: "db.r6g.large:multi-az", layer: "bundled" });
266
+ expect(out.month).toBeUndefined();
267
+ });
268
+
269
+ it("a scenario that is not there is refused with the reason", async () => {
270
+ answer = { code: 2, stdout: raw("cost-infra.refused.json") };
271
+ const rec = fixture("cost-infra.refused.json");
272
+ expect(await call("infra_cost", { scenario: "nope" })).toEqual({ ok: false, exit: 2, reason: rec.reason, message: rec.message });
273
+ });
274
+
275
+ it("a date that is not YYYY-MM-DD never reaches the CLI", async () => {
276
+ await expect(harness.call("infra_cost", { project_dir: projectDir, date: "11/03" })).rejects.toThrow();
277
+ expect(calls).toEqual([]);
278
+ });
279
+
280
+ it("names a key the month lacks", async () => {
281
+ answer = { stdout: without("cost-infra.month.json", "month.noPrice") };
282
+ expect(await harness.call("infra_cost", { project_dir: projectDir })).toMatch(/cost infra month: the JSON has no "noPrice"/);
283
+ });
284
+ });
285
+
286
+ describe("each tool names a key its summary reads and the CLI's JSON lacks", () => {
287
+ const cases: [string, Record<string, unknown>, string, string, RegExp][] = [
288
+ ["infra_validate", {}, "validate-infra.ok.json", "dir", /validate infra: the JSON has no "dir"/],
289
+ ["infra_load_validate", {}, "validate-infra-load.ok.json", "scenarios", /validate infra-load: the JSON has no "scenarios"/],
290
+ ["infra_prices_validate", {}, "validate-infra-prices.ok.json", "count", /validate infra-prices: the JSON has no "count"/],
291
+ ["infra_check", {}, "check-infra.json", "checks.0.status", /check infra check: the JSON has no "status"/],
292
+ ["infra_check", {}, "check-infra.json", "checks.0.name", /check infra check: the JSON has no "name"/],
293
+ ["infra_cost", {}, "cost-infra.month.json", "topNodes", /cost infra: the JSON has no "topNodes"/],
294
+ ["infra_cost", {}, "cost-infra.month.json", "prices.warningCount", /cost infra prices: the JSON has no "warningCount"/],
295
+ ["infra_cost", { date: "2026-11-03" }, "cost-infra.day.json", "day.nodes.0.hours", /cost infra day node: the JSON has no "hours"/],
296
+ ];
297
+ for (const [tool, params, file, path, message] of cases) {
298
+ it(`${tool}: ${path}`, async () => {
299
+ answer = { stdout: without(file, path) };
300
+ expect(await harness.call(tool, { project_dir: projectDir, ...params })).toMatch(message);
301
+ });
302
+ }
303
+
304
+ it("infra_seed: the report's unmatched", async () => {
305
+ answer = { seed: true };
306
+ const report = fixture("seed-infra.report.json");
307
+ delete report.unmatched;
308
+ execFileMock.mockImplementationOnce((command: string, args: string[], options: any, callback: ExecCallback) => {
309
+ const out = args[args.indexOf("-o") + 1];
310
+ mkdirSync(out, { recursive: true });
311
+ copyFileSync(join(FIX, "seed-infra.draft.json"), join(out, "draft.json"));
312
+ writeFileSync(join(out, "seed-report.json"), JSON.stringify(report));
313
+ callback(null, "", "");
314
+ return { stdin: { end() {} } };
315
+ });
316
+ expect(await harness.call("infra_seed", { project_dir: projectDir })).toMatch(/seed infra report: the JSON has no "unmatched"/);
317
+ });
318
+ });
319
+
320
+ describe("a long problem list", () => {
321
+ it("is counted whole and its first 20 kept", async () => {
322
+ const data = fixture("validate-infra.errors.json");
323
+ const [p] = data.problems;
324
+ data.problems = Array.from({ length: 25 }, (_, i) => ({ ...p, path: `/nodes/${i}/category` }));
325
+ answer = { code: 1, stdout: JSON.stringify(data) };
326
+ const out = await call("infra_validate");
327
+ expect(out.problems.count).toBe(25);
328
+ expect(out.problems.errors).toBe(25);
329
+ expect(out.problems.first).toHaveLength(20);
330
+ expect(out.problems.first[19].path).toBe("/nodes/19/category");
331
+ });
332
+ });