From b0cf310074d7f954452d0bbfe054028576361777 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 22:26:34 +0200 Subject: [PATCH] fix(agents): retain rendered context for Markdown references --- docs/development/agent-workflow.md | 3 + tools/skills/agent-guidance-markdown.mjs | 72 ++++++++++++------- tools/skills/validate-agent-guidance.test.mjs | 17 +++++ 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 65d838d8f..ba00a0424 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -67,6 +67,9 @@ Markdown destinations decode HTML entities before URI parsing, matching rendered Heading-anchor lookup is limited to Markdown targets. Source-file line fragments, PDF page fragments and other non-Markdown fragments retain file-existence checks. The import scan includes visible HTML text and excludes code and non-rendered containers. +Navigation uses the parsed rendered tree too. Temporary in-memory markers retain +definition, unresolved-reference and literal-path metadata, so Markdown inside +inert templates is excluded consistently with raw HTML navigation. Image source sets use `parse-srcset` to check each candidate URL. Root-relative literals never suppress source-relative definition checks. The Nx test hash includes `marked`, `parse5`, `github-slugger`, `parse-srcset` and `typescript` diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index 131eebb5b..0768074d9 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import parseSrcset from 'parse-srcset'; import GithubSlugger from 'github-slugger'; import { Marked, Tokenizer } from 'marked'; @@ -51,11 +52,12 @@ function decodeEntities(text) { ); } -function htmlNavigation(html) { +function htmlNavigation(html, inspect = () => {}) { const anchors = []; const references = []; function visit(node) { if (['script', 'style', 'template'].includes(node.tagName)) return; + inspect(node); for (const attribute of node.attrs ?? []) { if ( attribute.name === 'id' || @@ -186,35 +188,55 @@ function isLiteralRepositoryPath(token) { } export function guidanceReferences(markdown, includeLiterals) { - const result = []; - const destinations = new Set(); - const html = []; - const definitions = []; - const usedTargets = new Set(); - function add(target, literal = false, image = false) { - const key = `${literal}:${image}:${target}`; - if (!literal) usedTargets.add(target); - if (!destinations.has(key)) { - destinations.add(key); - result.push({ target, literal, image }); - } + const tokens = markdownLexer.lexer(markdown); + const markerTag = `guidance-reference-${randomUUID()}`; + const metadata = []; + function mark(token, reference) { + const index = metadata.push(reference) - 1; + token.type = 'html'; + token.raw = `<${markerTag} data-index="${index}">`; + token.text = token.raw; } - markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => { - if (['link', 'image'].includes(token.type)) - add(decodeEntities(token.href), false, token.type === 'image'); - if (token.type === 'def') definitions.push(decodeEntities(token.href)); - if (token.type === 'html') html.push(token.raw); - if (token.type === 'unresolved-reference') - result.push({ unresolvedReference: token.label }); - if ( + markdownLexer.walkTokens(tokens, (token) => { + if (token.type === 'def') + mark(token, { + target: decodeEntities(token.href), + definition: true, + }); + else if (token.type === 'unresolved-reference') + mark(token, { unresolvedReference: token.label }); + else if ( includeLiterals && token.type === 'codespan' && isLiteralRepositoryPath(token.text) ) - add(token.text, true); + mark(token, { target: token.text, literal: true }); }); - for (const reference of htmlNavigation(html.join('\n')).references) - add(reference.target, false, reference.image); - for (const target of definitions) if (!usedTargets.has(target)) add(target); + // Let Markdown rendering and HTML tree construction retain container context + // for ordinary links and for metadata that has no rendered navigation node. + const visible = []; + const navigation = htmlNavigation(new Marked().parser(tokens), (node) => { + if (node.tagName !== markerTag) return; + const index = Number( + node.attrs.find((attr) => attr.name === 'data-index')?.value + ); + if (metadata[index]) visible.push(metadata[index]); + }); + const result = []; + const seen = new Set(); + function add(reference) { + const key = JSON.stringify(reference); + if (!seen.has(key)) { + seen.add(key); + result.push(reference); + } + } + for (const reference of navigation.references) add(reference); + const usedTargets = new Set( + navigation.references.map((reference) => reference.target) + ); + for (const { definition, ...reference } of visible) { + if (!definition || !usedTargets.has(reference.target)) add(reference); + } return result; } diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 9cde9c61d..e7c9dde89 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -866,3 +866,20 @@ for (const mention of [ ); }); } + +for (const content of [ + '[example](docs/missing.md)', + '[example][ref]\n\n[ref]: docs/missing.md', + '[unused]: docs/missing.md', + '`docs/missing.md`', + '[example][undefined]', +]) { + test(`template Markdown is inert: ${content}`, async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': '', + }), + [] + ); + }); +}