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\nHidden',
+ }),
+ []
+ );
+});