Files
DongsenandGitHub 7c1db80893 fix(web): 写盘前拦截 prose-as-HTML artifact (#50) (#1144)
* 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 应拒收
2026-05-10 20:22:48 +08:00

79 lines
3.3 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});