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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 02:33:05 +05:30
co-authored by Claude Opus 5
parent 87a00de5f3
commit 953b1cd692
6 changed files with 90 additions and 28 deletions
+7 -3
View File
@@ -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,
});
}
+10 -6
View File
@@ -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<string>();
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,
+44
View File
@@ -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 { <main>x</main> }
}
`;
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 { <main>x</main> }
}
`;
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("");
});