fix(agents): validate rendered HTML navigation

This commit is contained in:
4gray committed 2026-09-20 19:56:14 +02:00
1 parent bdf7017a97
commit 244b1e43ce
3 files changed
+56 -5

No files matched your search

+2
View File
@@ -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.
+15 -5
View File
@@ -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;
}
@@ -498,3 +498,42 @@ for (const [heading, anchor] of [
);
});
}
for (const html of [
'<a href="docs/missing.md#section">Guide</a>',
'<img src="docs/missing.png">',
]) {
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':
'<a href="docs/example.md#hello-world">Guide</a> <img src="docs/a&amp;b.png">',
'docs/a&b.png': '',
}),
[]
);
assert.match(
(
await diagnostics(t, {
'AGENTS.md': '<a href="docs/example.md#missing">Guide</a>',
})
).join('\n'),
/missing anchor/
);
});
test('non-rendered HTML navigation is ignored', async (t) => {
assert.deepEqual(
await diagnostics(t, {
'AGENTS.md':
'<!-- <a href="missing.md"> -->\n<script>const a = \'<img src="missing.png">\';</script>\n\n<template><a href="missing.md">Hidden</a></template>',
}),
[]
);
});