jui-tools-mcp-server 2.17.0 → 2.18.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 (33) hide show
  1. package/README.md +2 -2
  2. package/dist/tools/infra/infra_cost.js +65 -2
  3. package/dist/tools/infra/infra_validate.d.ts +0 -5
  4. package/dist/tools/infra/infra_validate.js +41 -4
  5. package/package.json +1 -1
  6. package/scripts/record-infra-fixtures.sh +65 -0
  7. package/src/tools/infra/infra_cost.ts +57 -2
  8. package/src/tools/infra/infra_validate.ts +39 -4
  9. package/tests/fixtures/infra/RECORDED_FROM +1 -1
  10. package/tests/fixtures/infra/answers-without-plan.json +9 -0
  11. package/tests/fixtures/infra/check-infra.json +8 -6
  12. package/tests/fixtures/infra/cost-infra.day.json +2 -2
  13. package/tests/fixtures/infra/cost-infra.month-by-node.json +10 -2
  14. package/tests/fixtures/infra/cost-infra.month.json +21 -4
  15. package/tests/fixtures/infra/cost-infra.plan-bad.json +16 -0
  16. package/tests/fixtures/infra/cost-infra.plan-move-account.json +167 -0
  17. package/tests/fixtures/infra/cost-infra.plan-move.json +174 -0
  18. package/tests/fixtures/infra/cost-infra.plan-none.json +16 -0
  19. package/tests/fixtures/infra/cost-infra.plan-shrink.json +162 -0
  20. package/tests/fixtures/infra/validate-infra-load.errors.json +2 -1
  21. package/tests/fixtures/infra/validate-infra-load.unreadable.json +1 -0
  22. package/tests/fixtures/infra/validate-infra.errors.json +6 -2
  23. package/tests/fixtures/infra/validate-infra.ok.json +1 -1
  24. package/tests/fixtures/infra/validate-infra.plan-both.json +504 -0
  25. package/tests/fixtures/infra/validate-infra.plan-duplicate.json +539 -0
  26. package/tests/fixtures/infra/validate-infra.plan-edge-end.json +539 -0
  27. package/tests/fixtures/infra/validate-infra.plan-half.json +49 -0
  28. package/tests/fixtures/infra/validate-infra.plan-move.json +506 -0
  29. package/tests/fixtures/infra/validate-infra.plan-none.json +49 -0
  30. package/tests/fixtures/infra/validate-infra.plan-order-unknown.json +539 -0
  31. package/tests/fixtures/infra/validate-infra.plan-remove-missing.json +539 -0
  32. package/tests/fixtures/infra/validate-infra.plans.json +506 -0
  33. package/tests/tools_infra.test.ts +208 -0
package/README.md CHANGED
@@ -146,12 +146,12 @@ Claude Code のセッションを見るローカルのビューア(`jui viz`
146
146
 
147
147
  | Tool | Description | CLI |
148
148
  |------|-------------|-----|
149
- | `infra_validate` | モデルを読めるか(読めない時は理由)、使ったファイル・数・flow の数、problems を重さとコードで数え、最初の 20 件 | `jsonui-doc validate infra <project> --json` |
149
+ | `infra_validate` | モデルを読めるか(読めない時は理由)、使ったファイル・数・flow の数、problems を重さとコードで数え、最初の 20 件。移管計画(`docs/infra/migrations/<名前>`)がある部屋では、計画ごとの problems(`migration.*` と、新の構成に今の検査を当てた分)・数・ステップの順も(`plan` で 1 つだけ、2.3.20 以降) | `jsonui-doc validate infra <project> --json [--migration <名前>]` |
150
150
  | `infra_load_validate` | 負荷のシナリオ(id・環境・月・時間・行・edge)と problems(node・hour・quantity つき) | `jsonui-doc validate infra-load <project> --json` |
151
151
  | `infra_prices_validate` | 部屋の単価のファイル・単価の数・problems | `jsonui-doc validate infra-prices <project> --json` |
152
152
  | `infra_seed` | IaC・設定ファイル・コードから下書き。数・node・読んだ物・置けなかった物(unmatched)・skipped・notes。既定では一時フォルダに書いて読み戻し、プロジェクトは変えない(`write: true` で `<infra.dir>/seed`) | `jsonui-doc seed infra <project> -o <dir> [--force]` |
153
153
  | `infra_check` | 宣言した `builtin:infra-iac` / `infra-code` / `infra-load` の結果(status・数・ok でない結果の最初の 20 件・レポートの道) | `jsonui-doc check infra -p <project> --json` |
154
- | `infra_cost` | 月の合計・provider(か node)ごとの合計・上位 node・一番高い日・単価なし/規則なし/負荷なし・容量を超えた時間・注意。`date` で 1 日(上位 node の meter ごとの額と単価の層、ピークの時刻)。負荷の problems と単価の警告も | `jsonui-doc cost infra <project> --json [--scenario] [--date] [--by]` |
154
+ | `infra_cost` | 月の合計・provider(か node)ごとの合計・上位 node・一番高い日・単価なし/規則なし/負荷なし・容量を超えた時間・注意。`date` で 1 日(上位 node の meter ごとの額と単価の層、ピークの時刻)。負荷の problems と単価の警告も。`plan` で移管計画の月の旧・新・差(node ごとの増減の大きい順・provider ごと・ステップごと。ステップの差はそのステップでの月額全体の変化で、動いたノード・線は moved。旧か新で値が付かない変えたノードは unpricedChanged、`account: true` でアカウントの月(その環境をまとめて値付け)も、2.3.20 以降) | `jsonui-doc cost infra <project> --json [--scenario] [--date] [--by] [--migration <名前> [--account]]` |
155
155
 
156
156
  > 単価の取得(`jui viz cloud-prices fetch`)はネットへ出て viz のホームに書くので **MCP 非公開**(CLI で行う)。jsonui-doc が古くて `--json` の形が違うと、どのキーが無いかを名指しして失敗する。
157
157
 
@@ -8,7 +8,54 @@ import { FIRST, failure, need, problems, runDocJson, text } from "./infra_common
8
8
  * hours over capacity, the notes, the load problems and the price-book warnings — and for a day, each top node's
9
9
  * charge by meter with the price layer it came from. Exit 0: simulated; 1: simulated, a load file has an error (left
10
10
  * out); 2: nothing simulated (reason).
11
+ *
12
+ * With `plan` (jsonui-cli 2.3.20+, `--migration <name>`): the same engine run on the old model and on the new one (old
13
+ * + changes − removals), and the difference — the totals, per node (what the plan added, replaced, removed or left),
14
+ * per provider, after each step of the plan, and with `account` the account's month (its environments priced
15
+ * together). Nothing is computed here.
16
+ * A plan with an error is not priced: the CLI refuses it (exit 2, reason bad_plan, the plan's problems), as it does a
17
+ * plan name with no folder (no_plan) — both answered by the refusal path below, problems counted.
11
18
  */
19
+ function migrationSummary(data) {
20
+ need(data, ["migration", "scenario", "old", "new", "diff", "byNode", "byProvider", "steps", "account", "unpricedChanged"], "cost infra --migration");
21
+ for (const k of ["old", "new", "diff"])
22
+ need(data[k], ["usd"], `cost infra --migration ${k}`);
23
+ for (const k of ["old", "new"])
24
+ need(data[k], ["noPrice", "noRule", "loadUndefined"], `cost infra --migration ${k}`);
25
+ for (const n of data.byNode)
26
+ need(n, ["node", "change", "unpriced", "old", "new", "diff"], "cost infra --migration node");
27
+ for (const s of data.steps) {
28
+ need(s, ["index", "node", "usd", "diff", "moved"], "cost infra --migration step");
29
+ for (const m of s.moved)
30
+ need(m, ["node", "diff"], "cost infra --migration step moved");
31
+ }
32
+ if (data.account !== null)
33
+ need(data.account, ["on", "old", "new", "diff"], "cost infra --migration account");
34
+ const changed = data.byNode.filter((n) => n.change !== "same" || n.diff !== 0);
35
+ const byChange = {};
36
+ for (const n of data.byNode)
37
+ byChange[n.change] = (byChange[n.change] ?? 0) + 1;
38
+ return {
39
+ migration: data.migration,
40
+ scenario: data.scenario,
41
+ usd: { old: data.old.usd, new: data.new.usd, diff: data.diff.usd },
42
+ // what either model cannot price (no price, no rule, no load): its part is 0 there, so not in the difference
43
+ oldUnpriced: { noPrice: data.old.noPrice, noRule: data.old.noRule, loadUndefined: data.old.loadUndefined },
44
+ newUnpriced: { noPrice: data.new.noPrice, noRule: data.new.noRule, loadUndefined: data.new.loadUndefined },
45
+ // the nodes the plan changes whose month is not all priced: the difference leaves their part out — not a saving
46
+ unpricedChanged: data.unpricedChanged,
47
+ nodes: byChange,
48
+ // the biggest movements first, whichever way
49
+ topChanges: [...changed].sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff)).slice(0, FIRST),
50
+ changedCount: changed.length,
51
+ byProvider: data.byProvider,
52
+ // a step's diff is the whole month's change at that step, not its node's own: a neighbour can move with it (a
53
+ // shared free amount); moved names what moved, biggest first (a node, or "a>b" for a line's transfer)
54
+ steps: data.steps.map((s) => ({ index: s.index, node: s.node, usd: s.usd, diff: s.diff,
55
+ moved: s.moved.slice(0, FIRST), movedCount: s.moved.length })),
56
+ account: data.account,
57
+ };
58
+ }
12
59
  function monthSummary(m) {
13
60
  need(m, ["scenario", "month", "usd", "by", "keys", "days", "noPrice", "noRule", "loadUndefined", "overflowHours",
14
61
  "warnings", "warningCount"], "cost infra month");
@@ -65,15 +112,25 @@ function daySummary(day) {
65
112
  };
66
113
  }
67
114
  export function register(server, config) {
68
- server.tool("infra_cost", "Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). Needs jsonui-cli 2.3.15+.", {
115
+ server.tool("infra_cost", "Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). With plan (a migration plan's name, docs/infra/migrations/<name>): the month's old, new and difference — per node (added / replaced / removed / same, the biggest changes first), per provider, after each step of the plan (a step's difference is the whole month's change at that step, not its node's own: moved names each node or line's transfer that moved), what is not priced in the old or the new (unpricedChanged: changed nodes whose part the difference leaves out — not a saving), and with account the account's month too (its environments priced together); date and by do not apply. Needs jsonui-cli 2.3.15+ (plan: 2.3.20+).", {
69
116
  project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
70
117
  scenario: z.string().optional().describe("Load scenario id (default: the first)"),
71
118
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("One day of the scenario's month (YYYY-MM-DD)"),
72
119
  by: z.enum(["provider", "node"]).optional().describe("Group the month's totals by provider (default) or node"),
73
- }, async ({ project_dir, scenario, date, by }) => {
120
+ plan: z.string().optional().describe("A migration plan's name: the month's old / new / difference instead"),
121
+ account: z.boolean().optional().describe("With plan: the account's month too — its environments priced together, old / new / difference"),
122
+ }, async ({ project_dir, scenario, date, by, plan, account }) => {
74
123
  try {
124
+ if (!plan && account)
125
+ return failure(new Error("account applies with plan only"));
126
+ if (plan && (date || by))
127
+ return failure(new Error("date and by do not apply with plan: it answers by node and by provider at once, for the month"));
75
128
  const projectDir = config.resolveProjectDir(project_dir);
76
129
  const args = ["cost", "infra", projectDir, "--json"];
130
+ if (plan)
131
+ args.push("--migration", plan);
132
+ if (account)
133
+ args.push("--account");
77
134
  if (scenario)
78
135
  args.push("--scenario", scenario);
79
136
  if (date)
@@ -90,6 +147,12 @@ export function register(server, config) {
90
147
  need(data, ["dir", "scenario", "scenarios", "problems", "prices"], "cost infra");
91
148
  need(data.problems, ["count", "errors", "first"], "cost infra problems");
92
149
  need(data.prices, ["warnings", "warningCount"], "cost infra prices");
150
+ if (plan) {
151
+ // the load problems and price warnings are the old model's reading, as without plan
152
+ return text({ ok: exitCode === 0, exit: exitCode, dir: data.dir, scenarios: data.scenarios,
153
+ ...migrationSummary(data), loadProblems: data.problems, priceWarnings: data.prices.warnings,
154
+ priceWarningCount: data.prices.warningCount });
155
+ }
93
156
  const out = {
94
157
  ok: exitCode === 0,
95
158
  exit: exitCode,
@@ -1,8 +1,3 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ServerConfig } from "../../config.js";
3
- /**
4
- * `jsonui-doc validate infra <project> --json`: the room's model (docs/infra/*.json, or jui.config.json's infra.dir)
5
- * read as jui viz reads it — each file against the schema and the shapes of secrets, then merged, and its flows
6
- * walked. Exit 0: no error (warnings alone are 0); 1: errors; 2: nothing could be read (reason).
7
- */
8
3
  export declare function register(server: McpServer, config: ServerConfig): void;
@@ -1,17 +1,53 @@
1
1
  import { z } from "zod";
2
- import { failure, need, problems, runDocJson, text } from "./infra_common.js";
2
+ import { ContractError, FIRST, failure, need, problems, runDocJson, text } from "./infra_common.js";
3
3
  /**
4
4
  * `jsonui-doc validate infra <project> --json`: the room's model (docs/infra/*.json, or jui.config.json's infra.dir)
5
5
  * read as jui viz reads it — each file against the schema and the shapes of secrets, then merged, and its flows
6
6
  * walked. Exit 0: no error (warnings alone are 0); 1: errors; 2: nothing could be read (reason).
7
+ *
8
+ * Migration plans (jsonui-cli 2.3.20+, docs/infra/migrations/<name>/changes.json + removals.json): the CLI checks every
9
+ * plan with the model, or one with `--migration <name>`, and lists them under "migrations". A plan's error makes the
10
+ * exit 1 while the model's own problems are empty, so a room that has plans gets their summary here — without it the
11
+ * exit would name an error the answer does not show. A room with none answers exactly as before.
7
12
  */
13
+ function migrationSummary(m) {
14
+ need(m, ["name", "dir", "ok", "problems", "steps", "counts"], "validate infra migration");
15
+ return {
16
+ name: m.name,
17
+ dir: m.dir,
18
+ ok: m.ok,
19
+ counts: m.counts,
20
+ problems: problems(m.problems, "validate infra migration"),
21
+ steps: m.steps.length,
22
+ firstSteps: m.steps.slice(0, FIRST).map((s) => {
23
+ need(s, ["index", "kind", "node"], "validate infra migration step");
24
+ return { index: s.index, kind: s.kind, node: s.node,
25
+ ...(s.edgesRemoved?.length ? { edgesRemoved: s.edgesRemoved } : {}),
26
+ ...(s.edgesAdded?.length ? { edgesAdded: s.edgesAdded } : {}) };
27
+ }),
28
+ };
29
+ }
30
+ function migrations(data, plan) {
31
+ if (plan)
32
+ need(data, ["migrations"], "validate infra --migration");
33
+ const list = data.migrations;
34
+ if (list === undefined)
35
+ return {}; // a room with no plan: the CLI prints no key, and nor does this
36
+ if (!Array.isArray(list))
37
+ throw new ContractError('validate infra: "migrations" is not a list');
38
+ return { migrations: list.map(migrationSummary) };
39
+ }
8
40
  export function register(server, config) {
9
- server.tool("infra_validate", "Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. Needs jsonui-cli 2.3.15+.", {
41
+ server.tool("infra_validate", "Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. When the room has migration plans (docs/infra/migrations/<name>/changes.json + removals.json), each plan's check too: its problems (migration.remove_missing / duplicate / both / order_unknown / edge_end, and the infra checks on the merged new model), its counts (added, replaced, removed, edges added / removed) and its steps in order; `plan` checks that one plan only. Needs jsonui-cli 2.3.15+ (plans: 2.3.20+).", {
10
42
  project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
11
- }, async ({ project_dir }) => {
43
+ plan: z.string().optional().describe("One migration plan's name (its folder under docs/infra/migrations); default: every plan"),
44
+ }, async ({ project_dir, plan }) => {
12
45
  try {
13
46
  const projectDir = config.resolveProjectDir(project_dir);
14
- const { exitCode, data } = await runDocJson(["validate", "infra", projectDir, "--json"], projectDir);
47
+ const args = ["validate", "infra", projectDir, "--json"];
48
+ if (plan)
49
+ args.push("--migration", plan);
50
+ const { exitCode, data } = await runDocJson(args, projectDir);
15
51
  need(data, ["ok"], "validate infra");
16
52
  if (!data.ok && data.reason !== "invalid") {
17
53
  need(data, ["reason", "message"], "validate infra");
@@ -28,6 +64,7 @@ export function register(server, config) {
28
64
  counts: data.counts ?? null,
29
65
  flows: Array.isArray(data.flows) ? data.flows.length : null,
30
66
  problems: problems(data.problems, "validate infra"),
67
+ ...migrations(data, plan),
31
68
  });
32
69
  }
33
70
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "MCP server for JsonUI project management - wraps jui and jsonui-doc CLIs plus component spec lookup",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -65,6 +65,71 @@ doc "${WORK}/room" cost infra --date 2026-11-03 --json > "${OUT}/cost-infra.day.
65
65
  doc "${WORK}/room" cost infra --scenario nope --json > "${OUT}/cost-infra.refused.json"
66
66
  doc "${WORK}/room" check infra --json 2>/dev/null > "${OUT}/check-infra.json"
67
67
 
68
+ # Migration plans (jsonui-cli 2.3.20+), in a copy of their own so the room above stays a room without plans: one plan
69
+ # that holds (replace db-prod, add worker-prod and its edge, remove api-stg with its edges, in a written order), and one
70
+ # plan per migration.* code with exactly that error.
71
+ plan() { # plan <room> <name> <changes json> <removals json>
72
+ mkdir -p "$1/docs/infra/migrations/$2"
73
+ printf '%s' "$3" > "$1/docs/infra/migrations/$2/changes.json"
74
+ printf '%s' "$4" > "$1/docs/infra/migrations/$2/removals.json"
75
+ }
76
+ cp -R "${FIX}/aws-three-tier" "${WORK}/plans"
77
+ DB='{"id": "db-prod", "category": "database.relational", "service": "rds.postgres", "group": "private-prod", "ha": "multi-az", "environments": ["production"], "spec": [{"key": "instance_class", "value": "db.r6g.large"}, {"key": "storage_gb", "value": "200"}]}'
78
+ WORKER='{"id": "worker-prod", "category": "compute.container", "service": "ecs.fargate", "group": "private-prod", "count": 1, "environments": ["production"], "spec": [{"key": "cpu", "value": "512"}, {"key": "memory", "value": "1024"}, {"key": "tasks", "value": "1"}]}'
79
+ NONE='{"version": 0, "nodes": [], "groups": [], "edges": []}'
80
+ plan "${WORK}/plans" move "{\"version\": 0, \"order\": [\"db-prod\", \"worker-prod\", \"api-stg\"], \"nodes\": [${DB}, ${WORKER}], \"edges\": [{\"from\": \"api-prod\", \"to\": \"worker-prod\", \"protocol\": \"https\", \"port\": 443}]}" \
81
+ '{"version": 0, "nodes": ["api-stg"], "groups": [], "edges": []}'
82
+ # the account's month needs a load in two environments: staging gets one here (only in this copy)
83
+ python3 - "${WORK}/plans/docs/infra/load" <<'EOF'
84
+ import json, sys
85
+ d = json.load(open(f"{sys.argv[1]}/ordinary.json", encoding="utf-8"))
86
+ d.update(id="staging", label="staging", environment="staging",
87
+ loads=[{"node": "alb-stg", "quantities": {"lcu": 1}}, {"node": "api-stg", "quantities": {"vcpu": 0.25, "memory_gb": 0.5}},
88
+ {"node": "db-stg", "sku": "db.t4g.medium", "quantities": {"storage_gb": 20}}], edges=[])
89
+ json.dump(d, open(f"{sys.argv[1]}/staging.json", "w", encoding="utf-8"), ensure_ascii=False)
90
+ EOF
91
+ cp -R "${FIX}/aws-three-tier" "${WORK}/badplans"
92
+ plan "${WORK}/badplans" remove-missing "{\"version\": 0, \"nodes\": [${WORKER}]}" '{"version": 0, "nodes": ["nope"], "groups": [], "edges": []}'
93
+ plan "${WORK}/badplans" duplicate "{\"version\": 0, \"nodes\": [${WORKER}, ${WORKER}]}" "${NONE}"
94
+ plan "${WORK}/badplans" both "{\"version\": 0, \"nodes\": [${DB}]}" '{"version": 0, "nodes": ["db-prod"], "groups": [], "edges": []}'
95
+ plan "${WORK}/badplans" order-unknown "{\"version\": 0, \"order\": [\"ghost\"], \"nodes\": [${WORKER}]}" "${NONE}"
96
+ plan "${WORK}/badplans" edge-end "{\"version\": 0, \"nodes\": [${WORKER}], \"edges\": [{\"from\": \"worker-prod\", \"to\": \"ghost\", \"protocol\": \"https\", \"port\": 443}]}" "${NONE}"
97
+ doc "${WORK}/plans" validate infra --json > "${OUT}/validate-infra.plans.json"
98
+ doc "${WORK}/plans" validate infra --migration move --json > "${OUT}/validate-infra.plan-move.json"
99
+ doc "${WORK}/plans" validate infra --migration nope --json > "${OUT}/validate-infra.plan-none.json"
100
+ for code in remove-missing duplicate both order-unknown edge-end; do
101
+ doc "${WORK}/badplans" validate infra --migration "${code}" --json > "${OUT}/validate-infra.plan-${code}.json"
102
+ done
103
+ doc "${WORK}/plans" cost infra --migration move --json > "${OUT}/cost-infra.plan-move.json"
104
+ doc "${WORK}/plans" cost infra --migration move --account --scenario sale --json > "${OUT}/cost-infra.plan-move-account.json"
105
+ doc "${WORK}/badplans" cost infra --migration both --json > "${OUT}/cost-infra.plan-bad.json"
106
+ doc "${WORK}/plans" cost infra --migration nope --json > "${OUT}/cost-infra.plan-none.json"
107
+
108
+ # A plan whose biggest change is a fall, and an unchanged node that moves with it: batch-prod (a node of this copy
109
+ # only) comes after api-prod and shares the account's 100 GB internet egress with it (the free amount goes to the
110
+ # first nodes); the plan shrinks the database and removes api-prod, so batch-prod takes more of the free amount.
111
+ cp -R "${FIX}/aws-three-tier" "${WORK}/shrink"
112
+ python3 - "${WORK}/shrink/docs/infra" <<'EOF'
113
+ import json, sys
114
+ infra = sys.argv[1]
115
+ m = json.load(open(f"{infra}/main.json", encoding="utf-8"))
116
+ m["nodes"].append({"id": "batch-prod", "category": "compute.container", "service": "ecs.fargate", "group": "private-prod", "count": 1,
117
+ "environments": ["production"], "spec": [{"key": "cpu", "value": "256"}, {"key": "memory", "value": "512"}, {"key": "tasks", "value": "1"}]})
118
+ json.dump(m, open(f"{infra}/main.json", "w", encoding="utf-8"), ensure_ascii=False)
119
+ d = json.load(open(f"{infra}/load/ordinary.json", encoding="utf-8"))
120
+ for row in d["loads"]:
121
+ if row.get("node") == "api-prod":
122
+ row["quantities"]["egress_gb"] = {"amount": 2.7, "per": "day"}
123
+ d["loads"].append({"node": "batch-prod", "quantities": {"vcpu": 0.25, "memory_gb": 0.5, "egress_gb": {"amount": 2.7, "per": "day"}}})
124
+ json.dump(d, open(f"{infra}/load/ordinary.json", "w", encoding="utf-8"), ensure_ascii=False)
125
+ EOF
126
+ SMALL='{"id": "db-prod", "category": "database.relational", "service": "rds.mysql", "group": "private-prod", "environments": ["production"], "spec": [{"key": "instance_class", "value": "db.t4g.medium"}, {"key": "storage_gb", "value": "200"}]}'
127
+ plan "${WORK}/shrink" shrink "{\"version\": 0, \"nodes\": [${SMALL}]}" '{"version": 0, "nodes": ["api-prod"], "groups": [], "edges": []}'
128
+ doc "${WORK}/shrink" cost infra --migration shrink --json > "${OUT}/cost-infra.plan-shrink.json"
129
+ # a plan folder with nothing in it yet: listed, not ok, its read problem said (an empty migrations folder lists none)
130
+ mkdir -p "${WORK}/badplans/docs/infra/migrations/half"
131
+ doc "${WORK}/badplans" validate infra --migration half --json > "${OUT}/validate-infra.plan-half.json"
132
+
68
133
  # The temp folder's path is not part of the contract
69
134
  for f in "${OUT}"/*.json; do
70
135
  python3 - "$f" "${WORK}" <<'EOF'
@@ -11,8 +11,51 @@ import { FIRST, failure, need, problems, runDocJson, text } from "./infra_common
11
11
  * hours over capacity, the notes, the load problems and the price-book warnings — and for a day, each top node's
12
12
  * charge by meter with the price layer it came from. Exit 0: simulated; 1: simulated, a load file has an error (left
13
13
  * out); 2: nothing simulated (reason).
14
+ *
15
+ * With `plan` (jsonui-cli 2.3.20+, `--migration <name>`): the same engine run on the old model and on the new one (old
16
+ * + changes − removals), and the difference — the totals, per node (what the plan added, replaced, removed or left),
17
+ * per provider, after each step of the plan, and with `account` the account's month (its environments priced
18
+ * together). Nothing is computed here.
19
+ * A plan with an error is not priced: the CLI refuses it (exit 2, reason bad_plan, the plan's problems), as it does a
20
+ * plan name with no folder (no_plan) — both answered by the refusal path below, problems counted.
14
21
  */
15
22
 
23
+ function migrationSummary(data: any) {
24
+ need(data, ["migration", "scenario", "old", "new", "diff", "byNode", "byProvider", "steps", "account", "unpricedChanged"],
25
+ "cost infra --migration");
26
+ for (const k of ["old", "new", "diff"]) need(data[k], ["usd"], `cost infra --migration ${k}`);
27
+ for (const k of ["old", "new"]) need(data[k], ["noPrice", "noRule", "loadUndefined"], `cost infra --migration ${k}`);
28
+ for (const n of data.byNode) need(n, ["node", "change", "unpriced", "old", "new", "diff"], "cost infra --migration node");
29
+ for (const s of data.steps) {
30
+ need(s, ["index", "node", "usd", "diff", "moved"], "cost infra --migration step");
31
+ for (const m of s.moved) need(m, ["node", "diff"], "cost infra --migration step moved");
32
+ }
33
+ if (data.account !== null) need(data.account, ["on", "old", "new", "diff"], "cost infra --migration account");
34
+ const changed = data.byNode.filter((n: any) => n.change !== "same" || n.diff !== 0);
35
+ const byChange: Record<string, number> = {};
36
+ for (const n of data.byNode) byChange[n.change] = (byChange[n.change] ?? 0) + 1;
37
+ return {
38
+ migration: data.migration,
39
+ scenario: data.scenario,
40
+ usd: { old: data.old.usd, new: data.new.usd, diff: data.diff.usd },
41
+ // what either model cannot price (no price, no rule, no load): its part is 0 there, so not in the difference
42
+ oldUnpriced: { noPrice: data.old.noPrice, noRule: data.old.noRule, loadUndefined: data.old.loadUndefined },
43
+ newUnpriced: { noPrice: data.new.noPrice, noRule: data.new.noRule, loadUndefined: data.new.loadUndefined },
44
+ // the nodes the plan changes whose month is not all priced: the difference leaves their part out — not a saving
45
+ unpricedChanged: data.unpricedChanged,
46
+ nodes: byChange,
47
+ // the biggest movements first, whichever way
48
+ topChanges: [...changed].sort((a: any, b: any) => Math.abs(b.diff) - Math.abs(a.diff)).slice(0, FIRST),
49
+ changedCount: changed.length,
50
+ byProvider: data.byProvider,
51
+ // a step's diff is the whole month's change at that step, not its node's own: a neighbour can move with it (a
52
+ // shared free amount); moved names what moved, biggest first (a node, or "a>b" for a line's transfer)
53
+ steps: data.steps.map((s: any) => ({ index: s.index, node: s.node, usd: s.usd, diff: s.diff,
54
+ moved: s.moved.slice(0, FIRST), movedCount: s.moved.length })),
55
+ account: data.account,
56
+ };
57
+ }
58
+
16
59
  function monthSummary(m: any) {
17
60
  need(m, ["scenario", "month", "usd", "by", "keys", "days", "noPrice", "noRule", "loadUndefined", "overflowHours",
18
61
  "warnings", "warningCount"], "cost infra month");
@@ -71,17 +114,23 @@ function daySummary(day: any) {
71
114
  export function register(server: McpServer, config: ServerConfig) {
72
115
  server.tool(
73
116
  "infra_cost",
74
- "Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). Needs jsonui-cli 2.3.15+.",
117
+ "Simulate the server room's cost with `jsonui-doc cost infra --json` (the model, a load scenario under docs/infra/load, prices: the room's own, then fetched, then bundled — USD, on demand). Without date: the month's total, totals by provider (or by node), the top nodes, the dearest day, what has no price / no rule / no load, hours over capacity, notes. With date (YYYY-MM-DD in the scenario's month): that day's top nodes, each with its charge by meter, the price layer, and its peak hour. Also the scenarios there are, the load problems and the price-book warnings (prices.free_dropped: a room's price row dropping a bundled free amount). With plan (a migration plan's name, docs/infra/migrations/<name>): the month's old, new and difference — per node (added / replaced / removed / same, the biggest changes first), per provider, after each step of the plan (a step's difference is the whole month's change at that step, not its node's own: moved names each node or line's transfer that moved), what is not priced in the old or the new (unpricedChanged: changed nodes whose part the difference leaves out — not a saving), and with account the account's month too (its environments priced together); date and by do not apply. Needs jsonui-cli 2.3.15+ (plan: 2.3.20+).",
75
118
  {
76
119
  project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
77
120
  scenario: z.string().optional().describe("Load scenario id (default: the first)"),
78
121
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("One day of the scenario's month (YYYY-MM-DD)"),
79
122
  by: z.enum(["provider", "node"]).optional().describe("Group the month's totals by provider (default) or node"),
123
+ plan: z.string().optional().describe("A migration plan's name: the month's old / new / difference instead"),
124
+ account: z.boolean().optional().describe("With plan: the account's month too — its environments priced together, old / new / difference"),
80
125
  },
81
- async ({ project_dir, scenario, date, by }) => {
126
+ async ({ project_dir, scenario, date, by, plan, account }) => {
82
127
  try {
128
+ if (!plan && account) return failure(new Error("account applies with plan only"));
129
+ if (plan && (date || by)) return failure(new Error("date and by do not apply with plan: it answers by node and by provider at once, for the month"));
83
130
  const projectDir = config.resolveProjectDir(project_dir);
84
131
  const args = ["cost", "infra", projectDir, "--json"];
132
+ if (plan) args.push("--migration", plan);
133
+ if (account) args.push("--account");
85
134
  if (scenario) args.push("--scenario", scenario);
86
135
  if (date) args.push("--date", date);
87
136
  if (by) args.push("--by", by);
@@ -95,6 +144,12 @@ export function register(server: McpServer, config: ServerConfig) {
95
144
  need(data, ["dir", "scenario", "scenarios", "problems", "prices"], "cost infra");
96
145
  need(data.problems, ["count", "errors", "first"], "cost infra problems");
97
146
  need(data.prices, ["warnings", "warningCount"], "cost infra prices");
147
+ if (plan) {
148
+ // the load problems and price warnings are the old model's reading, as without plan
149
+ return text({ ok: exitCode === 0, exit: exitCode, dir: data.dir, scenarios: data.scenarios,
150
+ ...migrationSummary(data), loadProblems: data.problems, priceWarnings: data.prices.warnings,
151
+ priceWarningCount: data.prices.warningCount });
152
+ }
98
153
  const out: Record<string, unknown> = {
99
154
  ok: exitCode === 0,
100
155
  exit: exitCode,
@@ -1,24 +1,58 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { ServerConfig } from "../../config.js";
4
- import { failure, need, problems, runDocJson, text } from "./infra_common.js";
4
+ import { ContractError, FIRST, failure, need, problems, runDocJson, text } from "./infra_common.js";
5
5
 
6
6
  /**
7
7
  * `jsonui-doc validate infra <project> --json`: the room's model (docs/infra/*.json, or jui.config.json's infra.dir)
8
8
  * read as jui viz reads it — each file against the schema and the shapes of secrets, then merged, and its flows
9
9
  * walked. Exit 0: no error (warnings alone are 0); 1: errors; 2: nothing could be read (reason).
10
+ *
11
+ * Migration plans (jsonui-cli 2.3.20+, docs/infra/migrations/<name>/changes.json + removals.json): the CLI checks every
12
+ * plan with the model, or one with `--migration <name>`, and lists them under "migrations". A plan's error makes the
13
+ * exit 1 while the model's own problems are empty, so a room that has plans gets their summary here — without it the
14
+ * exit would name an error the answer does not show. A room with none answers exactly as before.
10
15
  */
16
+
17
+ function migrationSummary(m: any) {
18
+ need(m, ["name", "dir", "ok", "problems", "steps", "counts"], "validate infra migration");
19
+ return {
20
+ name: m.name,
21
+ dir: m.dir,
22
+ ok: m.ok,
23
+ counts: m.counts,
24
+ problems: problems(m.problems, "validate infra migration"),
25
+ steps: m.steps.length,
26
+ firstSteps: m.steps.slice(0, FIRST).map((s: any) => {
27
+ need(s, ["index", "kind", "node"], "validate infra migration step");
28
+ return { index: s.index, kind: s.kind, node: s.node,
29
+ ...(s.edgesRemoved?.length ? { edgesRemoved: s.edgesRemoved } : {}),
30
+ ...(s.edgesAdded?.length ? { edgesAdded: s.edgesAdded } : {}) };
31
+ }),
32
+ };
33
+ }
34
+ function migrations(data: any, plan: string | undefined) {
35
+ if (plan) need(data, ["migrations"], "validate infra --migration");
36
+ const list = data.migrations;
37
+ if (list === undefined) return {}; // a room with no plan: the CLI prints no key, and nor does this
38
+ if (!Array.isArray(list)) throw new ContractError('validate infra: "migrations" is not a list');
39
+ return { migrations: list.map(migrationSummary) };
40
+ }
41
+
11
42
  export function register(server: McpServer, config: ServerConfig) {
12
43
  server.tool(
13
44
  "infra_validate",
14
- "Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. Needs jsonui-cli 2.3.15+.",
45
+ "Validate the server room's infra model (docs/infra/*.json: environments, groups, nodes, edges, flows) with `jsonui-doc validate infra --json`. Returns whether it could be read (and why not), the files used, the counts, and the problems by severity and code with the first 20 (file, path, code, message). Flow problems (flow.*) name the rule a step most likely missed. When the room has migration plans (docs/infra/migrations/<name>/changes.json + removals.json), each plan's check too: its problems (migration.remove_missing / duplicate / both / order_unknown / edge_end, and the infra checks on the merged new model), its counts (added, replaced, removed, edges added / removed) and its steps in order; `plan` checks that one plan only. Needs jsonui-cli 2.3.15+ (plans: 2.3.20+).",
15
46
  {
16
47
  project_dir: z.string().optional().describe("Project directory with jui.config.json (overrides JUI_PROJECT_DIR env)"),
48
+ plan: z.string().optional().describe("One migration plan's name (its folder under docs/infra/migrations); default: every plan"),
17
49
  },
18
- async ({ project_dir }) => {
50
+ async ({ project_dir, plan }) => {
19
51
  try {
20
52
  const projectDir = config.resolveProjectDir(project_dir);
21
- const { exitCode, data } = await runDocJson(["validate", "infra", projectDir, "--json"], projectDir);
53
+ const args = ["validate", "infra", projectDir, "--json"];
54
+ if (plan) args.push("--migration", plan);
55
+ const { exitCode, data } = await runDocJson(args, projectDir);
22
56
  need(data, ["ok"], "validate infra");
23
57
  if (!data.ok && data.reason !== "invalid") {
24
58
  need(data, ["reason", "message"], "validate infra");
@@ -35,6 +69,7 @@ export function register(server: McpServer, config: ServerConfig) {
35
69
  counts: data.counts ?? null,
36
70
  flows: Array.isArray(data.flows) ? data.flows.length : null,
37
71
  problems: problems(data.problems, "validate infra"),
72
+ ...migrations(data, plan),
38
73
  });
39
74
  } catch (e: any) {
40
75
  return failure(e);
@@ -1 +1 @@
1
- 7479faaf infra arms: a load with warnings alone is cost infra's 0 (still counted), and a price dropped by two rows warns once
1
+ 290ce9df migration plans test: an edge whose two ends go at different steps goes at the first of them
@@ -0,0 +1,9 @@
1
+ {
2
+ "validate ok": "{\n \"ok\": true,\n \"exit\": 0,\n \"readable\": true,\n \"dir\": \"docs/infra\",\n \"files\": [\n \"external.json\",\n \"main.json\"\n ],\n \"environments\": [\n \"production\",\n \"staging\"\n ],\n \"counts\": {\n \"groups\": 8,\n \"nodes\": 12,\n \"edges\": 10\n },\n \"flows\": 0,\n \"problems\": {\n \"count\": 0,\n \"errors\": 0,\n \"warnings\": 0,\n \"byCode\": {},\n \"first\": []\n }\n}",
3
+ "validate errors": "{\n \"ok\": false,\n \"exit\": 1,\n \"readable\": true,\n \"dir\": \"docs/infra\",\n \"files\": [\n \"external.json\",\n \"main.json\"\n ],\n \"environments\": [\n \"production\",\n \"staging\"\n ],\n \"counts\": {\n \"groups\": 8,\n \"nodes\": 12,\n \"edges\": 10\n },\n \"flows\": 0,\n \"problems\": {\n \"count\": 1,\n \"errors\": 1,\n \"warnings\": 0,\n \"byCode\": {\n \"schema\": 1\n },\n \"first\": [\n {\n \"severity\": \"error\",\n \"code\": \"schema\",\n \"file\": \"bad.json\",\n \"path\": \"/nodes/0/category\",\n \"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\"\n }\n ]\n }\n}",
4
+ "validate unreadable": "{\n \"ok\": false,\n \"exit\": 2,\n \"readable\": false,\n \"reason\": \"no_infra\",\n \"message\": \"no model files in docs/infra (*.json)\"\n}",
5
+ "cost month": "{\n \"ok\": true,\n \"exit\": 0,\n \"dir\": \"docs/infra/load\",\n \"scenario\": \"ordinary\",\n \"scenarios\": [\n {\n \"id\": \"ordinary\",\n \"label\": \"ふだんの月\",\n \"file\": \"ordinary.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n },\n {\n \"id\": \"sale\",\n \"label\": \"セールの月\",\n \"file\": \"sale.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n }\n ],\n \"loadProblems\": {\n \"count\": 0,\n \"errors\": 0,\n \"first\": []\n },\n \"priceWarnings\": [],\n \"priceWarningCount\": 0,\n \"month\": {\n \"scenario\": \"ordinary\",\n \"month\": \"2026-11\",\n \"usd\": 1018.5974779999989,\n \"by\": \"provider\",\n \"totals\": [\n {\n \"key\": \"aws\",\n \"label\": \"aws\",\n \"usd\": 1018.5974779999997\n }\n ],\n \"days\": 30,\n \"dearestDay\": \"2026-11-30\",\n \"noPrice\": [\n {\n \"node\": \"mailer\",\n \"meter\": \"emails\",\n \"sku\": \"emails\",\n \"unit\": \"email\"\n },\n {\n \"node\": \"api-5xx\",\n \"meter\": \"alarm\",\n \"sku\": \"alarm\",\n \"unit\": \"month\"\n }\n ],\n \"noRule\": [\n \"api-role\"\n ],\n \"loadUndefined\": [\n \"stripe\"\n ],\n \"overflowHours\": {},\n \"notes\": [\n \"no price for (no service) emails (email) in ap-northeast-1\",\n \"no price for cloudwatch.alarm alarm (month) in no region\"\n ],\n \"noteCount\": 2\n },\n \"topNodes\": [\n {\n \"node\": \"cdn\",\n \"usd\": 455.4774000000002\n },\n {\n \"node\": \"db-prod\",\n \"usd\": 422.3999999999987\n },\n {\n \"node\": \"api-prod\",\n \"usd\": 64.80000000000005\n },\n {\n \"node\": \"assets\",\n \"usd\": 44.55927800000017\n },\n {\n \"node\": \"alb-prod\",\n \"usd\": 29.015999999999977\n },\n {\n \"node\": \"api-prod>db-prod\",\n \"usd\": 2.3447999999999984\n }\n ]\n}",
6
+ "cost month by node": "{\n \"ok\": true,\n \"exit\": 0,\n \"dir\": \"docs/infra/load\",\n \"scenario\": \"sale\",\n \"scenarios\": [\n {\n \"id\": \"ordinary\",\n \"label\": \"ふだんの月\",\n \"file\": \"ordinary.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n },\n {\n \"id\": \"sale\",\n \"label\": \"セールの月\",\n \"file\": \"sale.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n }\n ],\n \"loadProblems\": {\n \"count\": 0,\n \"errors\": 0,\n \"first\": []\n },\n \"priceWarnings\": [],\n \"priceWarningCount\": 0,\n \"month\": {\n \"scenario\": \"sale\",\n \"month\": \"2026-11\",\n \"usd\": 2679.3521999999975,\n \"by\": \"node\",\n \"totals\": [\n {\n \"key\": \"cdn\",\n \"label\": \"CloudFront\",\n \"usd\": 1975.4412000000011\n },\n {\n \"key\": \"db-prod\",\n \"label\": \"db-prod\",\n \"usd\": 598.2749999999992\n },\n {\n \"key\": \"api-prod\",\n \"label\": \"api-prod\",\n \"usd\": 88.13999999999997\n },\n {\n \"key\": \"alb-prod\",\n \"label\": \"alb-prod\",\n \"usd\": 17.496\n }\n ],\n \"days\": 30,\n \"dearestDay\": \"2026-11-27\",\n \"noPrice\": [\n {\n \"node\": \"api-5xx\",\n \"meter\": \"alarm\",\n \"sku\": \"alarm\",\n \"unit\": \"month\"\n }\n ],\n \"noRule\": [\n \"api-role\"\n ],\n \"loadUndefined\": [\n \"stripe\",\n \"mailer\",\n \"assets\"\n ],\n \"overflowHours\": {\n \"api-prod\": 17,\n \"db-prod\": 3\n },\n \"notes\": [\n \"no price for cloudwatch.alarm alarm (month) in no region\"\n ],\n \"noteCount\": 1\n },\n \"topNodes\": [\n {\n \"node\": \"cdn\",\n \"usd\": 1975.4412000000011\n },\n {\n \"node\": \"db-prod\",\n \"usd\": 598.2749999999991\n },\n {\n \"node\": \"api-prod\",\n \"usd\": 88.13999999999996\n },\n {\n \"node\": \"alb-prod\",\n \"usd\": 17.496\n }\n ]\n}",
7
+ "cost day": "{\n \"ok\": true,\n \"exit\": 0,\n \"dir\": \"docs/infra/load\",\n \"scenario\": \"ordinary\",\n \"scenarios\": [\n {\n \"id\": \"ordinary\",\n \"label\": \"ふだんの月\",\n \"file\": \"ordinary.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n },\n {\n \"id\": \"sale\",\n \"label\": \"セールの月\",\n \"file\": \"sale.json\",\n \"month\": \"2026-11\",\n \"environment\": \"production\"\n }\n ],\n \"loadProblems\": {\n \"count\": 0,\n \"errors\": 0,\n \"first\": []\n },\n \"priceWarnings\": [],\n \"priceWarningCount\": 0,\n \"day\": {\n \"scenario\": \"ordinary\",\n \"date\": \"2026-11-03\",\n \"usd\": 34.254097666666674,\n \"nodes\": [\n {\n \"node\": \"cdn\",\n \"label\": \"CloudFront\",\n \"provider\": \"aws\",\n \"rule\": \"aws/cloudfront\",\n \"usd\": 15.4623,\n \"peakHour\": 19,\n \"meters\": {\n \"egress-asia\": {\n \"usd\": 1.1940000000000008,\n \"amount\": 9.95,\n \"unit\": \"GB\",\n \"sku\": \"egress-asia\",\n \"layer\": \"bundled\"\n },\n \"egress-japan\": {\n \"usd\": 5.6715,\n \"amount\": 49.75,\n \"unit\": \"GB\",\n \"sku\": \"egress-japan\",\n \"layer\": \"bundled\"\n },\n \"requests-asia\": {\n \"usd\": 1.4328,\n \"amount\": 1194000,\n \"unit\": \"request\",\n \"sku\": \"requests-asia\",\n \"layer\": \"bundled\"\n },\n \"requests-japan\": {\n \"usd\": 7.164,\n \"amount\": 5970000,\n \"unit\": \"request\",\n \"sku\": \"requests-japan\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"db-prod\",\n \"label\": \"db-prod\",\n \"provider\": \"aws\",\n \"rule\": \"aws/rds\",\n \"usd\": 14.080000000000005,\n \"peakHour\": 0,\n \"meters\": {\n \"instance\": {\n \"usd\": 12.239999999999997,\n \"amount\": 24,\n \"unit\": \"instance-hour\",\n \"sku\": \"db.r6g.large:multi-az\",\n \"layer\": \"bundled\"\n },\n \"storage\": {\n \"usd\": 1.8400000000000054,\n \"amount\": 6.666666666666665,\n \"unit\": \"GB-month\",\n \"sku\": \"storage:multi-az\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"api-prod\",\n \"label\": \"api-prod\",\n \"provider\": \"aws\",\n \"rule\": \"aws/fargate\",\n \"usd\": 2.16,\n \"peakHour\": 0,\n \"meters\": {\n \"vcpu\": {\n \"usd\": 1.7999999999999994,\n \"amount\": 36,\n \"unit\": \"vCPU-hour\",\n \"sku\": \"vcpu\",\n \"layer\": \"user\"\n },\n \"memory\": {\n \"usd\": 0.3600000000000002,\n \"amount\": 72,\n \"unit\": \"GB-hour\",\n \"sku\": \"memory\",\n \"layer\": \"user\"\n }\n }\n },\n {\n \"node\": \"assets\",\n \"label\": \"アセットのバケツ\",\n \"provider\": \"aws\",\n \"rule\": \"aws/s3\",\n \"usd\": 1.5049976666666658,\n \"peakHour\": 19,\n \"meters\": {\n \"storage\": {\n \"usd\": 0.4166666666666656,\n \"amount\": 16.666666666666668,\n \"unit\": \"GB-month\",\n \"sku\": \"storage\",\n \"layer\": \"bundled\"\n },\n \"get\": {\n \"usd\": 1.060272,\n \"amount\": 2865600,\n \"unit\": \"request\",\n \"sku\": \"get\",\n \"layer\": \"user\"\n },\n \"put\": {\n \"usd\": 0.028059,\n \"amount\": 5970,\n \"unit\": \"request\",\n \"sku\": \"put\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"alb-prod\",\n \"label\": \"alb-prod\",\n \"provider\": \"aws\",\n \"rule\": \"aws/alb\",\n \"usd\": 0.9672000000000001,\n \"peakHour\": 0,\n \"meters\": {\n \"hour\": {\n \"usd\": 0.5831999999999998,\n \"amount\": 24,\n \"unit\": \"instance-hour\",\n \"sku\": \"hour\",\n \"layer\": \"bundled\"\n },\n \"lcu\": {\n \"usd\": 0.38400000000000023,\n \"amount\": 48,\n \"unit\": \"LCU-hour\",\n \"sku\": \"lcu\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"api-prod>db-prod\",\n \"label\": \"api-prod>db-prod\",\n \"provider\": \"aws\",\n \"rule\": \"transfer\",\n \"usd\": 0.07959999999999993,\n \"peakHour\": 19,\n \"meters\": {\n \"transfer-regional\": {\n \"usd\": 0.07959999999999996,\n \"amount\": 3.9800000000000004,\n \"unit\": \"GB\",\n \"sku\": \"transfer-regional\",\n \"layer\": \"bundled\"\n }\n }\n },\n {\n \"node\": \"stripe\",\n \"label\": \"Stripe\",\n \"provider\": \"stripe\",\n \"rule\": \"generic/external.saas\",\n \"note\": \"load undefined\",\n \"usd\": 0,\n \"peakHour\": 0,\n \"meters\": {}\n },\n {\n \"node\": \"mailer\",\n \"label\": \"メール配信\",\n \"provider\": \"sendgrid\",\n \"rule\": \"generic/mail\",\n \"usd\": 0,\n \"peakHour\": 0,\n \"meters\": {\n \"emails\": {\n \"usd\": 0,\n \"amount\": 1658.3333333333335,\n \"unit\": \"email\",\n \"sku\": \"emails\",\n \"layer\": null\n }\n }\n },\n {\n \"node\": \"api-role\",\n \"label\": \"API の実行ロール\",\n \"provider\": \"aws\",\n \"rule\": null,\n \"note\": \"no rule\",\n \"usd\": 0,\n \"peakHour\": 0,\n \"meters\": {}\n },\n {\n \"node\": \"api-5xx\",\n \"label\": \"API の 5xx\",\n \"provider\": \"aws\",\n \"rule\": \"aws/cloudwatch-alarm\",\n \"usd\": 0,\n \"peakHour\": 0,\n \"meters\": {\n \"alarm\": {\n \"usd\": 0,\n \"amount\": 0.03333333333333332,\n \"unit\": \"month\",\n \"sku\": \"alarm\",\n \"layer\": null\n }\n }\n }\n ],\n \"others\": {\n \"count\": 0,\n \"usd\": 0\n }\n }\n}",
8
+ "cost refused": "{\n \"ok\": false,\n \"exit\": 2,\n \"reason\": \"no_scenario\",\n \"message\": \"no scenario 'nope'; there are: ordinary, sale\"\n}"
9
+ }
@@ -11,16 +11,17 @@
11
11
  "mismatch": 0,
12
12
  "missing_in_impl": 0,
13
13
  "missing_in_doc": 0,
14
- "skipped": 6,
15
- "warning": 2,
14
+ "skipped": 4,
15
+ "warning": 4,
16
16
  "unit": "node"
17
17
  },
18
18
  "results": [
19
19
  {
20
20
  "target": "load/ordinary/stripe",
21
- "status": "skipped",
21
+ "status": "warning",
22
22
  "confidence": "metadata",
23
- "message": "its kind reads no load"
23
+ "expected": "requests, input_tokens, output_tokens",
24
+ "message": "billed by use, but this load definition gives it no load (undefined, not 0)"
24
25
  },
25
26
  {
26
27
  "target": "load/ordinary/api-role",
@@ -36,9 +37,10 @@
36
37
  },
37
38
  {
38
39
  "target": "load/sale/stripe",
39
- "status": "skipped",
40
+ "status": "warning",
40
41
  "confidence": "metadata",
41
- "message": "its kind reads no load"
42
+ "expected": "requests, input_tokens, output_tokens",
43
+ "message": "billed by use, but this load definition gives it no load (undefined, not 0)"
42
44
  },
43
45
  {
44
46
  "target": "load/sale/mailer",
@@ -8834,8 +8834,8 @@
8834
8834
  "node": "stripe",
8835
8835
  "label": "Stripe",
8836
8836
  "provider": "stripe",
8837
- "rule": null,
8838
- "note": "no rule",
8837
+ "rule": "generic/external.saas",
8838
+ "note": "load undefined",
8839
8839
  "usd": 0.0,
8840
8840
  "hours": [
8841
8841
  {
@@ -441,10 +441,10 @@
441
441
  }
442
442
  ],
443
443
  "noRule": [
444
- "stripe",
445
444
  "api-role"
446
445
  ],
447
446
  "loadUndefined": [
447
+ "stripe",
448
448
  "mailer",
449
449
  "assets"
450
450
  ],
@@ -456,9 +456,17 @@
456
456
  {
457
457
  "fn": "price_meter",
458
458
  "message": "no price for cloudwatch.alarm alarm (month) in no region",
459
+ "detail": {
460
+ "keyword": "cost.no_price",
461
+ "service": "cloudwatch.alarm",
462
+ "sku": "alarm",
463
+ "unit": "month",
464
+ "region": ""
465
+ },
459
466
  "node": "api-5xx",
460
467
  "hour": "2026-11-01T00",
461
- "meter": "alarm"
468
+ "meter": "alarm",
469
+ "short": "cloudwatch.alarm alarm(month)の価格が、(region 無し) にありません"
462
470
  }
463
471
  ],
464
472
  "warningCount": 1