diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index fe10a1c71..59c5ce06a 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -42,6 +42,9 @@ separately so encoded filename delimiters stay in the filename. Fenced and inden do not count as root guidance imports or satisfy the required Claude import. The required Claude import must be an unformatted standalone line in a top-level paragraph; headings, quotes and list items do not satisfy it. +The parsed HTML tree also verifies that this paragraph is outside HTML containers, +including templates split across Markdown tokens. Generated HTML is inspected +in memory only; it is never executed or emitted. Heading anchors decode HTML character references in text and use `github-slugger` for GitHub-compatible character filtering and duplicate suffixes. Explicit HTML anchors use `parse5`, excluding comments, scripts, styles and template contents. diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index afa90aa63..7500b70e0 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -2,7 +2,7 @@ import GithubSlugger from 'github-slugger'; import { Marked, Tokenizer } from 'marked'; import { parseFragment } from 'parse5'; -// Lex only: no Markdown is rendered and no HTML is sanitized or re-emitted. +// Inspection only: generated HTML is parsed in memory, never executed or emitted. const markdownLexer = new Marked({ tokenizer: { reflink(source, links) { @@ -84,13 +84,34 @@ export function guidanceProse(markdown) { } export function guidanceStandaloneImports(markdown) { - return markdownLexer + const candidates = markdownLexer .lexer(markdown) .filter((token) => token.type === 'paragraph') .flatMap((token) => [ ...token.raw.matchAll(/^ {0,3}@([^\s]+)[\t ]*$/gmu), ]) .map((match) => match[1]); + // Markdown can split an HTML container across several top-level tokens. + // Check the parsed output tree as well as raw source formatting. Only text + // directly inside a root paragraph can supply the standalone directive. + const document = parseFragment(new Marked().parse(markdown)); + const visible = new Map(); + for (const node of document.childNodes) { + if (node.tagName !== 'p') continue; + const text = node.childNodes + .map((child) => + child.nodeName === '#text' ? child.value : '\uFFFC' + ) + .join(''); + for (const match of text.matchAll(/^ {0,3}@([^\s]+)[\t ]*$/gmu)) + visible.set(match[1], (visible.get(match[1]) ?? 0) + 1); + } + return candidates.filter((candidate) => { + const count = visible.get(candidate) ?? 0; + if (!count) return false; + visible.set(candidate, count - 1); + return true; + }); } export function guidanceAnchors(markdown) { diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index b53e55f75..ce0f7f995 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -572,3 +572,22 @@ for (const body of [ ); }); } + +for (const tag of ['template', 'div']) { + test(`HTML container cannot supply Claude import: ${tag}`, async (t) => { + assert.match( + ( + await diagnostics(t, { + 'CLAUDE.md': `<${tag}>\n\n@AGENTS.md\n\n`, + }) + ).join('\n'), + /standalone @AGENTS.md/ + ); + assert.deepEqual( + await diagnostics(t, { + 'CLAUDE.md': `<${tag}>\n\nExample\n\n\n\n@AGENTS.md`, + }), + [] + ); + }); +}