diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 59c5ce06a..353182b13 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -50,6 +50,9 @@ for GitHub-compatible character filtering and duplicate suffixes. Explicit HTML 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. +Image references check file existence without interpreting image fragments as +Markdown headings; document links keep anchor checks even when sharing a target. +Inline guidance imports are rejected after punctuation as well as whitespace. The Nx test hash includes `marked`, `parse5` and `github-slugger` 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 7500b70e0..a977efb44 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -65,7 +65,10 @@ function htmlNavigation(html) { (node.tagName === 'a' && attribute.name === 'href') || (node.tagName === 'img' && attribute.name === 'src') ) - references.push(attribute.value); + references.push({ + target: attribute.value, + image: node.tagName === 'img', + }); } for (const child of node.childNodes ?? []) visit(child); } @@ -157,15 +160,20 @@ export function guidanceReferences(markdown, includeLiterals) { const result = []; const destinations = new Set(); const html = []; - function add(target, literal = false) { - const key = `${literal}:${target}`; + const definitions = []; + const usedTargets = new Set(); + function add(target, literal = false, image = false) { + const key = `${literal}:${image}:${target}`; + usedTargets.add(target); if (!destinations.has(key)) { destinations.add(key); - result.push({ target, literal }); + result.push({ target, literal, image }); } } markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => { - if (['link', 'image', 'def'].includes(token.type)) add(token.href); + if (['link', 'image'].includes(token.type)) + add(token.href, false, token.type === 'image'); + if (token.type === 'def') definitions.push(token.href); if (token.type === 'html') html.push(token.raw); if (token.type === 'unresolved-reference') result.push({ unresolvedReference: token.label }); @@ -176,7 +184,8 @@ export function guidanceReferences(markdown, includeLiterals) { ) add(token.text, true); }); - for (const target of htmlNavigation(html.join('\n')).references) - add(target); + 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); return result; } diff --git a/tools/skills/validate-agent-guidance.mjs b/tools/skills/validate-agent-guidance.mjs index 76232ae56..be5842102 100644 --- a/tools/skills/validate-agent-guidance.mjs +++ b/tools/skills/validate-agent-guidance.mjs @@ -26,7 +26,7 @@ function within(root, path) { async function validateReference( rootDir, source, - { target, literal, unresolvedReference } + { target, literal, image, unresolvedReference } ) { if (unresolvedReference !== undefined) return `${source}: unresolved Markdown reference "${unresolvedReference}"`; @@ -51,7 +51,11 @@ async function validateReference( const actual = await realpath(absolute); if (!within(rootDir, actual)) return `${source}: referenced path escapes repository root: ${target}`; - if (anchor && !anchors(await readFile(actual, 'utf8')).has(anchor)) { + if ( + anchor && + !image && + !anchors(await readFile(actual, 'utf8')).has(anchor) + ) { return `${source}: missing anchor "${anchor}" in ${target}`; } } catch (error) { @@ -93,7 +97,9 @@ export async function validateAgentGuidance({ rootDir }) { const unfenced = guidanceProse(markdown); const imports = guidanceStandaloneImports(markdown); const prose = unfenced.replace(/`[^`\n]+`/gu, ''); - const inlineImports = [...prose.matchAll(/(?:^|[\s(])@([^\s]+)/gu)] + const inlineImports = [ + ...prose.matchAll(/(?:^|[^\p{L}\p{N}_@])@([^\s]+)/gu), + ] .map((match) => match[1]) .filter( (token) => diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index ce0f7f995..3f39827ae 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -591,3 +591,50 @@ for (const tag of ['template', 'div']) { ); }); } + +for (const punctuation of ['"', "'", '[', '{', ':']) { + test(`rejects inline imports after ${punctuation}`, async (t) => { + assert.match( + ( + await diagnostics(t, { + 'CLAUDE.md': + '@AGENTS.md\n\nRead ' + + punctuation + + '@docs/example.md', + }) + ).join('\n'), + /imports are not allowed/ + ); + }); +} +for (const image of [ + '![Diagram](docs/diagram.png#gh-dark-mode-only)', + '', + '![Diagram][image]\n\n[image]: docs/diagram.png#gh-dark-mode-only', +]) { + test(`image fragments are not Markdown headings: ${image}`, async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': image, + 'docs/diagram.png': 'image fixture', + }), + [] + ); + assert.match( + (await diagnostics(t, { 'AGENTS.md': image })).join('\n'), + /does not exist/ + ); + }); +} + +test('sharing an image reference does not suppress document anchor checks', async (t) => { + assert.match( + ( + await diagnostics(t, { + 'AGENTS.md': + '![Preview][target] [Read][target]\n\n[target]: docs/example.md#missing', + }) + ).join('\n'), + /missing anchor/ + ); +});