fix(agents): honor HTML bases and preserve adjacent imports

This commit is contained in:
4gray committed 2026-09-21 03:09:22 +02:00
1 parent 3dbd522d70
commit 464d829538
4 files changed
+86 -11

No files matched your search

+2 -1
View File
@@ -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
+22 -3
View File
@@ -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',
+16 -7
View File
@@ -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(
@@ -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<https://example.com>).@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':
"<iframe srcdoc=\"<base href='docs/'><a href='example.md#repeat'>Go</a>\"></iframe>",
}),
[]
);
assert.ok(
(
await diagnostics(t, {
'AGENTS.md':
"<iframe srcdoc=\"<base href='missing/'><a href='docs/example.md'>Go</a>\"></iframe>",
})
).length > 0
);
});
test('srcdoc bases retain remote and repository containment rules', async (t) => {
assert.deepEqual(
await diagnostics(t, {
'AGENTS.md':
"<iframe srcdoc=\"<base href='https://example.com/'><a href='guide.md'>Go</a>\"></iframe>",
}),
[]
);
assert.ok(
(
await diagnostics(t, {
'AGENTS.md':
"<iframe srcdoc=\"<base href='../'><a href='guide.md'>Go</a>\"></iframe>",
})
).some((message) => message.includes('escapes repository'))
);
});