Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]
### 修复
- **单组件根围栏被静默拒绝(JSON 有效却永远保持代码块)**:`parsePartialGenuiSpec` / `repairGenuiSpec` / `validateGenuiSpec` 三处入口都强制根节点必须带 `items` 数组——而注入的围栏词汇表把单组件(`{"type":"callout",…}` 直接作根)列为合法写法 → 这类围栏 JSON 完全有效、渲染器却拒绝:DOM 通道报一次「does not parse」后保持代码块(控制台有告警,页面无效果)。修复:新增 `wrapSingleComponentRoot`(spec.ts),单组件根自动包裹为 `col`(`panel`/`append` 提升到包裹层,面板路由不受影响),解析/修复/校验三条路径统一归一化,渲染器与 `validate_dsh_ui` 工具行为一致
### 测试
- 370 → 380(+10:genui-guard +7(单组件根包裹/panel-append 提升/非组件拒绝/幂等/校验通过/parseGenuiSpec 包裹/垃圾拒绝)、genui-partial +3(单组件根包裹/panel-append 提升/非组件拒绝));本地环境其余失败均为宿主源码树依赖(install-script chmod / skill-md yaml 版本),与本次变更无关

## [0.8.5] - 2026-08-16
### 发布
- **发布规范对齐 `plugin_check`(issue #15)**:
Expand Down
6 changes: 3 additions & 3 deletions lib/client.js

Large diffs are not rendered by default.

34 changes: 30 additions & 4 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
//#region src/client/spec.ts
/**
* Wrap a bare component object into a col root. Returns null when `value` is
* not component-shaped (no usable `type`). `panel`/`append` live on the root
* spec, so they are hoisted onto the wrapper.
*/
function wrapSingleComponentRoot(value) {
if (typeof value !== "object" || value === null) return null;
const v = value;
if (typeof v.type !== "string" || v.type === "") return null;
const root = {
type: "col",
items: [value]
};
if (v.panel === true) root.panel = true;
if (v.append === true) root.append = true;
return root;
}
//#endregion
//#region src/client/guard.ts
/** Hard resource limits enforced by repair (and mirrored at render time). */
const GENUI_LIMITS = {
Expand Down Expand Up @@ -813,13 +832,20 @@ function repairQuizOptions(v) {
}
/**
* Deterministically repair a raw spec value into a renderable GenuiSpec.
* Returns null only when the root is not an object with an `items` array;
* every other defect is healed by dropping/clamping/truncating. Idempotent:
* repairing a repaired spec is a no-op.
* Returns null only when the root is not an object with an `items` array
* (a bare component root is wrapped into a col first — the documented fence
* vocabulary allows single-component bodies); every other defect is healed by
* dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
* no-op.
*/
function repairGenuiSpec(value) {
const v = obj(value);
if (v === void 0 || !Array.isArray(v.items)) return null;
if (v === void 0) return null;
if (!Array.isArray(v.items)) {
const wrapped = wrapSingleComponentRoot(value);
if (wrapped === null) return null;
return repairGenuiSpec(wrapped);
}
const ctx = { remaining: GENUI_LIMITS.maxNodes };
return {
...opt("title", str(v.title, GENUI_LIMITS.maxString)),
Expand Down
8 changes: 5 additions & 3 deletions lib/types/client/guard.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ export interface GenuiValidation {
}
/**
* Deterministically repair a raw spec value into a renderable GenuiSpec.
* Returns null only when the root is not an object with an `items` array;
* every other defect is healed by dropping/clamping/truncating. Idempotent:
* repairing a repaired spec is a no-op.
* Returns null only when the root is not an object with an `items` array
* (a bare component root is wrapped into a col first — the documented fence
* vocabulary allows single-component bodies); every other defect is healed by
* dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
* no-op.
*/
export declare function repairGenuiSpec(value: unknown): GenuiSpec | null;
/**
Expand Down
6 changes: 6 additions & 0 deletions lib/types/client/spec.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,5 +457,11 @@ export interface GenuiQuiz {
}
/** Parse the raw fence body as a GenuiSpec, or null when it is not one. */
export declare function parseGenuiSpec(raw: string): GenuiSpec | null;
/**
* Wrap a bare component object into a col root. Returns null when `value` is
* not component-shaped (no usable `type`). `panel`/`append` live on the root
* spec, so they are hoisted onto the wrapper.
*/
export declare function wrapSingleComponentRoot(value: unknown): GenuiSpec | null;
/** Basic structural guard: is this object a valid GenuiSpec? */
export declare function isGenuiSpec(value: unknown): value is GenuiSpec;
24 changes: 19 additions & 5 deletions src/client/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* are elided.
*/
import type { GenuiFileTreeNode, GenuiList, GenuiNode, GenuiPlot, GenuiPlotSeries, GenuiScene3D, GenuiSpec } from './spec.ts'
import { wrapSingleComponentRoot } from './spec.ts'

/** Hard resource limits enforced by repair (and mirrored at render time). */
export const GENUI_LIMITS = {
Expand Down Expand Up @@ -708,13 +709,20 @@ function repairQuizOptions(v: unknown): Array<{ label: string; correct?: boolean

/**
* Deterministically repair a raw spec value into a renderable GenuiSpec.
* Returns null only when the root is not an object with an `items` array;
* every other defect is healed by dropping/clamping/truncating. Idempotent:
* repairing a repaired spec is a no-op.
* Returns null only when the root is not an object with an `items` array
* (a bare component root is wrapped into a col first — the documented fence
* vocabulary allows single-component bodies); every other defect is healed by
* dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
* no-op.
*/
export function repairGenuiSpec(value: unknown): GenuiSpec | null {
const v = obj(value)
if (v === undefined || !Array.isArray(v.items)) return null
if (v === undefined) return null
if (!Array.isArray(v.items)) {
const wrapped = wrapSingleComponentRoot(value)
if (wrapped === null) return null
return repairGenuiSpec(wrapped)
}
const ctx: RepairCtx = { remaining: GENUI_LIMITS.maxNodes }
return {
...opt('title', str(v.title, GENUI_LIMITS.maxString)),
Expand Down Expand Up @@ -777,7 +785,13 @@ export function validateGenuiSpec(value: unknown): GenuiValidation {
const errors: string[] = []
const v = obj(value)
if (v === undefined) return { ok: false, errors: ['spec root must be an object'] }
if (!Array.isArray(v.items)) return { ok: false, errors: ['spec.items must be an array'] }
if (!Array.isArray(v.items)) {
// Single-component root: validate through the wrapped form so the tool
// agrees with the renderer about what is a valid fence body.
const wrapped = wrapSingleComponentRoot(value)
if (wrapped !== null) return validateGenuiSpec(wrapped)
return { ok: false, errors: ['spec.items must be an array'] }
}
if (v.title !== undefined && typeof v.title !== 'string') errors.push('spec.title must be a string')
if (v.gap !== undefined && (typeof v.gap !== 'number' || !Number.isFinite(v.gap))) errors.push('spec.gap must be a finite number')
let count = 0
Expand Down
7 changes: 6 additions & 1 deletion src/client/parse-partial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
*/
import { GENUI_LIMITS } from './guard.ts'
import { isGenuiSpec, type GenuiSpec } from './spec.ts'
import { wrapSingleComponentRoot } from './spec.ts'

/** Default repair-candidate budget (adjustable; see the design doc). */
export const MAX_PARTIAL_REPAIR_ATTEMPTS = 32
Expand Down Expand Up @@ -124,7 +125,11 @@ function closeSuffix(stack: string[]): string {
function trySpec(candidate: string): GenuiSpec | null {
try {
const value: unknown = JSON.parse(candidate)
return isGenuiSpec(value) ? value : null
if (isGenuiSpec(value)) return value
// Single-component roots are part of the documented fence vocabulary
// (e.g. a bare {"type":"callout",…} body) — wrap into a col so the
// items-gated pipeline renders them (panel/append hoisted).
return wrapSingleComponentRoot(value)
} catch {
return null
}
Expand Down
28 changes: 27 additions & 1 deletion src/client/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,33 @@ export function parseGenuiSpec(raw: string): GenuiSpec | null {
} catch {
return null
}
return isGenuiSpec(value) ? value : null
if (isGenuiSpec(value)) return value
// Single-component roots are part of the documented fence vocabulary
// (e.g. {"type":"callout","tone":"info","title":"…","content":"…"} as the
// whole body) — wrap them into a col so the items-gated pipeline renders
// them. panel/append hoist onto the wrapper so panel routing keeps working.
return wrapSingleComponentRoot(value)
}

/**
* Wrap a bare component object into a col root. Returns null when `value` is
* not component-shaped (no usable `type`). `panel`/`append` live on the root
* spec, so they are hoisted onto the wrapper.
*/
export function wrapSingleComponentRoot(value: unknown): GenuiSpec | null {
if (typeof value !== 'object' || value === null) return null
const v = value as { type?: unknown; panel?: unknown; append?: unknown }
if (typeof v.type !== 'string' || v.type === '') return null
// GenuiCol is structurally a GenuiSpec (items + optional gap); panel and
// append are GenuiSpec-only root flags, added after construction.
const wrapped: GenuiCol = {
type: 'col',
items: [value as GenuiNode],
}
const root: GenuiSpec = wrapped
if (v.panel === true) root.panel = true
if (v.append === true) root.append = true
return root
}

/** Basic structural guard: is this object a valid GenuiSpec? */
Expand Down
52 changes: 51 additions & 1 deletion tests/genui-guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// `repairGenuiSpec` before rendering, so these invariants protect the UI.
import { describe, expect, it } from 'vitest'
import { GENUI_LIMITS, repairGenuiSpec, validateGenuiSpec } from '../src/client/guard.ts'
import { isGenuiSpec } from '../src/client/spec.ts'
import { isGenuiSpec, parseGenuiSpec } from '../src/client/spec.ts'

const text = (content: string) => ({ type: 'text', content })

Expand Down Expand Up @@ -43,6 +43,56 @@ describe('repairGenuiSpec: root shape', () => {
})
})

describe('repairGenuiSpec: single-component roots', () => {
it('wraps a bare component root into a col (documented fence vocabulary)', () => {
const spec = repairGenuiSpec({ type: 'callout', tone: 'info', title: '核心观察', content: '你好' })
expect(spec).not.toBeNull()
// The repaired GenuiSpec carries no `type` (root spec field set) — the
// observable wrap effect is the items array holding the bare component.
expect(spec?.items).toHaveLength(1)
expect((spec?.items[0] as { type: string }).type).toBe('callout')
expect(isGenuiSpec(spec)).toBe(true)
})

it('hoists panel/append from the bare component onto the wrapper', () => {
const spec = repairGenuiSpec({ type: 'text', content: 'x', panel: true, append: true })
expect(spec?.panel).toBe(true)
expect(spec?.append).toBe(true)
const inner = spec?.items[0] as { panel?: unknown; append?: unknown }
expect(inner.panel).toBeUndefined()
expect(inner.append).toBeUndefined()
})

it('still rejects non-component objects without an items array', () => {
expect(repairGenuiSpec({ title: 'x' })).toBeNull()
expect(repairGenuiSpec({ foo: 1 })).toBeNull()
})

it('idempotent: a wrapped single root repairs to itself', () => {
const once = repairGenuiSpec({ type: 'stat', label: 'L', value: '1' })
const twice = repairGenuiSpec(once)
expect(twice).toEqual(once)
})
})

describe('validateGenuiSpec / parseGenuiSpec: single-component roots', () => {
it('accepts a bare component as valid', () => {
const result = validateGenuiSpec({ type: 'callout', tone: 'info', title: 'T', content: 'c' })
expect(result.ok).toBe(true)
})

it('parseGenuiSpec wraps a single-component fence body', () => {
const spec = parseGenuiSpec(JSON.stringify({ type: 'keyvalue', pairs: [{ key: 'a', value: 'b' }] }))
expect(spec?.type).toBe('col')
expect((spec?.items[0] as { type: string }).type).toBe('keyvalue')
})

it('parseGenuiSpec still rejects non-component junk', () => {
expect(parseGenuiSpec('{"foo":1}')).toBeNull()
expect(parseGenuiSpec('not json')).toBeNull()
})
})

describe('repairGenuiSpec: node-level healing', () => {
it('drops nodes with missing required fields', () => {
const spec = repairGenuiSpec({ items: [
Expand Down
20 changes: 20 additions & 0 deletions tests/genui-partial.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ describe('parsePartialGenuiSpec', () => {
expect(spec?.items).toHaveLength(1)
})

it('wraps a single-component root into a col (documented fence vocabulary)', () => {
// Regression: bare component bodies (no root `items`) were rejected —
// the DOM channel kept the code block with a parse-failure warning.
const spec = parsePartialGenuiSpec('{"type":"callout","tone":"info","title":"T","content":"c"}')
expect(spec).not.toBeNull()
expect(spec!.items).toHaveLength(1)
expect((spec!.items[0] as { type: string }).type).toBe('callout')
})

it('hoists panel/append from a single-component root onto the wrapper', () => {
const spec = parsePartialGenuiSpec('{"type":"text","content":"x","panel":true,"append":true}')
expect(spec?.panel).toBe(true)
expect(spec?.append).toBe(true)
})

it('still rejects non-component roots without an items array', () => {
expect(parsePartialGenuiSpec('{"title":"x"}')).toBeNull()
expect(parsePartialGenuiSpec('{"foo":1}')).toBeNull()
})

it('extracts finished components while the array is still growing', () => {
// 第 1 个元素完成,第 2 个未写完 —— 应只返回第 1 个
const spec = parsePartialGenuiSpec('{"items":[{"type":"text","content":"A"},{"type":"stat","labe')
Expand Down