From 953b1cd692fd9806f4284bcb85292903c06f2220 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 02:33:05 +0530 Subject: [PATCH] fix(syntax): reject bare apis-container bodies and cross-mode duplicate api names Bare bodies inside apis {} silently discarded their text with no error, producing a do-nothing block. They now throw a ParseError naming the entry and pointing at the response {} section. Duplicate-name detection for dataApis moved from an incremental, order-dependent check (only saw prior entries in the array) to a single post-parse pass over the whole ast.dataApis, so it catches cross-mode duplicates (apis {} vs ssr { api }) regardless of declaration order. Co-Authored-By: Claude Opus 5 --- editors/vscode/src/compiler.cjs | 23 ++++++++----- editors/vscode/src/extension.bundle.cjs | 2 +- editors/vscode/src/language-server.cjs | 23 ++++++++----- packages/syntax/src/api-sections.ts | 10 ++++-- packages/syntax/src/parser.ts | 16 +++++---- packages/syntax/test/apis-block.test.ts | 44 +++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 28 deletions(-) diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 3bfd42e9..6dc89204 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: ed63852e856493749abcfd8160c27519e4b7e0d38e93e2b5cf64e94e29bf581f +// WRN editor compiler source hash: d23177f63433ab92bc7d40d5d5967ed8eae783843224be091cc48be13747df6e // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -4798,7 +4798,6 @@ function parseApiSections(source) { function hasRequestSection(source) { return scanTopLevelBlocks(source, SECTION_NAMES).has("request"); } -const emptySections = { parameters: [], body: [], response: "", error: "" }; /** * Parse the body of a page-level `apis { }` container: a sequence of * ` { ... }` entries with no leading `api` keyword @@ -4820,13 +4819,17 @@ function parseApiEntries(source) { } const path = lx.readPath(); const entryBody = lx.readBalancedBraces(); + const sections = parseApiSections(entryBody); + if (sections === null) { + throw new tokenizer_ts_1.LexError(`Api entry "${nameToken.value}" has a bare body; declare a "response { }" section instead`); + } entries.push({ mode: "any", name: nameToken.value, method: methodToken.value.toUpperCase(), path, body: "", - sections: parseApiSections(entryBody) ?? emptySections, + sections, }); } return entries; @@ -6793,12 +6796,7 @@ function parse(source) { case "apis": { lx.next(); const body = lx.readBalancedBraces(); - for (const entry of (0, api_sections_ts_1.parseApiEntries)(body)) { - if (dataApis.some((block) => block.name === entry.name)) { - throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`); - } - dataApis.push(entry); - } + dataApis.push(...(0, api_sections_ts_1.parseApiEntries)(body)); break; } default: @@ -6872,6 +6870,13 @@ function parse(source) { }; for (const name of namedLoads.keys()) visitLoad(name); + const seenApiNames = new Set(); + for (const block of dataApis) { + if (seenApiNames.has(block.name)) { + throw new ParseError(`Duplicate api entry "${block.name}"`, "WRN-API-DUPLICATE"); + } + seenApiNames.add(block.name); + } return { type: "page", imports, diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 10e361fb..b2fac7fa 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: e37604bc3978c372d7b76ee019cb883a5c284877a9be16e8740d7e1bddfde5c4 +// WRN editor extension source hash: eca15cff8b9c2ae1842cf584ab58b70c1d23327a30dd7aa36e9d0c50f1fe2ba9 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 4e175a19..15f3d320 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// WRN editor language server source hash: a5d091c01e106fd98dba6ef8bda030e821bf1f04ebeb63a6d12cc2d0783e93e2 +// WRN editor language server source hash: 7084da58f35fcb02c387b392d78660fa3b59f2060c765c3e982dd1918edab78a // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; @@ -170869,7 +170869,6 @@ function parseApiSections(source) { function hasRequestSection(source) { return scanTopLevelBlocks(source, SECTION_NAMES).has("request"); } -var emptySections = { parameters: [], body: [], response: "", error: "" }; function parseApiEntries(source) { const entries = []; const lx = new Lexer(source); @@ -170884,13 +170883,17 @@ function parseApiEntries(source) { } const path = lx.readPath(); const entryBody = lx.readBalancedBraces(); + const sections = parseApiSections(entryBody); + if (sections === null) { + throw new LexError(`Api entry "${nameToken.value}" has a bare body; declare a "response { }" section instead`); + } entries.push({ mode: "any", name: nameToken.value, method: methodToken.value.toUpperCase(), path, body: "", - sections: parseApiSections(entryBody) ?? emptySections + sections }); } return entries; @@ -171910,12 +171913,7 @@ function parse(source) { case "apis": { lx.next(); const body = lx.readBalancedBraces(); - for (const entry of parseApiEntries(body)) { - if (dataApis.some((block) => block.name === entry.name)) { - throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`); - } - dataApis.push(entry); - } + dataApis.push(...parseApiEntries(body)); break; } default: @@ -171987,6 +171985,13 @@ function parse(source) { }; for (const name2 of namedLoads.keys()) visitLoad(name2); + const seenApiNames = new Set; + for (const block of dataApis) { + if (seenApiNames.has(block.name)) { + throw new ParseError(`Duplicate api entry "${block.name}"`, "WRN-API-DUPLICATE"); + } + seenApiNames.add(block.name); + } return { type: "page", imports, diff --git a/packages/syntax/src/api-sections.ts b/packages/syntax/src/api-sections.ts index 61396e9d..b9cf2c53 100644 --- a/packages/syntax/src/api-sections.ts +++ b/packages/syntax/src/api-sections.ts @@ -172,8 +172,6 @@ export function hasRequestSection(source: string): boolean { return scanTopLevelBlocks(source, SECTION_NAMES).has("request"); } -const emptySections: ApiSections = { parameters: [], body: [], response: "", error: "" }; - /** Shape of an entry parsed from an `apis { }` container body. */ export interface ApiEntry { mode: "any"; @@ -206,6 +204,12 @@ export function parseApiEntries(source: string): ApiEntry[] { } const path = lx.readPath(); const entryBody = lx.readBalancedBraces(); + const sections = parseApiSections(entryBody); + if (sections === null) { + throw new LexError( + `Api entry "${nameToken.value}" has a bare body; declare a "response { }" section instead`, + ); + } entries.push({ mode: "any", @@ -213,7 +217,7 @@ export function parseApiEntries(source: string): ApiEntry[] { method: methodToken.value.toUpperCase(), path, body: "", - sections: parseApiSections(entryBody) ?? emptySections, + sections, }); } diff --git a/packages/syntax/src/parser.ts b/packages/syntax/src/parser.ts index 40662b19..bb5c29bd 100644 --- a/packages/syntax/src/parser.ts +++ b/packages/syntax/src/parser.ts @@ -853,12 +853,7 @@ export function parse(source: string): PageAst { case "apis": { lx.next(); const body = lx.readBalancedBraces(); - for (const entry of parseApiEntries(body)) { - if (dataApis.some((block) => block.name === entry.name)) { - throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`); - } - dataApis.push(entry); - } + dataApis.push(...parseApiEntries(body)); break; } default: @@ -951,6 +946,15 @@ export function parse(source: string): PageAst { visited.add(name); }; for (const name of namedLoads.keys()) visitLoad(name); + + const seenApiNames = new Set(); + for (const block of dataApis) { + if (seenApiNames.has(block.name)) { + throw new ParseError(`Duplicate api entry "${block.name}"`, "WRN-API-DUPLICATE"); + } + seenApiNames.add(block.name); + } + return { type: "page", imports, diff --git a/packages/syntax/test/apis-block.test.ts b/packages/syntax/test/apis-block.test.ts index c48eb35d..2754a03e 100644 --- a/packages/syntax/test/apis-block.test.ts +++ b/packages/syntax/test/apis-block.test.ts @@ -69,3 +69,47 @@ test("duplicate names inside one container are rejected", () => { ), ).toThrow(/duplicate/i); }); + +test("a bare body inside apis {} is a parse error naming response", () => { + expect(() => parse(page(` bare GET /api/z { return data }`))).toThrow(/response/i); +}); + +test("apis entry followed by an ssr api of the same name is rejected", () => { + const src = `page Repro { + apis { + foo GET /api/foo { response { return data } } + } + + ssr { + api foo GET /api/foo { return data } + } + + view {
x
} +} +`; + expect(() => parse(src)).toThrow(/duplicate/i); +}); + +test("ssr api followed by an apis entry of the same name is rejected", () => { + const src = `page Repro { + ssr { + api foo GET /api/foo { return data } + } + + apis { + foo GET /api/foo { response { return data } } + } + + view {
x
} +} +`; + expect(() => parse(src)).toThrow(/duplicate/i); +}); + +test("an explicit empty response section is accepted, unlike a bare body", () => { + const ast = parse(page(` empty GET /api/e { response { } }`)); + + const block = ast.dataApis[0]!; + expect(block.name).toBe("empty"); + expect(block.sections?.response.trim()).toBe(""); +});