fix(agents): handle image fragments and quoted imports

This commit is contained in:
4gray committed 2026-09-20 20:44:46 +02:00
1 parent 87c3e466d2
commit e028712e61
4 files changed
+75 -10

No files matched your search

+3
View File
@@ -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.
+16 -7
View File
@@ -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;
}
+9 -3
View File
@@ -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) =>
@@ -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)',
'<img src="docs/diagram.png#gh-light-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/
);
});