From c7b191067fc740d3e82f794a3fc221b9026d07a7 Mon Sep 17 00:00:00 2001 From: John Smith Date: Sat, 19 Sep 2026 00:54:33 -0400 Subject: [PATCH] chore(guardrails): add data validator, wire into CI, reproducible plan generation - scripts/validate-data.mjs enforces plan schema + AGENTS invariants (non-empty limits/models, positive token budgets on paid tiers, conservative <= midpoint <= optimistic, annual price math, id/filename) - validate-data + lint now run in deploy and update-data workflows - generate_plans.py: relative output path, single LAST_VERIFIED constant - fix zero-token paid tiers (kilo-code Teams, opencode Zen, tabnine) with documented platform-fee-equivalent estimates so stacking no longer drops them --- .github/workflows/deploy.yml | 6 ++ .github/workflows/update-data.yml | 3 + data/coding-plans/kilo-code.json | 8 +- data/coding-plans/opencode.json | 8 +- data/coding-plans/tabnine.json | 16 +-- data/generate_plans.py | 101 ++++++++++-------- package.json | 1 + public/data/last-updated.json | 2 +- public/data/plans.json | 32 +++--- scripts/validate-data.mjs | 172 ++++++++++++++++++++++++++++++ 10 files changed, 278 insertions(+), 71 deletions(-) create mode 100644 scripts/validate-data.mjs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 47c77d2..9785a54 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Lint + run: npm run lint + + - name: Validate data invariants + run: npm run validate-data + - name: Build site run: npm run build diff --git a/.github/workflows/update-data.yml b/.github/workflows/update-data.yml index 041defc..7bca802 100644 --- a/.github/workflows/update-data.yml +++ b/.github/workflows/update-data.yml @@ -30,6 +30,9 @@ jobs: AA_API_KEY: ${{ secrets.AA_API_KEY }} run: npm run update-data + - name: Validate data invariants + run: npm run validate-data + - name: Commit and push changes run: | git config user.name "github-actions[bot]" diff --git a/data/coding-plans/kilo-code.json b/data/coding-plans/kilo-code.json index de07568..0592e62 100644 --- a/data/coding-plans/kilo-code.json +++ b/data/coding-plans/kilo-code.json @@ -33,9 +33,11 @@ "Kilo Gateway (500+ models)" ], "estimatedTokenBudget": { - "description": "Platform fee only; inference at provider list rates", - "estimatedMillionTokens": 0, - "assumptions": "Token cost = provider rates + 5% gateway fee (or BYOK, zero fee)" + "description": "Platform fee; ~15M fee-equivalent tokens at open-model rates", + "estimatedMillionTokens": 15, + "midpointEstimate": 30, + "optimisticEstimate": 45, + "assumptions": "Platform-fee plan: no token bundle included; inference billed separately at provider rates + 5% gateway fee (BYOK zero fee). Figures are the fee-equivalent value of $15 at ~$1.00/M (conservative) to ~$0.33/M (optimistic open-model gateway rates) for cross-plan comparison only" }, "notes": "Security/governance features included" }, diff --git a/data/coding-plans/opencode.json b/data/coding-plans/opencode.json index 54aaa0a..f0caaad 100644 --- a/data/coding-plans/opencode.json +++ b/data/coding-plans/opencode.json @@ -40,9 +40,11 @@ "Free stealth models" ], "estimatedTokenBudget": { - "description": "Per-token billing at curated gateway rates; min $20", - "estimatedMillionTokens": 0, - "assumptions": "Balance-based; cost equals published per-model rates" + "description": "Prepaid balance (~10M fee-equivalent tokens)", + "estimatedMillionTokens": 10, + "midpointEstimate": 20, + "optimisticEstimate": 40, + "assumptions": "$20 minimum balance at zero markup buys $20 of inference at curated gateway rates; conservative 10M at ~$2.00/M frontier blend, midpoint 20M at ~$1.00/M, optimistic 40M at ~$0.50/M open-model rates; excludes the $1.23 card fee" }, "notes": "US-hosted, zero retention" }, diff --git a/data/coding-plans/tabnine.json b/data/coding-plans/tabnine.json index 7d8f081..a50e3e3 100644 --- a/data/coding-plans/tabnine.json +++ b/data/coding-plans/tabnine.json @@ -17,9 +17,11 @@ "Tabnine Protected" ], "estimatedTokenBudget": { - "description": "Uncapped volumes; token cost = LLM provider list price + 5% service fee", - "estimatedMillionTokens": 0, - "assumptions": "No fixed monthly token bundle; supply your own LLM for unlimited usage, or reserve token quota at actual provider rates" + "description": "Platform fee; ~19.5M fee-equivalent tokens", + "estimatedMillionTokens": 19.5, + "midpointEstimate": 39, + "optimisticEstimate": 78, + "assumptions": "Platform-fee plan: no token bundle included; Tabnine-provided LLM access is billed at provider list price + 5% handling fee, BYO LLM endpoint is unlimited. Figures are fee-equivalent at ~$2.00/M (conservative), ~$1.00/M (midpoint) and ~$0.50/M (optimistic) for cross-plan comparison only" }, "notes": "Completions + grounded chat in IDEs; Jira integration; SOC 2/ISO 27001" }, @@ -35,9 +37,11 @@ "Tabnine Protected" ], "estimatedTokenBudget": { - "description": "Uncapped; provider list price + 5% handling fee", - "estimatedMillionTokens": 0, - "assumptions": "No fixed token bundle; value is the platform, agents and context engine rather than quota" + "description": "Platform fee; ~29.5M fee-equivalent tokens", + "estimatedMillionTokens": 29.5, + "midpointEstimate": 59, + "optimisticEstimate": 118, + "assumptions": "Platform-fee plan: no token bundle included; same LLM economics as Code Assistant. Figures are fee-equivalent at ~$2.00/M / ~$1.00/M / ~$0.50/M for cross-plan comparison only" }, "notes": "Adds autonomous agents, Coaching Guidelines, Jira/Confluence/database MCP access" }, diff --git a/data/generate_plans.py b/data/generate_plans.py index 65bf401..e6c554d 100644 --- a/data/generate_plans.py +++ b/data/generate_plans.py @@ -5,7 +5,8 @@ null = None false = False true = True -target_dir = "/home/john/Projects/Token-Max/data/coding-plans" +LAST_VERIFIED = "2026-09-18" +target_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "coding-plans") os.makedirs(target_dir, exist_ok=True) @@ -24,7 +25,7 @@ write_json( "name": "Cursor", "category": "coding-ide", "url": "https://cursor.com/docs/account/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Hobby", @@ -142,7 +143,7 @@ write_json( "name": "GitHub Copilot", "category": "coding-ide", "url": "https://github.com/features/copilot/plans", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -284,7 +285,7 @@ write_json( "name": "Claude Code", "category": "coding-ide", "url": "https://claude.com/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Pro", @@ -365,7 +366,7 @@ write_json( "name": "OpenAI Codex (ChatGPT)", "category": "coding-ide", "url": "https://openai.com/chatgpt/pricing/", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -457,7 +458,7 @@ write_json( "name": "Google Antigravity", "category": "coding-ide", "url": "https://antigravity.google/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Individual", @@ -584,7 +585,7 @@ write_json( "name": "Meta Muse Code", "category": "coding-ide", "url": "https://developer.meta.com/ai/products/muse-code", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Everyday", @@ -657,7 +658,7 @@ write_json( "name": "Kiro", "category": "coding-ide", "url": "https://kiro.dev/pricing/", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -784,7 +785,7 @@ write_json( "name": "Kilo Code", "category": "coding-router", "url": "https://kilo.ai/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Individual", @@ -810,9 +811,11 @@ write_json( }, "models": ["Kilo Gateway (500+ models)"], "estimatedTokenBudget": { - "description": "Platform fee only; inference at provider list rates", - "estimatedMillionTokens": 0, - "assumptions": "Token cost = provider rates + 5% gateway fee (or BYOK, zero fee)", + "description": "Platform fee; ~15M fee-equivalent tokens at open-model rates", + "estimatedMillionTokens": 15, + "midpointEstimate": 30, + "optimisticEstimate": 45, + "assumptions": "Platform-fee plan: no token bundle included; inference billed separately at provider rates + 5% gateway fee (BYOK zero fee). Figures are the fee-equivalent value of $15 at ~$1.00/M (conservative) to ~$0.33/M (optimistic open-model gateway rates) for cross-plan comparison only", }, "notes": "Security/governance features included", }, @@ -849,7 +852,7 @@ write_json( "name": "Lovable", "category": "coding-ide", "url": "https://lovable.dev/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -921,7 +924,7 @@ write_json( "name": "Kimi Code", "category": "coding-router", "url": "https://www.kimi.com/coding-plan/", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Moderato", @@ -1004,7 +1007,7 @@ write_json( "name": "Windsurf (Cognition)", "category": "coding-ide", "url": "https://windsurf.com/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -1111,7 +1114,7 @@ write_json( "name": "Augment Code", "category": "coding-ide", "url": "https://www.augmentcode.com/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Standard", @@ -1180,7 +1183,7 @@ write_json( "name": "Replit", "category": "coding-ide", "url": "https://replit.com/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Core", @@ -1249,7 +1252,7 @@ write_json( "name": "Amazon Q Developer", "category": "coding-ide", "url": "https://aws.amazon.com/q/developer/pricing/", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free Tier", @@ -1303,7 +1306,7 @@ write_json( "name": "Tabnine", "category": "coding-ide", "url": "https://www.tabnine.com/pricing/", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Code Assistant", @@ -1315,9 +1318,11 @@ write_json( }, "models": ["Tabnine Protected"], "estimatedTokenBudget": { - "description": "Uncapped volumes; token cost = LLM provider list price + 5% service fee", - "estimatedMillionTokens": 0, - "assumptions": "No fixed monthly token bundle; supply your own LLM for unlimited usage, or reserve token quota at actual provider rates", + "description": "Platform fee; ~19.5M fee-equivalent tokens", + "estimatedMillionTokens": 19.5, + "midpointEstimate": 39, + "optimisticEstimate": 78, + "assumptions": "Platform-fee plan: no token bundle included; Tabnine-provided LLM access is billed at provider list price + 5% handling fee, BYO LLM endpoint is unlimited. Figures are fee-equivalent at ~$2.00/M (conservative), ~$1.00/M (midpoint) and ~$0.50/M (optimistic) for cross-plan comparison only", }, "notes": "Completions + grounded chat in IDEs; Jira integration; SOC 2/ISO 27001", }, @@ -1331,9 +1336,11 @@ write_json( }, "models": ["Tabnine Protected"], "estimatedTokenBudget": { - "description": "Uncapped; provider list price + 5% handling fee", - "estimatedMillionTokens": 0, - "assumptions": "No fixed token bundle; value is the platform, agents and context engine rather than quota", + "description": "Platform fee; ~29.5M fee-equivalent tokens", + "estimatedMillionTokens": 29.5, + "midpointEstimate": 59, + "optimisticEstimate": 118, + "assumptions": "Platform-fee plan: no token bundle included; same LLM economics as Code Assistant. Figures are fee-equivalent at ~$2.00/M / ~$1.00/M / ~$0.50/M for cross-plan comparison only", }, "notes": "Adds autonomous agents, Coaching Guidelines, Jira/Confluence/database MCP access", }, @@ -1375,7 +1382,7 @@ write_json( "name": "Aider", "category": "coding-ide", "url": "https://aider.chat", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free / BYOK", @@ -1421,7 +1428,7 @@ write_json( "name": "CommandCode", "category": "coding-router", "url": "https://commandcode.ai/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Go", @@ -1530,7 +1537,7 @@ write_json( "name": "OpenCode", "category": "coding-router", "url": "https://opencode.ai/zen", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "OpenSource CLI", @@ -1565,9 +1572,11 @@ write_json( "Free stealth models", ], "estimatedTokenBudget": { - "description": "Per-token billing at curated gateway rates; min $20", - "estimatedMillionTokens": 0, - "assumptions": "Balance-based; cost equals published per-model rates", + "description": "Prepaid balance (~10M fee-equivalent tokens)", + "estimatedMillionTokens": 10, + "midpointEstimate": 20, + "optimisticEstimate": 40, + "assumptions": "$20 minimum balance at zero markup buys $20 of inference at curated gateway rates; conservative 10M at ~$2.00/M frontier blend, midpoint 20M at ~$1.00/M, optimistic 40M at ~$0.50/M open-model rates; excludes the $1.23 card fee", }, "notes": "US-hosted, zero retention", }, @@ -1612,7 +1621,7 @@ write_json( "name": "OpenRouter", "category": "coding-router", "url": "https://openrouter.ai", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -1668,7 +1677,7 @@ write_json( "name": "BytePlus ModelArk Coding Plan", "category": "api-provider", "url": "https://www.byteplus.com/en/activity/arkcodingplan", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Lite", @@ -1734,7 +1743,7 @@ write_json( "name": "Alibaba Cloud", "category": "api-provider", "url": "https://www.alibabacloud.com/en/campaign/ai-landing-page-token", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Personal Lite", @@ -1849,7 +1858,7 @@ write_json( "name": "MiniMax Token Plan", "category": "api-provider", "url": "https://platform.minimax.io/docs/guides/pricing-token-plan", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Plus", @@ -1932,7 +1941,7 @@ write_json( "name": "OpenAI API", "category": "api-provider", "url": "https://platform.openai.com", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "PAYG", @@ -1981,7 +1990,7 @@ write_json( "name": "Anthropic API", "category": "api-provider", "url": "https://console.anthropic.com", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "PAYG", @@ -2030,7 +2039,7 @@ write_json( "name": "Google AI Studio", "category": "api-provider", "url": "https://aistudio.google.com", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -2119,7 +2128,7 @@ write_json( "name": "DeepSeek API", "category": "api-provider", "url": "https://platform.deepseek.com", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "PAYG (Flash)", @@ -2174,7 +2183,7 @@ write_json( "name": "Groq API", "category": "api-provider", "url": "https://console.groq.com", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -2228,7 +2237,7 @@ write_json( "name": "Mistral API", "category": "api-provider", "url": "https://mistral.ai/pricing", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free (Le Plan Studio)", @@ -2289,7 +2298,7 @@ write_json( "name": "Together.ai", "category": "api-provider", "url": "https://together.ai", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -2345,7 +2354,7 @@ write_json( "name": "Fireworks.ai", "category": "api-provider", "url": "https://fireworks.ai", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -2398,7 +2407,7 @@ write_json( "name": "Meta Llama API", "category": "api-provider", "url": "https://meta.com", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Standard", @@ -2454,7 +2463,7 @@ write_json( "name": "Ollama Cloud", "category": "api-provider", "url": "https://ollama.com", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Free", @@ -2544,7 +2553,7 @@ write_json( "name": "Z.ai GLM Coding Plan", "category": "api-provider", "url": "https://z.ai/subscribe", - "lastVerified": "2026-09-18", + "lastVerified": LAST_VERIFIED, "tiers": [ { "name": "Lite", diff --git a/package.json b/package.json index 345b52d..6e3bf96 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "fetch-benchmarks": "node scripts/fetch-benchmarks.mjs", "build-data": "node scripts/build-data.mjs", "update-data": "node scripts/fetch-models.mjs && node scripts/fetch-benchmarks.mjs && node scripts/build-data.mjs", + "validate-data": "node scripts/validate-data.mjs", "lint": "oxlint", "preview": "vite preview" }, diff --git a/public/data/last-updated.json b/public/data/last-updated.json index 79bc428..30007ca 100644 --- a/public/data/last-updated.json +++ b/public/data/last-updated.json @@ -1,3 +1,3 @@ { - "timestamp": "2026-09-19T04:11:37.228Z" + "timestamp": "2026-09-19T04:54:16.160Z" } \ No newline at end of file diff --git a/public/data/plans.json b/public/data/plans.json index c040744..f003545 100644 --- a/public/data/plans.json +++ b/public/data/plans.json @@ -1283,9 +1283,11 @@ "Kilo Gateway (500+ models)" ], "estimatedTokenBudget": { - "description": "Platform fee only; inference at provider list rates", - "estimatedMillionTokens": 0, - "assumptions": "Token cost = provider rates + 5% gateway fee (or BYOK, zero fee)" + "description": "Platform fee; ~15M fee-equivalent tokens at open-model rates", + "estimatedMillionTokens": 15, + "midpointEstimate": 30, + "optimisticEstimate": 45, + "assumptions": "Platform-fee plan: no token bundle included; inference billed separately at provider rates + 5% gateway fee (BYOK zero fee). Figures are the fee-equivalent value of $15 at ~$1.00/M (conservative) to ~$0.33/M (optimistic open-model gateway rates) for cross-plan comparison only" }, "notes": "Security/governance features included" }, @@ -2177,9 +2179,11 @@ "Free stealth models" ], "estimatedTokenBudget": { - "description": "Per-token billing at curated gateway rates; min $20", - "estimatedMillionTokens": 0, - "assumptions": "Balance-based; cost equals published per-model rates" + "description": "Prepaid balance (~10M fee-equivalent tokens)", + "estimatedMillionTokens": 10, + "midpointEstimate": 20, + "optimisticEstimate": 40, + "assumptions": "$20 minimum balance at zero markup buys $20 of inference at curated gateway rates; conservative 10M at ~$2.00/M frontier blend, midpoint 20M at ~$1.00/M, optimistic 40M at ~$0.50/M open-model rates; excludes the $1.23 card fee" }, "notes": "US-hosted, zero retention" }, @@ -2359,9 +2363,11 @@ "Tabnine Protected" ], "estimatedTokenBudget": { - "description": "Uncapped volumes; token cost = LLM provider list price + 5% service fee", - "estimatedMillionTokens": 0, - "assumptions": "No fixed monthly token bundle; supply your own LLM for unlimited usage, or reserve token quota at actual provider rates" + "description": "Platform fee; ~19.5M fee-equivalent tokens", + "estimatedMillionTokens": 19.5, + "midpointEstimate": 39, + "optimisticEstimate": 78, + "assumptions": "Platform-fee plan: no token bundle included; Tabnine-provided LLM access is billed at provider list price + 5% handling fee, BYO LLM endpoint is unlimited. Figures are fee-equivalent at ~$2.00/M (conservative), ~$1.00/M (midpoint) and ~$0.50/M (optimistic) for cross-plan comparison only" }, "notes": "Completions + grounded chat in IDEs; Jira integration; SOC 2/ISO 27001" }, @@ -2377,9 +2383,11 @@ "Tabnine Protected" ], "estimatedTokenBudget": { - "description": "Uncapped; provider list price + 5% handling fee", - "estimatedMillionTokens": 0, - "assumptions": "No fixed token bundle; value is the platform, agents and context engine rather than quota" + "description": "Platform fee; ~29.5M fee-equivalent tokens", + "estimatedMillionTokens": 29.5, + "midpointEstimate": 59, + "optimisticEstimate": 118, + "assumptions": "Platform-fee plan: no token bundle included; same LLM economics as Code Assistant. Figures are fee-equivalent at ~$2.00/M / ~$1.00/M / ~$0.50/M for cross-plan comparison only" }, "notes": "Adds autonomous agents, Coaching Guidelines, Jira/Confluence/database MCP access" }, diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs new file mode 100644 index 0000000..a59fc2d --- /dev/null +++ b/scripts/validate-data.mjs @@ -0,0 +1,172 @@ +import fs from 'fs/promises'; +import path from 'path'; + +const PLANS_DIR = path.join(process.cwd(), 'data/coding-plans'); +const PUBLIC_PLANS = path.join(process.cwd(), 'public/data/plans.json'); + +const CATEGORIES = new Set(['coding-ide', 'coding-router', 'api-provider']); +const STACKING_POLICIES = new Set(['allowed', 'silent', 'prohibited', 'unknown']); + +const errors = []; + +function err(msg) { + errors.push(msg); +} + +function isIsoDate(value) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value)); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function validatePlan(plan, filename) { + const where = `${filename}`; + if (!plan || typeof plan !== 'object') { + err(`${where}: not a JSON object`); + return; + } + if (typeof plan.id !== 'string' || plan.id.length === 0) err(`${where}: missing id`); + const idFromFile = filename.replace(/\.json$/, ''); + if (plan.id !== idFromFile) err(`${where}: id "${plan.id}" does not match filename`); + if (typeof plan.name !== 'string' || plan.name.trim() === '') err(`${where}: missing name`); + if (!CATEGORIES.has(plan.category)) err(`${where}: category "${plan.category}" is not one of ${[...CATEGORIES].join(', ')}`); + if (typeof plan.url !== 'string' || !/^https?:\/\//.test(plan.url)) err(`${where}: url must be an http(s) URL`); + if (!isIsoDate(plan.lastVerified)) err(`${where}: lastVerified must be YYYY-MM-DD`); + if (typeof plan.dataTraining !== 'string' || plan.dataTraining.trim() === '') err(`${where}: dataTraining must be a non-empty string`); + if (typeof plan.ipIndemnity !== 'boolean' && (typeof plan.ipIndemnity !== 'string' || plan.ipIndemnity.trim() === '')) { + err(`${where}: ipIndemnity must be a boolean or non-empty string`); + } + if (!Array.isArray(plan.gotchas) || plan.gotchas.length === 0) err(`${where}: gotchas must be a non-empty array`); + if (!Array.isArray(plan.tosHighlights)) err(`${where}: tosHighlights must be an array`); + if (plan.stackingPolicy !== undefined && !STACKING_POLICIES.has(plan.stackingPolicy)) { + err(`${where}: stackingPolicy "${plan.stackingPolicy}" must be one of ${[...STACKING_POLICIES].join(', ')}`); + } + + if (!Array.isArray(plan.tiers) || plan.tiers.length === 0) { + err(`${where}: tiers must be a non-empty array`); + return; + } + + const seenTierNames = new Set(); + for (const tier of plan.tiers) { + const t = `${where} [${tier?.name ?? '?'}]`; + if (!isPlainObject(tier)) { + err(`${t}: tier is not an object`); + continue; + } + if (typeof tier.name !== 'string' || tier.name.trim() === '') err(`${t}: missing tier name`); + if (seenTierNames.has(tier.name)) err(`${t}: duplicate tier name`); + seenTierNames.add(tier.name); + if (tier.monthlyPrice !== null && (typeof tier.monthlyPrice !== 'number' || tier.monthlyPrice < 0 || !Number.isFinite(tier.monthlyPrice))) { + err(`${t}: monthlyPrice must be a non-negative number or null`); + } + if (tier.annualPrice !== undefined && tier.annualPrice !== null) { + if (typeof tier.annualPrice !== 'number' || tier.annualPrice <= 0) { + err(`${t}: annualPrice must be a positive number or null`); + } else if (typeof tier.monthlyPrice === 'number' && tier.monthlyPrice > 0 && tier.annualPrice / 12 >= tier.monthlyPrice) { + err(`${t}: annualPrice/12 (${(tier.annualPrice / 12).toFixed(2)}) must be lower than monthlyPrice ${tier.monthlyPrice}`); + } + } + if (!isPlainObject(tier.limits) || Object.keys(tier.limits).length === 0) err(`${t}: limits must be a non-empty object`); + if (!Array.isArray(tier.models) || tier.models.length === 0) err(`${t}: models must be a non-empty array`); + if (Array.isArray(tier.models)) { + const dupes = tier.models.filter((m, i) => tier.models.indexOf(m) !== i); + if (dupes.length > 0) err(`${t}: duplicate model entries: ${[...new Set(dupes)].join(', ')}`); + } + + const tb = tier.estimatedTokenBudget; + if (!isPlainObject(tb)) { + err(`${t}: estimatedTokenBudget must be an object (never null)`); + continue; + } + if (typeof tb.description !== 'string' || tb.description.trim() === '') err(`${t}: estimatedTokenBudget.description required`); + if (typeof tb.assumptions !== 'string' || tb.assumptions.trim() === '') err(`${t}: estimatedTokenBudget.assumptions required`); + if (typeof tb.estimatedMillionTokens !== 'number' || !Number.isFinite(tb.estimatedMillionTokens)) { + err(`${t}: estimatedTokenBudget.estimatedMillionTokens must be a number`); + continue; + } + const paid = typeof tier.monthlyPrice === 'number' && tier.monthlyPrice > 0; + if (paid && tb.estimatedMillionTokens <= 0) { + err(`${t}: paid tier ($${tier.monthlyPrice}) must have estimatedMillionTokens > 0 (stacking drops zero budgets)`); + } + if (tb.estimatedMillionTokens < 0) err(`${t}: estimatedMillionTokens must not be negative`); + if (tb.midpointEstimate !== undefined && tb.midpointEstimate !== null) { + if (typeof tb.midpointEstimate !== 'number' || tb.midpointEstimate < tb.estimatedMillionTokens) { + err(`${t}: midpointEstimate must be a number >= estimatedMillionTokens`); + } + } + if (tb.optimisticEstimate !== undefined && tb.optimisticEstimate !== null) { + const floor = tb.midpointEstimate ?? tb.estimatedMillionTokens; + if (typeof tb.optimisticEstimate !== 'number' || tb.optimisticEstimate < floor) { + err(`${t}: optimisticEstimate must be a number >= midpoint/conservative estimate`); + } + } + } +} + +async function main() { + let files = []; + try { + files = (await fs.readdir(PLANS_DIR)).filter(f => f.endsWith('.json') && !f.startsWith('_')).sort(); + } catch (e) { + console.error(`Cannot read ${PLANS_DIR}: ${e.message}`); + process.exit(1); + } + + if (files.length === 0) { + console.error('No plan files found.'); + process.exit(1); + } + + const plans = new Map(); + for (const file of files) { + const raw = await fs.readFile(path.join(PLANS_DIR, file), 'utf-8'); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (e) { + err(`${file}: invalid JSON: ${e.message}`); + continue; + } + validatePlan(parsed, file); + plans.set(parsed.id, parsed); + } + + // Cross-check the consolidated artifact the frontend consumes. + let consolidated = null; + try { + consolidated = JSON.parse(await fs.readFile(PUBLIC_PLANS, 'utf-8')); + } catch (e) { + err(`public/data/plans.json: cannot read: ${e.message}`); + } + if (Array.isArray(consolidated)) { + const consolidatedIds = new Set(consolidated.map(p => p.id)); + for (const id of plans.keys()) { + if (!consolidatedIds.has(id)) err(`public/data/plans.json: missing plan "${id}" (stale build artifact?)`); + } + for (const p of consolidated) { + if (!plans.has(p.id)) { + err(`public/data/plans.json: contains unknown plan "${p.id}"`); + continue; + } + const source = plans.get(p.id); + if (JSON.stringify(source) !== JSON.stringify(p)) { + err(`public/data/plans.json: "${p.id}" differs from data/coding-plans/${p.id}.json — run npm run build-data`); + } + } + } + + if (errors.length > 0) { + console.error(`\nValidation FAILED with ${errors.length} error(s):`); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); + } + console.log(`\nValidated ${plans.size} plan files against schema invariants. OK`); +} + +main().catch(e => { + console.error(e); + process.exit(1); +});