Files
fusion/plugins/fusion-plugin-compound-engineering/src/artifacts/html-mutation.ts
gsxdsm 631e8fc675 FN-7159: add canonical CE HTML checklist repair
Enable CE HTML document review to safely canonicalize malformed checklists.

- Add a DOM-safe checklist-repair operation that rewrites provable markdown-marker and checkbox list shapes into canonical CE checklist HTML.
- Refuse ambiguous, nested, state-bearing, or protected checklist-like markup while preserving report-only fallback semantics.
- Expand HTML mutation and CE doc-review tests plus operator docs and rendering guidance for the canonical checklist contract.

Files changed:
 .changeset/fn-7159-canonical-html-checklist.md     |   7 +
 docs/workflow-editor.md                            |   2 +-
 docs/workflow-steps.md                             |   5 +-
 .../src/__tests__/ce-doc-review-html-mode.test.ts  |   6 +-
 .../src/artifacts/__tests__/html-mutation.test.ts  | 135 ++++++++++++++-
 .../src/artifacts/html-mutation.ts                 | 186 ++++++++++++++++++++-
 .../ce-brainstorm/references/html-rendering.md     |  31 ++++
 .../src/skills/ce-doc-review/SKILL.md              |  13 +-
 .../skills/ce-ideate/references/html-rendering.md  |  31 ++++
 .../skills/ce-plan/references/html-rendering.md    |  31 ++++
 10 files changed, 436 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-7159
Fusion-Task-Lineage: bbde7003-2942-4363-98d5-53bc3a5976b8
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-28 09:20:17 -07:00

704 lines
29 KiB
TypeScript

import { lstatSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative } from "node:path";
import { randomUUID } from "node:crypto";
import { html, parse, parseFragment, serialize, serializeOuter, type DefaultTreeAdapterTypes } from "parse5";
const MAX_ARTIFACT_BYTES = 2_000_000;
const STABLE_SECTION_IDS = new Set([
"goal-capsule",
"product-contract",
"product-requirements",
"planning-contract",
"implementation-units",
"verification-contract",
"definition-of-done",
"appendix",
"open-questions",
"outstanding-questions",
]);
const PROTECTED_TAGS = new Set(["head", "script", "style"]);
const RAW_TEXT_TAGS = new Set(["pre", "code", "script", "style"]);
const SAFE_OPEN_QUESTION_TAGS = new Set([
"li",
"a",
"abbr",
"b",
"br",
"cite",
"code",
"em",
"i",
"kbd",
"mark",
"q",
"s",
"samp",
"small",
"span",
"strong",
"sub",
"sup",
"u",
"var",
]);
const SAFE_OPEN_QUESTION_GLOBAL_ATTRS = new Set(["aria-label", "title"]);
const SAFE_OPEN_QUESTION_ATTRS = new Map<string, ReadonlySet<string>>([["a", new Set(["href", "title", "aria-label"])] as const]);
const CHECKLIST_REPAIR_BLOCK_TAGS = new Set([
"article",
"blockquote",
"details",
"div",
"dl",
"figure",
"ol",
"p",
"section",
"table",
"ul",
]);
type Document = DefaultTreeAdapterTypes.Document;
type DocumentFragment = DefaultTreeAdapterTypes.DocumentFragment;
type Element = DefaultTreeAdapterTypes.Element;
type TextNode = DefaultTreeAdapterTypes.TextNode;
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
type ParentNode = DefaultTreeAdapterTypes.ParentNode;
export type HtmlMutationOperation =
| { type: "append-open-question"; itemHtml: string }
| { type: "checklist-repair" }
| { type: "repair-heading-depth"; anchorId: string; fromLevel: 1 | 2 | 3 | 4 | 5 | 6; toLevel: 1 | 2 | 3 | 4 | 5 | 6 }
| { type: "normalize-duplicate-inter-block-whitespace" }
| { type: "replace-visible-text"; from: string; to: string; anchorId?: string };
export interface HtmlMutationSuccess {
ok: true;
html: string;
fixesApplied: number;
}
export interface HtmlMutationRefusal {
ok: false;
reason: string;
fixesApplied: 0;
}
export type HtmlMutationResult = HtmlMutationSuccess | HtmlMutationRefusal;
export interface HtmlMutationWriteSuccess {
ok: true;
fixesApplied: number;
path: string;
}
export type HtmlMutationWriteResult = HtmlMutationWriteSuccess | HtmlMutationRefusal;
export interface HtmlMutationWriteOptions {
rootDir?: string;
validateWrittenHtml?: (html: string) => boolean;
}
interface ProtectedSnapshot {
protectedMarkup: string[];
ids: string[];
dataAttrs: string[];
}
/*
FNXC:CompoundEngineering 2026-06-27-21:48:
FN-7149 requires CE HTML fixes to use a direct parse5 parse/mutate/serialize loop, not jsdom or markdown text edits. The helper refuses unless the source is parse5 round-trip stable, anchors resolve deterministically, protected regions are byte-preserved, and the write path can roll back after post-write validation.
*/
export function applyHtmlMutations(input: string, operations: readonly HtmlMutationOperation[]): HtmlMutationResult {
let document = parseStableDocument(input);
if (!document.ok) return document;
const beforeVisibleText = getVisibleText(document.document);
const protectedBefore = snapshotProtectedRegions(document.document);
let expectedVisibleText = beforeVisibleText;
let fixesApplied = 0;
for (const operation of operations) {
const mutation = applySingleOperation(document.document, operation);
if (!mutation.ok) return mutation;
if (mutation.applied) {
fixesApplied += 1;
expectedVisibleText = mutation.expectedVisibleText ?? mutateExpectedVisibleText(expectedVisibleText, operation);
}
}
const output = serialize(document.document);
const reparsed = parseStableDocument(output);
if (!reparsed.ok) return refusal(`post-mutation validation failed: ${reparsed.reason}`);
const protectedAfter = snapshotProtectedRegions(reparsed.document);
if (!sameJson(protectedBefore, protectedAfter)) return refusal("protected region changed during HTML mutation");
if (getVisibleText(reparsed.document) !== expectedVisibleText) return refusal("visible text changed outside the intended mutation");
return { ok: true, html: output, fixesApplied };
}
export function writeHtmlMutationsToFile(
filePath: string,
operations: readonly HtmlMutationOperation[],
options: HtmlMutationWriteOptions = {},
): HtmlMutationWriteResult {
let tempPath: string | undefined;
try {
const safePath = resolveSafeArtifactPath(filePath, options.rootDir);
const original = readFileSync(safePath, "utf8");
const result = applyHtmlMutations(original, operations);
if (!result.ok) return result;
if (result.fixesApplied === 0 || result.html === original) return { ok: true, fixesApplied: 0, path: safePath };
tempPath = join(dirname(safePath), `.${basename(safePath)}.html-mutation-${randomUUID()}.tmp`);
writeFileSync(tempPath, result.html, { encoding: "utf8", mode: 0o600 });
renameSync(tempPath, safePath);
tempPath = undefined;
const restoreOriginal = (): HtmlMutationRefusal => {
tempPath = join(dirname(safePath), `.${basename(safePath)}.html-mutation-rollback-${randomUUID()}.tmp`);
writeFileSync(tempPath, original, { encoding: "utf8", mode: 0o600 });
renameSync(tempPath, safePath);
tempPath = undefined;
return refusal("post-write validation failed; restored original HTML artifact");
};
/*
FNXC:CompoundEngineering 2026-06-28-07:56:
FN-7149 PR feedback requires thrown post-write validators to use the same rollback path as false validation. Headless CE mutation must never leave a validator-rejected artifact on disk after the atomic rename.
*/
let postWriteFailed = false;
try {
const written = readFileSync(safePath, "utf8");
const postWrite = parseStableDocument(written);
postWriteFailed = !postWrite.ok || written !== result.html || options.validateWrittenHtml?.(written) === false;
} catch {
postWriteFailed = true;
}
if (postWriteFailed) return restoreOriginal();
return { ok: true, fixesApplied: result.fixesApplied, path: safePath };
} catch (error) {
if (tempPath) rmSync(tempPath, { force: true });
return refusal(error instanceof Error ? error.message : "HTML mutation write failed");
} finally {
if (tempPath) rmSync(tempPath, { force: true });
}
}
function parseStableDocument(input: string): { ok: true; document: Document } | HtmlMutationRefusal {
const document = parse(input);
const roundTrip = serialize(document);
if (roundTrip !== input) return refusal("round-trip stability gate failed");
return { ok: true, document };
}
function refusal(reason: string): HtmlMutationRefusal {
return { ok: false, reason, fixesApplied: 0 };
}
function applySingleOperation(
document: Document,
operation: HtmlMutationOperation,
): { ok: true; applied: boolean; expectedVisibleText?: string } | HtmlMutationRefusal {
switch (operation.type) {
case "append-open-question":
return appendOpenQuestion(document, operation.itemHtml);
case "checklist-repair":
return repairChecklists(document);
case "repair-heading-depth":
return repairHeadingDepth(document, operation);
case "normalize-duplicate-inter-block-whitespace":
return normalizeDuplicateInterBlockWhitespace(document);
case "replace-visible-text":
return replaceVisibleText(document, operation);
default:
return refusal("unsupported HTML mutation operation");
}
}
/**
* FNXC:CompoundEngineering 2026-06-27-21:49:
* Append-to-Open-Questions is portable to HTML only as a parsed single `<li>` fragment under an existing Open/Outstanding Questions list. The helper must not fabricate sections or inject script/style-capable fragments because FN-7147's report-only fallback is safer than guessing the CE renderer's structure.
*/
function appendOpenQuestion(document: Document, itemHtml: string): { ok: true; applied: boolean } | HtmlMutationRefusal {
const anchor = resolveOpenQuestionsAnchor(document);
if (!anchor.ok) return anchor;
const list = resolveQuestionList(anchor.element);
if (!list.ok) return list;
const item = parseListItemFragment(itemHtml);
if (!item.ok) return item;
const itemMarkup = serializeOuter(item.element);
if (list.element.childNodes.some((child) => isElement(child) && child.tagName === "li" && serializeOuter(child) === itemMarkup)) {
return { ok: true, applied: false };
}
item.element.parentNode = list.element;
list.element.childNodes.push(item.element);
return { ok: true, applied: true };
}
/**
* FNXC:CompoundEngineering 2026-06-28-08:41:
* FN-7159 permits HTML checklist write-back only after the CE rendering contract defines one canonical source-readable shape. Repair is limited to provable markdown-marker/list/input-checkbox variants, rejects ambiguous lists and subtrees with hidden/state-bearing attributes, and delegates round-trip, protected-region, visible-text, atomic-write, and rollback safety to the shared helper contract.
*/
function repairChecklists(document: Document): { ok: true; applied: boolean; expectedVisibleText?: string } | HtmlMutationRefusal {
const repairs: Array<{ target: ChildNode; items: ChecklistItem[] }> = [];
let sawCanonical = false;
let sawAmbiguous = false;
walkNodes(document, (node, ancestors) => {
if (isInsideProtectedOrRawText(ancestors)) return;
if (isText(node)) {
const parsed = parseRawMarkdownChecklistText(node.value);
if (parsed.kind === "repair") repairs.push({ target: node, items: parsed.items });
if (parsed.kind === "ambiguous") sawAmbiguous = true;
return;
}
if (!isElement(node) || (node.tagName !== "ul" && node.tagName !== "ol")) return;
if (isCanonicalChecklist(node)) {
sawCanonical = true;
return;
}
const parsed = parseMalformedChecklistList(node);
if (parsed.kind === "repair") repairs.push({ target: node, items: parsed.items });
if (parsed.kind === "ambiguous") sawAmbiguous = true;
});
if (sawAmbiguous) return refusal("checklist repair found ambiguous or unsafe checklist-like HTML");
if (repairs.length === 0) {
return sawCanonical ? { ok: true, applied: false } : refusal("no provable malformed checklist found");
}
for (const repair of repairs) {
const parent = repair.target.parentNode;
if (!parent || !("childNodes" in parent)) return refusal("checklist repair target has no mutable parent");
const index = parent.childNodes.indexOf(repair.target);
if (index < 0) return refusal("checklist repair target is not attached to its parent");
const replacement = makeCanonicalChecklist(repair.items);
replacement.parentNode = parent;
parent.childNodes[index] = replacement;
}
return { ok: true, applied: true, expectedVisibleText: getVisibleText(document) };
}
interface ChecklistItem {
checked: boolean;
label: string;
}
type ChecklistParseResult = { kind: "none" } | { kind: "ambiguous" } | { kind: "repair"; items: ChecklistItem[] };
function parseRawMarkdownChecklistText(value: string): ChecklistParseResult {
if (!/[\r\n]?\s*-\s*\[[ xX]\]/.test(value)) return { kind: "none" };
const lines = value.split(/\r?\n/);
const items: ChecklistItem[] = [];
for (const line of lines) {
if (line.trim() === "") continue;
const match = /^\s*-\s*\[([ xX])\]\s+(.+?)\s*$/.exec(line);
if (!match) return { kind: "ambiguous" };
items.push({ checked: match[1].toLowerCase() === "x", label: match[2] });
}
return items.length > 0 ? { kind: "repair", items } : { kind: "none" };
}
function parseMalformedChecklistList(list: Element): ChecklistParseResult {
if (hasUnsafeChecklistSubtree(list)) return looksChecklistLike(list) ? { kind: "ambiguous" } : { kind: "none" };
const items = list.childNodes.filter(isElement);
if (items.length === 0) return { kind: "none" };
if (items.some((item) => item.tagName !== "li") || list.childNodes.some((child) => !isWhitespaceText(child) && !isElement(child))) {
return looksChecklistLike(list) ? { kind: "ambiguous" } : { kind: "none" };
}
if (items.some(hasNestedChecklistRepairBlock)) return looksChecklistLike(list) ? { kind: "ambiguous" } : { kind: "none" };
const markerItems = items.map(parseMarkerListItem);
if (markerItems.every((item): item is ChecklistItem => Boolean(item))) return { kind: "repair", items: markerItems };
if (markerItems.some(Boolean)) return { kind: "ambiguous" };
const inputItems = items.map(parseInputCheckboxListItem);
if (inputItems.every((item): item is ChecklistItem => Boolean(item))) return { kind: "repair", items: inputItems };
if (inputItems.some(Boolean)) return { kind: "ambiguous" };
return { kind: "none" };
}
function hasNestedChecklistRepairBlock(item: Element): boolean {
return findElements(item, (element) => element !== item && CHECKLIST_REPAIR_BLOCK_TAGS.has(element.tagName)).length > 0;
}
function parseMarkerListItem(item: Element): ChecklistItem | null {
const visible = getVisibleText(item);
const match = /^\[([ xX])\]\s+(.+?)\s*$/.exec(visible);
if (!match) return null;
return { checked: match[1].toLowerCase() === "x", label: match[2] };
}
function parseInputCheckboxListItem(item: Element): ChecklistItem | null {
const first = item.childNodes.find((child) => !isWhitespaceText(child));
if (!first || !isElement(first) || first.tagName !== "input" || getAttr(first, "type")?.toLowerCase() !== "checkbox") return null;
const label = getVisibleText(item).trim();
if (!label) return null;
return { checked: hasAttr(first, "checked"), label };
}
function looksChecklistLike(root: ParentNode): boolean {
return /\[[ xX]\]|type=["']?checkbox/i.test(serialize(root));
}
function hasUnsafeChecklistSubtree(root: Element): boolean {
return findElements(root, (element) => {
if (PROTECTED_TAGS.has(element.tagName)) return true;
if (getAttr(element, "id")) return true;
return element.attrs.some((attr) => {
const name = attr.name.toLowerCase();
return name.startsWith("data-") || name.startsWith("on") || name.startsWith("aria-") || name === "role" || name === "class" || name === "style";
});
}).length > 0;
}
function isCanonicalChecklist(list: Element): boolean {
if (list.tagName !== "ul" || getAttr(list, "class") !== "ce-checklist" || getAttr(list, "aria-label") !== "Checklist") return false;
for (const child of list.childNodes) {
if (isWhitespaceText(child)) continue;
if (!isElement(child) || child.tagName !== "li" || getAttr(child, "class") !== "ce-checklist-item") return false;
const semantic = child.childNodes.filter((itemChild) => !isWhitespaceText(itemChild) || (isText(itemChild) && itemChild.value === " "));
if (semantic.length !== 3) return false;
const [state, spacer, label] = semantic;
if (!isElement(state) || state.tagName !== "span" || getAttr(state, "class") !== "ce-checklist-state") return false;
if (getVisibleText(state) !== "[ ]" && getVisibleText(state) !== "[x]") return false;
if (!isText(spacer) || spacer.value !== " ") return false;
if (!isElement(label) || label.tagName !== "span" || getAttr(label, "class") !== "ce-checklist-label" || !getVisibleText(label)) {
return false;
}
}
return true;
}
function makeCanonicalChecklist(items: readonly ChecklistItem[]): Element {
return makeElement(
"ul",
[
{ name: "class", value: "ce-checklist" },
{ name: "aria-label", value: "Checklist" },
],
items.map((item) =>
makeElement("li", [{ name: "class", value: "ce-checklist-item" }], [
makeElement("span", [{ name: "class", value: "ce-checklist-state" }], [makeText(item.checked ? "[x]" : "[ ]")]),
makeText(" "),
makeElement("span", [{ name: "class", value: "ce-checklist-label" }], [makeText(item.label)]),
]),
),
);
}
function makeElement(tagName: string, attrs: Element["attrs"], childNodes: ChildNode[] = []): Element {
const element: Element = { nodeName: tagName, tagName, attrs, namespaceURI: html.NS.HTML, childNodes, parentNode: null };
for (const child of childNodes) child.parentNode = element;
return element;
}
function makeText(value: string): TextNode {
return { nodeName: "#text", value, parentNode: null };
}
function repairHeadingDepth(
document: Document,
operation: Extract<HtmlMutationOperation, { type: "repair-heading-depth" }>,
): { ok: true; applied: boolean } | HtmlMutationRefusal {
if (!STABLE_SECTION_IDS.has(operation.anchorId)) return refusal("heading repair anchor is not in the stable CE section registry");
const anchor = resolveUniqueElementById(document, operation.anchorId);
if (!anchor.ok) return anchor;
if (!isHeading(anchor.element)) return refusal("heading repair anchor does not resolve to a heading element");
if (anchor.element.tagName === `h${operation.toLevel}`) return { ok: true, applied: false };
if (anchor.element.tagName !== `h${operation.fromLevel}`) return refusal("heading repair source level does not match the anchored element");
anchor.element.nodeName = `h${operation.toLevel}`;
anchor.element.tagName = `h${operation.toLevel}`;
return { ok: true, applied: true };
}
/**
* FNXC:CompoundEngineering 2026-06-27-21:50:
* Duplicate whitespace normalization is limited to adjacent inter-block text nodes outside raw-text elements. It never rewrites text inside prose, pre/code, script, or style, so rendered words and executable/style content stay byte-identical.
*/
function normalizeDuplicateInterBlockWhitespace(document: Document): { ok: true; applied: boolean } | HtmlMutationRefusal {
let applied = false;
walkParents(document, (parent) => {
if (isElement(parent) && RAW_TEXT_TAGS.has(parent.tagName)) return;
for (let index = parent.childNodes.length - 1; index > 0; index -= 1) {
const current = parent.childNodes[index];
const previous = parent.childNodes[index - 1];
if (isWhitespaceText(current) && isWhitespaceText(previous)) {
parent.childNodes.splice(index, 1);
applied = true;
}
}
for (let index = 1; index < parent.childNodes.length - 1; index += 1) {
const current = parent.childNodes[index];
if (!isWhitespaceText(current) || !/\n\s*\n/.test(current.value)) continue;
const previous = parent.childNodes[index - 1];
const next = parent.childNodes[index + 1];
if (isElement(previous) && isElement(next)) {
current.value = "\n";
applied = true;
}
}
});
return { ok: true, applied };
}
/**
* FNXC:CompoundEngineering 2026-06-27-21:51:
* Typo repair is constrained to one exact visible text-node substring. Ambiguous matches, protected-region matches, and cross-node wording edits stay report-only because they cannot prove visible-prose equivalence without human judgment.
*
* FNXC:CompoundEngineering 2026-06-28-07:49:
* FN-7149 PR feedback found that expected visible text must reconcile against the selected text node's occurrence, not the first occurrence in the concatenated document. Anchor-scoped replacements can target a later node while earlier prose still contains the same word.
*/
function replaceVisibleText(
document: Document,
operation: Extract<HtmlMutationOperation, { type: "replace-visible-text" }>,
): { ok: true; applied: boolean; expectedVisibleText?: string } | HtmlMutationRefusal {
if (!operation.from || operation.from === operation.to) return { ok: true, applied: false };
const root = operation.anchorId ? resolveUniqueElementById(document, operation.anchorId) : { ok: true as const, element: document };
if (!root.ok) return root;
const matches: TextNode[] = [];
walkNodes(root.element, (node, ancestors) => {
if (!isText(node) || isInsideProtectedOrRawText(ancestors)) return;
if (countOccurrences(node.value, operation.from) === 1) matches.push(node);
});
if (matches.length !== 1) return refusal("visible text replacement did not resolve to exactly one text node");
const expectedVisibleText = replaceVisibleTextAtNode(document, matches[0], operation.from, operation.to);
matches[0].value = matches[0].value.replace(operation.from, operation.to);
return { ok: true, applied: true, expectedVisibleText };
}
function replaceVisibleTextAtNode(document: Document, target: TextNode, from: string, to: string): string {
let rawVisibleText = "";
walkNodes(document, (node, ancestors) => {
if (!isText(node) || isInsideProtectedOrRawText(ancestors)) return;
rawVisibleText += node === target ? node.value.replace(from, to) : node.value;
});
return normalizeVisibleText(rawVisibleText);
}
function mutateExpectedVisibleText(text: string, operation: HtmlMutationOperation): string {
if (operation.type === "append-open-question") return normalizeVisibleText(`${text}${getVisibleText(parseFragment(operation.itemHtml))}`);
if (operation.type === "replace-visible-text") return normalizeVisibleText(text.replace(operation.from, operation.to));
return text;
}
function resolveOpenQuestionsAnchor(document: Document): { ok: true; element: Element } | HtmlMutationRefusal {
const byId = uniqueElements(
["open-questions", "outstanding-questions"].flatMap((id) => findElements(document, (el) => getAttr(el, "id") === id)),
);
if (byId.length === 1) return { ok: true, element: byId[0] };
if (byId.length > 1) return refusal("Open Questions anchor is ambiguous");
const byHeading = uniqueElements(
findElements(document, (el) => isHeading(el) && /^(open|outstanding) questions$/i.test(getVisibleText(el).trim())),
);
if (byHeading.length !== 1) return refusal(byHeading.length === 0 ? "Open Questions anchor not found" : "Open Questions anchor is ambiguous");
return { ok: true, element: byHeading[0] };
}
function resolveQuestionList(anchor: Element): { ok: true; element: Element } | HtmlMutationRefusal {
const candidates: Element[] = [];
if (anchor.tagName === "section" || anchor.tagName === "article" || anchor.tagName === "div") {
candidates.push(...findElements(anchor, (el) => el !== anchor && (el.tagName === "ul" || el.tagName === "ol")));
} else if (isHeading(anchor) && anchor.parentNode && "childNodes" in anchor.parentNode) {
const siblings = anchor.parentNode.childNodes;
const start = siblings.indexOf(anchor);
const anchorLevel = headingLevel(anchor);
for (const sibling of siblings.slice(start + 1)) {
if (isElement(sibling) && isHeading(sibling) && headingLevel(sibling) <= anchorLevel) break;
if (isElement(sibling) && (sibling.tagName === "ul" || sibling.tagName === "ol")) candidates.push(sibling);
if (isElement(sibling)) candidates.push(...findElements(sibling, (el) => el.tagName === "ul" || el.tagName === "ol"));
}
}
const unique = uniqueElements(candidates);
if (unique.length !== 1) return refusal(unique.length === 0 ? "Open Questions list not found" : "Open Questions list is ambiguous");
return { ok: true, element: unique[0] };
}
function parseListItemFragment(itemHtml: string): { ok: true; element: Element } | HtmlMutationRefusal {
const fragment = parseFragment(itemHtml);
const elementChildren = fragment.childNodes.filter(isElement);
if (elementChildren.length !== 1 || fragment.childNodes.some((node) => !isWhitespaceText(node) && !isElement(node))) {
return refusal("Open Questions append requires exactly one list item fragment");
}
const [item] = elementChildren;
if (item.tagName !== "li") return refusal("Open Questions append fragment must be a list item");
const safety = validateOpenQuestionListItem(item);
if (!safety.ok) return safety;
return { ok: true, element: item };
}
/*
FNXC:CompoundEngineering 2026-06-28-08:02:
FN-7149 PR feedback requires Open Questions HTML append to be text-with-minimal-inline-markup, not an open HTML passthrough. A strict tag/attribute allowlist rejects active elements, handlers, srcdoc, and active URL schemes before generated CE documents can persist model-shaped HTML.
*/
function validateOpenQuestionListItem(item: Element): { ok: true } | HtmlMutationRefusal {
for (const element of findElements(item, () => true)) {
if (!SAFE_OPEN_QUESTION_TAGS.has(element.tagName)) {
return refusal("Open Questions append fragment contains an unsafe element");
}
for (const attr of element.attrs) {
const attrName = attr.name.toLowerCase();
if (attrName.startsWith("on") || attrName === "srcdoc") {
return refusal("Open Questions append fragment contains an unsafe attribute");
}
const tagAttrs = SAFE_OPEN_QUESTION_ATTRS.get(element.tagName);
if (!SAFE_OPEN_QUESTION_GLOBAL_ATTRS.has(attrName) && !tagAttrs?.has(attrName)) {
return refusal("Open Questions append fragment contains an unsupported attribute");
}
if (attrName === "href" && !isSafeOpenQuestionHref(attr.value)) {
return refusal("Open Questions append fragment contains an unsafe URL");
}
}
}
return { ok: true };
}
function isSafeOpenQuestionHref(value: string): boolean {
const trimmed = [...value.trim()]
.filter((char) => {
const code = char.charCodeAt(0);
return code > 0x1f && code !== 0x7f && !/\s/.test(char);
})
.join("")
.toLowerCase();
return (
trimmed.startsWith("#") ||
trimmed.startsWith("/") ||
trimmed.startsWith("./") ||
trimmed.startsWith("../") ||
trimmed.startsWith("http://") ||
trimmed.startsWith("https://") ||
trimmed.startsWith("mailto:") ||
!/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
);
}
function resolveUniqueElementById(document: Document, id: string): { ok: true; element: Element } | HtmlMutationRefusal {
const matches = findElements(document, (el) => getAttr(el, "id") === id);
if (matches.length !== 1) return refusal(matches.length === 0 ? `anchor id not found: ${id}` : `anchor id is ambiguous: ${id}`);
return { ok: true, element: matches[0] };
}
function resolveSafeArtifactPath(filePath: string, rootDir?: string): string {
const stat = lstatSync(filePath);
if (stat.isSymbolicLink()) throw new Error("Symlink HTML artifacts are not allowed");
if (!stat.isFile()) throw new Error("HTML mutation target must be a file");
if (stat.size > MAX_ARTIFACT_BYTES) throw new Error("HTML artifact exceeds mutation size limit");
const realFile = realpathSync(filePath);
if (rootDir) {
const realRoot = realpathSync(rootDir);
const rel = relative(realRoot, realFile);
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("HTML mutation target escapes the project root");
}
return realFile;
}
function snapshotProtectedRegions(root: ParentNode): ProtectedSnapshot {
const protectedMarkup: string[] = [];
const ids: string[] = [];
const dataAttrs: string[] = [];
walkNodes(root, (node) => {
if (!isElement(node)) return;
if (PROTECTED_TAGS.has(node.tagName)) protectedMarkup.push(serializeOuter(node));
const id = getAttr(node, "id");
if (id) ids.push(id);
for (const attr of node.attrs) {
if (attr.name.startsWith("data-")) dataAttrs.push(`${attr.name}=${attr.value}`);
}
});
return { protectedMarkup, ids, dataAttrs };
}
function getVisibleText(root: ParentNode): string {
let text = "";
walkNodes(root, (node, ancestors) => {
if (isText(node) && !isInsideProtectedOrRawText(ancestors)) text += node.value;
});
return normalizeVisibleText(text);
}
function normalizeVisibleText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function isInsideProtectedOrRawText(ancestors: readonly ParentNode[]): boolean {
return ancestors.some((ancestor) => isElement(ancestor) && (PROTECTED_TAGS.has(ancestor.tagName) || RAW_TEXT_TAGS.has(ancestor.tagName)));
}
function walkParents(node: ParentNode, visit: (node: ParentNode) => void): void {
visit(node);
for (const child of node.childNodes) {
if (isParent(child)) walkParents(child, visit);
}
}
function walkNodes(node: ParentNode | ChildNode, visit: (node: ParentNode | ChildNode, ancestors: ParentNode[]) => void, ancestors: ParentNode[] = []): void {
visit(node, ancestors);
if (!isParentLike(node)) return;
for (const child of node.childNodes) {
walkNodes(child, visit, [...ancestors, node]);
}
}
function findElements(root: ParentNode, predicate: (element: Element) => boolean): Element[] {
const matches: Element[] = [];
walkNodes(root, (node) => {
if (isElement(node) && predicate(node)) matches.push(node);
});
return matches;
}
function uniqueElements(elements: Element[]): Element[] {
return [...new Set(elements)];
}
function isParent(node: ChildNode): node is Element {
return "childNodes" in node;
}
function isParentLike(node: ParentNode | ChildNode): node is ParentNode {
return "childNodes" in node;
}
function isElement(node: ParentNode | ChildNode): node is Element {
return "tagName" in node;
}
function isText(node: ParentNode | ChildNode): node is TextNode {
return node.nodeName === "#text";
}
function isWhitespaceText(node: ChildNode): node is TextNode {
return isText(node) && /^\s*$/.test(node.value);
}
function isHeading(element: Element): boolean {
return /^h[1-6]$/.test(element.tagName);
}
function headingLevel(element: Element): number {
return Number(element.tagName.slice(1));
}
function getAttr(element: Element, name: string): string | undefined {
return element.attrs.find((attr) => attr.name === name)?.value;
}
function hasAttr(element: Element, name: string): boolean {
return element.attrs.some((attr) => attr.name === name);
}
function countOccurrences(text: string, needle: string): number {
return text.split(needle).length - 1;
}
function sameJson(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}