mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(web): reject prose-as-HTML artifacts at write time (#50) AI 偶尔会在仅做 in-place 编辑(无新 canonical HTML 产出)时仍按 system prompt 的非协商性收尾规则发出 `<artifact type="text/html">` 块,但块内只装一句中文总结。`persistArtifact` 之前不做内容校验, 此类 prose 会作为合法 HTML 落盘到 `.od/projects/<id>/<id>.html`, 并附带 `kind: html` manifest,污染项目文件面板(截图见 #50 评论)。 新增 `validateHtmlArtifact` 纯函数:要求非空 + 长度 ≥64 + 含 `<!doctype html>` 或 `<html>` 标签(大小写不敏感、容忍 BOM)。 `persistArtifact` 在 `ext === '.html'` 分支调用 gate,失败时 通过 `setError` 报错且不写文件。 scope 限于 `<artifact>`-tag 持久化路径——FileViewer/FileWorkspace 里用户手动保存草稿 HTML 走的是不同入口,不受影响。 prompt 层根因(缺少免发条款)已拆出 #1143 单独跟进,本 PR 是 持久化层的兜底防御。 * fix(web): anchor HTML structural check at content start (#1144 review) mrcfps 在 #1144 review 指出原实现的 false negative: HTML_OPENING_TAG_RE / DOCTYPE_RE 用 .test() 在整个字符串里搜, 所以 AI 描述改动时 inline 引一个 tag 名("Updated the <html lang> attribute...")就能蒙混过关——长度过 64、含 `<html `——同样 落地为幽灵 HTML 文件。 修复:合并两个 regex 成 STARTS_WITH_DOCUMENT_RE,加 ^ anchor, 要求 trimmed 内容的首个非空白 token 必须是 `<!doctype html>` 或 `<html`。Mid-string 出现的 tag 名不再算数。 同时按 lefarcen 的非阻塞文档建议把 docblock 改写得更精确: - "structural sniff" 替代 "validation",明确不是 HTML 校验器 - 列出 not-a-linter / .jsx-tsx-skipped / 用户手动保存路径不受 影响 三条 scope 边界 - 64 字符阈值会拒收 49 字符的最小空 doc(如 `<!doctype html><html><body></body></html>`),明确这是有意 trade-off:AI 产出预期是 non-trivial deliverable 新增 3 例测试覆盖 mrcfps 描述的 false negative: - 长 prose 中 inline 引 `<html lang>` 应拒收 - 长 prose 中 inline 引 `<!doctype html>` 应拒收 - 首个 token 是 `<p>` 等非文档标签的 fragment 应拒收
79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import { validateHtmlArtifact } from '../../src/artifacts/validate';
|
||
|
||
describe('validateHtmlArtifact', () => {
|
||
it('rejects an empty string', () => {
|
||
const result = validateHtmlArtifact('');
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) expect(result.reason).toMatch(/empty/i);
|
||
});
|
||
|
||
it('rejects whitespace-only content', () => {
|
||
const result = validateHtmlArtifact(' \n\t ');
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('rejects a one-line prose summary (the #50 phantom-artifact case)', () => {
|
||
const prose = '查看 `html-ppt-xhs-white-editorial/index.html` — 已删第 2 页(章节分隔)和第 8 页(致谢),剩余 6 张移除顶部 chrome,仅保留右下角 `01/06`–`06/06` 页码。';
|
||
const result = validateHtmlArtifact(prose);
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) expect(result.reason).toMatch(/html/i);
|
||
});
|
||
|
||
it('rejects content shorter than the minimum threshold even if it contains angle brackets', () => {
|
||
const result = validateHtmlArtifact('<p>hi</p>');
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('rejects a long prose blob that lacks any HTML structural markers', () => {
|
||
const prose = '这是一段很长的中文总结,'.repeat(20);
|
||
const result = validateHtmlArtifact(prose);
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('rejects long prose that mentions an inline <html ...> tag mid-sentence (mrcfps finding)', () => {
|
||
const prose = 'Updated the <html lang> attribute and cleaned up the footer layout for mobile previews.';
|
||
expect(prose.length).toBeGreaterThan(64);
|
||
const result = validateHtmlArtifact(prose);
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('rejects long prose that mentions <!doctype html> mid-sentence', () => {
|
||
const prose = 'I added a <!doctype html> declaration at the top and rewrote the body section to match the brief.';
|
||
expect(prose.length).toBeGreaterThan(64);
|
||
const result = validateHtmlArtifact(prose);
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('rejects content where the first non-whitespace token is a non-document tag like <p>', () => {
|
||
const fragment = '<p>This is a paragraph that happens to contain enough chars and a stray <html> mention.</p>';
|
||
const result = validateHtmlArtifact(fragment);
|
||
expect(result.ok).toBe(false);
|
||
});
|
||
|
||
it('accepts a complete <!doctype html> document', () => {
|
||
const html = '<!doctype html><html><head><title>x</title></head><body><h1>hello</h1></body></html>';
|
||
const result = validateHtmlArtifact(html);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('accepts content with a leading <html> tag (no doctype)', () => {
|
||
const html = '<html><head><title>x</title></head><body><div>content here long enough</div></body></html>';
|
||
const result = validateHtmlArtifact(html);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('is case-insensitive on the doctype / html tag check', () => {
|
||
const html = '<!DOCTYPE HTML><HTML><BODY><DIV>hello world content</DIV></BODY></HTML>';
|
||
const result = validateHtmlArtifact(html);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
|
||
it('tolerates leading whitespace and BOM before the doctype', () => {
|
||
const html = '\n <!doctype html>\n<html><body>real document body content</body></html>';
|
||
const result = validateHtmlArtifact(html);
|
||
expect(result.ok).toBe(true);
|
||
});
|
||
});
|