From 244b1e43ceb979368e0d771d4d75996b6ee89026 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 19:56:14 +0200 Subject: [PATCH] fix(agents): validate rendered HTML navigation --- docs/development/agent-workflow.md | 2 + tools/skills/agent-guidance-markdown.mjs | 20 +++++++--- tools/skills/validate-agent-guidance.test.mjs | 39 +++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 3be60660c..2d40e1948 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -42,6 +42,8 @@ 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. Heading anchors decode HTML character references in text. Explicit HTML anchors use `parse5`, excluding comments, scripts, styles and template contents. +Rendered HTML anchor hrefs and image sources use the same local-reference checks +as Markdown links, including decoded attributes and fragment validation. The Nx test hash includes `marked` and `parse5` so dependency changes invalidate parser coverage. It cannot prove semantic equivalence; review changed contracts as well. diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index 0e7aa2d34..ad715066c 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -49,8 +49,9 @@ function decodeEntities(text) { ); } -function htmlAnchors(html) { - const found = []; +function htmlNavigation(html) { + const anchors = []; + const references = []; function visit(node) { if (['script', 'style', 'template'].includes(node.tagName)) return; for (const attribute of node.attrs ?? []) { @@ -58,12 +59,17 @@ function htmlAnchors(html) { attribute.name === 'id' || (node.tagName === 'a' && attribute.name === 'name') ) - found.push(attribute.value); + anchors.push(attribute.value); + if ( + (node.tagName === 'a' && attribute.name === 'href') || + (node.tagName === 'img' && attribute.name === 'src') + ) + references.push(attribute.value); } for (const child of node.childNodes ?? []) visit(child); } visit(parseFragment(html)); - return found; + return { anchors, references }; } export function guidanceProse(markdown) { @@ -92,7 +98,7 @@ export function guidanceAnchors(markdown) { } if (token.type === 'html') html.push(token.raw); }); - return new Set([...found, ...htmlAnchors(html.join('\n'))]); + return new Set([...found, ...htmlNavigation(html.join('\n')).anchors]); } function isLiteralRepositoryPath(token) { @@ -124,6 +130,7 @@ function isLiteralRepositoryPath(token) { export function guidanceReferences(markdown, includeLiterals) { const result = []; const destinations = new Set(); + const html = []; function add(target, literal = false) { const key = `${literal}:${target}`; if (!destinations.has(key)) { @@ -133,6 +140,7 @@ export function guidanceReferences(markdown, includeLiterals) { } markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => { if (['link', 'image', 'def'].includes(token.type)) add(token.href); + if (token.type === 'html') html.push(token.raw); if (token.type === 'unresolved-reference') result.push({ unresolvedReference: token.label }); if ( @@ -142,5 +150,7 @@ export function guidanceReferences(markdown, includeLiterals) { ) add(token.text, true); }); + for (const target of htmlNavigation(html.join('\n')).references) + add(target); return result; } diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 165d9f606..f730517fe 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -498,3 +498,42 @@ for (const [heading, anchor] of [ ); }); } + +for (const html of [ + 'Guide', + '', +]) { + test(`validates rendered HTML navigation: ${html}`, async (t) => { + assert.match( + (await diagnostics(t, { 'AGENTS.md': html })).join('\n'), + /does not exist/ + ); + }); +} +test('HTML navigation supports valid anchors and decoded attributes', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + 'Guide ', + 'docs/a&b.png': '', + }), + [] + ); + assert.match( + ( + await diagnostics(t, { + 'AGENTS.md': 'Guide', + }) + ).join('\n'), + /missing anchor/ + ); +}); +test('non-rendered HTML navigation is ignored', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + '\n\n\n', + }), + [] + ); +});