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:
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// 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
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -4798,7 +4798,6 @@ function parseApiSections(source) {
|
|||||||
function hasRequestSection(source) {
|
function hasRequestSection(source) {
|
||||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
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
|
* Parse the body of a page-level `apis { }` container: a sequence of
|
||||||
* `<name> <METHOD> <path> { ... }` entries with no leading `api` keyword
|
* `<name> <METHOD> <path> { ... }` entries with no leading `api` keyword
|
||||||
@@ -4820,13 +4819,17 @@ function parseApiEntries(source) {
|
|||||||
}
|
}
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const entryBody = lx.readBalancedBraces();
|
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({
|
entries.push({
|
||||||
mode: "any",
|
mode: "any",
|
||||||
name: nameToken.value,
|
name: nameToken.value,
|
||||||
method: methodToken.value.toUpperCase(),
|
method: methodToken.value.toUpperCase(),
|
||||||
path,
|
path,
|
||||||
body: "",
|
body: "",
|
||||||
sections: parseApiSections(entryBody) ?? emptySections,
|
sections,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
@@ -6793,12 +6796,7 @@ function parse(source) {
|
|||||||
case "apis": {
|
case "apis": {
|
||||||
lx.next();
|
lx.next();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
for (const entry of (0, api_sections_ts_1.parseApiEntries)(body)) {
|
dataApis.push(...(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);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -6872,6 +6870,13 @@ function parse(source) {
|
|||||||
};
|
};
|
||||||
for (const name of namedLoads.keys())
|
for (const name of namedLoads.keys())
|
||||||
visitLoad(name);
|
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 {
|
return {
|
||||||
type: "page",
|
type: "page",
|
||||||
imports,
|
imports,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: e37604bc3978c372d7b76ee019cb883a5c284877a9be16e8740d7e1bddfde5c4
|
// WRN editor extension source hash: eca15cff8b9c2ae1842cf584ab58b70c1d23327a30dd7aa36e9d0c50f1fe2ba9
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||||
// @bun @bun-cjs
|
// @bun @bun-cjs
|
||||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||||
@@ -170869,7 +170869,6 @@ function parseApiSections(source) {
|
|||||||
function hasRequestSection(source) {
|
function hasRequestSection(source) {
|
||||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||||
}
|
}
|
||||||
var emptySections = { parameters: [], body: [], response: "", error: "" };
|
|
||||||
function parseApiEntries(source) {
|
function parseApiEntries(source) {
|
||||||
const entries = [];
|
const entries = [];
|
||||||
const lx = new Lexer(source);
|
const lx = new Lexer(source);
|
||||||
@@ -170884,13 +170883,17 @@ function parseApiEntries(source) {
|
|||||||
}
|
}
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const entryBody = lx.readBalancedBraces();
|
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({
|
entries.push({
|
||||||
mode: "any",
|
mode: "any",
|
||||||
name: nameToken.value,
|
name: nameToken.value,
|
||||||
method: methodToken.value.toUpperCase(),
|
method: methodToken.value.toUpperCase(),
|
||||||
path,
|
path,
|
||||||
body: "",
|
body: "",
|
||||||
sections: parseApiSections(entryBody) ?? emptySections
|
sections
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
@@ -171910,12 +171913,7 @@ function parse(source) {
|
|||||||
case "apis": {
|
case "apis": {
|
||||||
lx.next();
|
lx.next();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
for (const entry of parseApiEntries(body)) {
|
dataApis.push(...parseApiEntries(body));
|
||||||
if (dataApis.some((block) => block.name === entry.name)) {
|
|
||||||
throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`);
|
|
||||||
}
|
|
||||||
dataApis.push(entry);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -171987,6 +171985,13 @@ function parse(source) {
|
|||||||
};
|
};
|
||||||
for (const name2 of namedLoads.keys())
|
for (const name2 of namedLoads.keys())
|
||||||
visitLoad(name2);
|
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 {
|
return {
|
||||||
type: "page",
|
type: "page",
|
||||||
imports,
|
imports,
|
||||||
|
|||||||
@@ -172,8 +172,6 @@ export function hasRequestSection(source: string): boolean {
|
|||||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptySections: ApiSections = { parameters: [], body: [], response: "", error: "" };
|
|
||||||
|
|
||||||
/** Shape of an entry parsed from an `apis { }` container body. */
|
/** Shape of an entry parsed from an `apis { }` container body. */
|
||||||
export interface ApiEntry {
|
export interface ApiEntry {
|
||||||
mode: "any";
|
mode: "any";
|
||||||
@@ -206,6 +204,12 @@ export function parseApiEntries(source: string): ApiEntry[] {
|
|||||||
}
|
}
|
||||||
const path = lx.readPath();
|
const path = lx.readPath();
|
||||||
const entryBody = lx.readBalancedBraces();
|
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({
|
entries.push({
|
||||||
mode: "any",
|
mode: "any",
|
||||||
@@ -213,7 +217,7 @@ export function parseApiEntries(source: string): ApiEntry[] {
|
|||||||
method: methodToken.value.toUpperCase(),
|
method: methodToken.value.toUpperCase(),
|
||||||
path,
|
path,
|
||||||
body: "",
|
body: "",
|
||||||
sections: parseApiSections(entryBody) ?? emptySections,
|
sections,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -853,12 +853,7 @@ export function parse(source: string): PageAst {
|
|||||||
case "apis": {
|
case "apis": {
|
||||||
lx.next();
|
lx.next();
|
||||||
const body = lx.readBalancedBraces();
|
const body = lx.readBalancedBraces();
|
||||||
for (const entry of parseApiEntries(body)) {
|
dataApis.push(...parseApiEntries(body));
|
||||||
if (dataApis.some((block) => block.name === entry.name)) {
|
|
||||||
throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`);
|
|
||||||
}
|
|
||||||
dataApis.push(entry);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -951,6 +946,15 @@ export function parse(source: string): PageAst {
|
|||||||
visited.add(name);
|
visited.add(name);
|
||||||
};
|
};
|
||||||
for (const name of namedLoads.keys()) visitLoad(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 {
|
return {
|
||||||
type: "page",
|
type: "page",
|
||||||
imports,
|
imports,
|
||||||
|
|||||||
@@ -69,3 +69,47 @@ test("duplicate names inside one container are rejected", () => {
|
|||||||
),
|
),
|
||||||
).toThrow(/duplicate/i);
|
).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("");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user