fix(pen-core): reject invalid level in buildHeading with a clear error
ab-v4 partial sweep (2026-05-01) caught gpt-5.4 emitting
`add_heading_v0({"content":"Pending invitations","level":"caption"})`
as the 12th tag of a 13-tag composite multi-tool response. Even
though the MCP tool def has `enum: ['display','h1','h2','h3']`, the
ab-corpus harness and the in-process production dispatcher both call
buildHeading() with raw JSON args (no jsonschema gate), so the model's
invented "caption" reached the preset lookup. LATIN_PRESETS["caption"]
is undefined, and the next line `fontSize: preset.fontSize` crashed
the WHOLE batch with `undefined is not an object (evaluating
'preset.fontSize')` — the 11 valid tags ahead of it never landed.
Adds an entry-point validation in buildHeading: if `level` is set and
not in the {display, h1, h2, h3} set, throw with a clear message.
The dispatch loops in apps/web/element-tools-dispatcher and
scripts/ab-corpus/apply both catch per-shape and keep running the
remaining tags, so a single bad level on tag 12 no longer kills tags
1-11 + 13.
3 new edge-case tests in element-builders-edge-cases.test.ts
cover the throw + the four valid levels + the omitted-default case.
3772 vitest pass (+3), format clean, tsc silent.
Other element builders likely have the same pattern (preset lookup
on a string enum without runtime validation) — separate sweep, not
shotgunning here.
This commit is contained in:
parent
bfff97a42a
commit
39aeb0c30e
|
|
@ -147,3 +147,28 @@ describe('calendar_grid boundary cases', () => {
|
|||
expect(hasTodayOrSelected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeading — invalid level rejection', () => {
|
||||
// ab-v4 (2026-05-01) caught gpt-5.4 inventing `level: "caption"` on a
|
||||
// composite multi-tool sub-task. The preset lookup silently returned
|
||||
// undefined and the next line crashed with the cryptic message
|
||||
// `undefined is not an object (evaluating 'preset.fontSize')`,
|
||||
// failing the WHOLE composite apply mid-batch. Builder now rejects
|
||||
// unknown levels at the entry boundary so the surrounding dispatch
|
||||
// loop captures a clear per-shape error and keeps applying the rest.
|
||||
it('throws with a helpful message when level is not a known enum value', () => {
|
||||
expect(() =>
|
||||
buildHeading({ content: 'Pending invitations', level: 'caption' as never }),
|
||||
).toThrow(/invalid level "caption"; expected one of: display, h1, h2, h3/);
|
||||
});
|
||||
|
||||
it('does not throw for any of the four valid levels', () => {
|
||||
for (const level of ['display', 'h1', 'h2', 'h3'] as const) {
|
||||
expect(() => buildHeading({ content: 'ok', level })).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not throw when level is omitted (defaults to h2)', () => {
|
||||
expect(() => buildHeading({ content: 'no level' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@ import type { ElementTree } from './helpers.js';
|
|||
|
||||
export type HeadingLevel = 'display' | 'h1' | 'h2' | 'h3';
|
||||
|
||||
const VALID_HEADING_LEVELS = new Set<string>(['display', 'h1', 'h2', 'h3']);
|
||||
|
||||
export interface HeadingParams {
|
||||
content: string;
|
||||
/** TS compile-time enum. Runtime callers (MCP / harness raw JSON)
|
||||
* may pass any string; an unknown value throws with a helpful
|
||||
* message rather than crashing inside the preset lookup. */
|
||||
level?: HeadingLevel;
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +45,20 @@ const CJK_BASE: Record<HeadingLevel, Omit<HeadingPreset, 'fontFamily'>> = {
|
|||
* no "应拆尽拆" violation (enum controls typography, not structure).
|
||||
*/
|
||||
export function buildHeading(params: HeadingParams): ElementTree {
|
||||
const level = params.level ?? 'h2';
|
||||
const requestedLevel = (params.level ?? 'h2') as string;
|
||||
// ab-v4 caught gpt-5.4 inventing `level: "caption"` on a composite
|
||||
// multi-tool sub-task — the preset lookup returned undefined and the
|
||||
// builder crashed mid-batch with a cryptic
|
||||
// `undefined is not an object (evaluating 'preset.fontSize')`.
|
||||
// Reject the value at the entry boundary so the caller (apply.ts /
|
||||
// dispatch loop) records a clear "invalid level" error per shape
|
||||
// and the surrounding history batch still commits the rest.
|
||||
if (!VALID_HEADING_LEVELS.has(requestedLevel)) {
|
||||
throw new Error(
|
||||
`add_heading_v0: invalid level "${requestedLevel}"; expected one of: display, h1, h2, h3`,
|
||||
);
|
||||
}
|
||||
const level = requestedLevel as HeadingLevel;
|
||||
const script = detectCjkScript(params.content);
|
||||
const cjkFont = cjkFontFamily(script);
|
||||
const preset: HeadingPreset = cjkFont
|
||||
|
|
|
|||
Loading…
Reference in a new issue