diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 0fef67919..051e2d2e6 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -61,6 +61,7 @@ URL attributes remove ASCII tabs/newlines throughout and discard surrounding ASCII control/space characters before resolution. Iframe/embed sources and object data attributes are document references and retain Markdown-target anchor checks. Inline iframe srcdoc documents are traversed too, with their own fragment anchors. +The first active HTML base href sets reference resolution, including nested srcdoc bases. Explicit srcset attributes must contain at least one parsed candidate. Image and media references require nonempty targets that resolve to files, not directories. Image references check file existence without interpreting image fragments as @@ -69,7 +70,7 @@ 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. -At-signs inside external URLs are excluded from the import scan. +At-signs inside external URLs are excluded per HTML text node, preserving adjacent imports. Extensionless inline candidates are also imports when they resolve to repository files, checking the full filename before prefixes at ASCII/Unicode prose separators. Declared scoped dependencies, scope wildcards and matching TypeScript path aliases diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index a13e84f18..b55000c9e 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -59,9 +59,14 @@ function decodeEntities(text, attribute = false) { function htmlNavigation(html, inspect = () => {}) { const anchors = []; const references = []; + let baseHref; function visit(node) { if (['script', 'style', 'template'].includes(node.tagName)) return; inspect(node); + if (node.tagName === 'base' && baseHref === undefined) + baseHref = node.attrs?.find( + (attribute) => attribute.name === 'href' + )?.value; for (const attribute of node.attrs ?? []) { if ( attribute.name === 'id' || @@ -97,7 +102,8 @@ function htmlNavigation(html, inspect = () => {}) { for (const reference of embedded.references) references.push( reference.target.startsWith('#') && - !reference.embeddedAnchors + !reference.embeddedAnchors && + !reference.bases?.length ? { ...reference, embeddedAnchors: embedded.anchors, @@ -119,7 +125,16 @@ function htmlNavigation(html, inspect = () => {}) { for (const child of node.childNodes ?? []) visit(child); } visit(parseFragment(html)); - return { anchors, references }; + return { + anchors, + references: + baseHref === undefined + ? references + : references.map((reference) => ({ + ...reference, + bases: [baseHref, ...(reference.bases ?? [])], + })), + }; } export function guidanceProse(markdown) { @@ -130,7 +145,11 @@ export function guidanceProse(markdown) { ) ) return ' '; - if (node.nodeName === '#text') return node.value; + if (node.nodeName === '#text') + return node.value.replace( + /(?:\b[a-z][a-z\d+.-]*:\/\/|\/\/)[^\s]+/giu, + ' ' + ); const content = (node.childNodes ?? []).map(text).join(''); return [ 'address', diff --git a/tools/skills/validate-agent-guidance.mjs b/tools/skills/validate-agent-guidance.mjs index 3381d30d4..cdf1621fa 100644 --- a/tools/skills/validate-agent-guidance.mjs +++ b/tools/skills/validate-agent-guidance.mjs @@ -8,7 +8,7 @@ import { resolve, sep, } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { guidanceAnchors as anchors, guidanceProse, @@ -36,12 +36,24 @@ function within(root, path) { async function validateReference( rootDir, source, - { target, literal, image, unresolvedReference, embeddedAnchors } + { target, literal, image, unresolvedReference, embeddedAnchors, bases } ) { if (unresolvedReference !== undefined) return `${source}: unresolved Markdown reference "${unresolvedReference}"`; - if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/iu.test(target)) return; if (image && !target) return `${source}: empty media target`; + if (bases?.length) { + try { + let base = pathToFileURL(resolve(rootDir, source)); + for (const href of bases) base = new URL(href, base); + const url = new URL(target, base); + if (url.protocol !== 'file:') return; + target = url.pathname + url.search + url.hash; + embeddedAnchors = undefined; + } catch { + return `${source}: malformed HTML base or target: ${target}`; + } + } + if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/iu.test(target)) return; let path; let anchor; try { @@ -189,10 +201,7 @@ export async function validateAgentGuidance({ rootDir }) { diagnostics.push( `${source}: at most ${maxBytes} UTF-8 bytes allowed (received ${bytes})` ); - const prose = guidanceProse(markdown).replace( - /(?:\b[a-z][a-z\d+.-]*:\/\/|\/\/)[^\s]+/giu, - ' ' - ); + const prose = guidanceProse(markdown); const imports = guidanceStandaloneImports(markdown); const inlineImports = []; for (const match of prose.matchAll( diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 9292ab84b..81fb15915 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -1432,3 +1432,49 @@ test('srcdoc anchors are scoped and templates inert', async (t) => { [] ); }); + +test('autolink does not hide adjacent import', async (t) => { + assert.ok( + ( + await diagnostics(t, { + 'CLAUDE.md': + '@AGENTS.md\n\n).@docs/guide.md', + }) + ).some((message) => message.includes('additional or inline')) + ); +}); +test('srcdoc base resolves local document paths', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + "", + }), + [] + ); + assert.ok( + ( + await diagnostics(t, { + 'AGENTS.md': + "", + }) + ).length > 0 + ); +}); + +test('srcdoc bases retain remote and repository containment rules', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + "", + }), + [] + ); + assert.ok( + ( + await diagnostics(t, { + 'AGENTS.md': + "", + }) + ).some((message) => message.includes('escapes repository')) + ); +});