diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index f3e77705a..891a2e27b 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -58,7 +58,9 @@ 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. -HTML video, audio and source `src` assets use the same existence checks as images. +HTML video, audio, source and track `src` assets and video posters use the same +existence checks as images. Entity decoding uses full HTML text/attribute rules, +including references whose semicolon may be omitted. Inline guidance imports are rejected after punctuation as well as whitespace. Declared scoped dependencies, scope wildcards and matching TypeScript path aliases are recognized as package/alias mentions. Traversal and document-file imports are diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index 860824136..089b0ba53 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -45,11 +45,15 @@ function inlineText(tokens) { .join(''); } -function decodeEntities(text) { - return text.replace( - /&(?:#(?:x[0-9a-f]+|[0-9]+)|[a-z][a-z0-9]+);/giu, - (entity) => parseFragment(entity).childNodes[0]?.value ?? entity - ); +function decodeEntities(text, attribute = false) { + if (attribute) { + const html = ``; + return parseFragment(html).childNodes[0].attrs[0].value; + } + // RCDATA decodes the full HTML character-reference grammar without + // interpreting literal tags. The prefix preserves an initial newline. + const html = ``; + return parseFragment(html).childNodes[0].childNodes[0].value.slice(1); } function htmlNavigation(html, inspect = () => {}) { @@ -66,8 +70,11 @@ function htmlNavigation(html, inspect = () => {}) { anchors.push(attribute.value); if ( (node.tagName === 'a' && attribute.name === 'href') || - (['img', 'video', 'audio', 'source'].includes(node.tagName) && - attribute.name === 'src') + (['img', 'video', 'audio', 'source', 'track'].includes( + node.tagName + ) && + attribute.name === 'src') || + (node.tagName === 'video' && attribute.name === 'poster') ) references.push({ target: attribute.value, @@ -216,7 +223,7 @@ export function guidanceReferences(markdown, includeLiterals) { markdownLexer.walkTokens(tokens, (token) => { if (token.type === 'def') mark(token, { - target: decodeEntities(token.href), + target: decodeEntities(token.href, true), definition: true, }); else if (token.type === 'unresolved-reference') diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 0da056b23..941f74a3a 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -955,3 +955,39 @@ for (const html of [ ); }); } + +for (const heading of ['A© B', 'A© B', 'A© B']) { + test(`semicolonless heading entities decode: ${heading}`, async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': '[Heading](docs/example.md#a-b)', + 'docs/example.md': '# ' + heading, + }), + [] + ); + }); +} +test('semicolonless definition entities match rendered destinations', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': '[ref]: docs/a©.md', + 'docs/a©.md': '', + }), + [] + ); +}); +for (const html of [ + '', + '', +]) { + test(`remaining media assets are checked: ${html}`, async (t) => { + assert.match( + (await diagnostics(t, { 'AGENTS.md': html })).join('\n'), + /does not exist/ + ); + assert.deepEqual( + await diagnostics(t, { 'AGENTS.md': html, 'docs/asset.png': '' }), + [] + ); + }); +}