fix(agents): validate visible HTML and image source sets

This commit is contained in:
4gray committed 2026-09-20 20:57:34 +02:00
1 parent e028712e61
commit d6a35cfa31
6 files changed
+119 -9

No files matched your search

+5 -1
View File
@@ -53,7 +53,11 @@ 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.
The import scan includes visible HTML text and excludes code and non-rendered containers.
Image source sets use `parse-srcset` to check each candidate URL. Root-relative
literals never suppress source-relative definition checks.
The Nx test hash includes `marked`, `parse5`, `github-slugger` and `parse-srcset`
so dependency changes invalidate parser coverage.
It cannot prove semantic equivalence; review changed contracts as well.
## Protected Markdown edits
+1
View File
@@ -233,6 +233,7 @@
"node-gyp": "12.4.0",
"nx": "23.2.1",
"nx-electron": "22.0.0",
"parse-srcset": "1.0.2",
"parse5": "8.0.1",
"prettier": "^3.9.6",
"sharp": "0.35.4",
+8
View File
@@ -458,6 +458,9 @@ importers:
nx-electron:
specifier: 22.0.0
version: 22.0.0(patch_hash=4d5ac9c5b10268dcc40998d7a2b166d004105ae7875fa182130d6810871a1e84)(@nx/devkit@23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))))(@nx/workspace@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))(@swc/core@1.16.1(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.2)(rxjs@7.8.2)(typescript@6.0.3)
parse-srcset:
specifier: 1.0.2
version: 1.0.2
parse5:
specifier: 8.0.1
version: 8.0.1
@@ -8030,6 +8033,9 @@ packages:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parse5-html-rewriting-stream@8.0.1:
resolution: {integrity: sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==}
@@ -18621,6 +18627,8 @@ snapshots:
parse-node-version@1.0.1:
optional: true
parse-srcset@1.0.2: {}
parse5-html-rewriting-stream@8.0.1:
dependencies:
entities: 8.0.0
+33 -7
View File
@@ -1,3 +1,4 @@
import parseSrcset from 'parse-srcset';
import GithubSlugger from 'github-slugger';
import { Marked, Tokenizer } from 'marked';
import { parseFragment } from 'parse5';
@@ -69,6 +70,12 @@ function htmlNavigation(html) {
target: attribute.value,
image: node.tagName === 'img',
});
if (
['img', 'source'].includes(node.tagName) &&
attribute.name === 'srcset'
)
for (const candidate of parseSrcset(attribute.value))
references.push({ target: candidate.url, image: true });
}
for (const child of node.childNodes ?? []) visit(child);
}
@@ -77,13 +84,32 @@ function htmlNavigation(html) {
}
export function guidanceProse(markdown) {
function prose(token) {
if (['code', 'codespan', 'html'].includes(token.type)) return '';
if (token.items) return token.items.map(prose).join('\n');
if (token.tokens) return token.tokens.map(prose).join('');
return token.text ?? '';
function text(node) {
if (
['script', 'style', 'template', 'pre', 'code'].includes(
node.tagName
)
)
return ' ';
if (node.nodeName === '#text') return node.value;
const content = (node.childNodes ?? []).map(text).join('');
return [
'p',
'li',
'blockquote',
'div',
'br',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
].includes(node.tagName)
? content + '\n'
: content;
}
return markdownLexer.lexer(markdown).map(prose).join('\n');
return text(parseFragment(new Marked().parse(markdown)));
}
export function guidanceStandaloneImports(markdown) {
@@ -164,7 +190,7 @@ export function guidanceReferences(markdown, includeLiterals) {
const usedTargets = new Set();
function add(target, literal = false, image = false) {
const key = `${literal}:${image}:${target}`;
usedTargets.add(target);
if (!literal) usedTargets.add(target);
if (!destinations.has(key)) {
destinations.add(key);
result.push({ target, literal, image });
+6 -1
View File
@@ -12,7 +12,12 @@
"{projectRoot}/*.mjs",
"{projectRoot}/project.json",
{
"externalDependencies": ["marked", "parse5", "github-slugger"]
"externalDependencies": [
"marked",
"parse5",
"github-slugger",
"parse-srcset"
]
}
],
"options": {
@@ -638,3 +638,69 @@ test('sharing an image reference does not suppress document anchor checks', asyn
/missing anchor/
);
});
test('root literals cannot suppress source-relative definitions', async (t) => {
assert.match(
(
await diagnostics(t, {
[map]: '`README.md`\n\n[unused]: README.md',
'README.md': '# Root',
})
).join('\n'),
/does not exist: README.md/
);
});
test('visible HTML text cannot hide inline imports', async (t) => {
assert.match(
(
await diagnostics(t, {
'CLAUDE.md': '@AGENTS.md\n\n<p>Read @docs/example.md</p>',
})
).join('\n'),
/imports are not allowed/
);
});
for (const html of [
'<picture><source srcset="docs/dark.png"><img src="docs/light.png"></picture>',
'<img src="docs/light.png" srcset="docs/light.png 1x, docs/dark.png 2x">',
]) {
test(`validates every srcset asset: ${html}`, async (t) => {
assert.match(
(
await diagnostics(t, {
'AGENTS.md': html,
'docs/light.png': '',
})
).join('\n'),
/does not exist: docs\/dark.png/
);
assert.deepEqual(
await diagnostics(t, {
'AGENTS.md': html,
'docs/light.png': '',
'docs/dark.png': '',
}),
[]
);
});
}
test('srcset data URLs do not become local filenames', async (t) => {
assert.deepEqual(
await diagnostics(t, {
'AGENTS.md':
'<img srcset="data:image/png;base64,AAAA 1x, docs/image.png 2x">',
'docs/image.png': '',
}),
[]
);
});
test('non-rendered HTML text and code do not introduce imports', async (t) => {
assert.deepEqual(
await diagnostics(t, {
'CLAUDE.md':
'@AGENTS.md\n\n<!-- @docs/missing.md -->\n<script>@docs/missing.md</script>\n<style>@docs/missing.md</style>\n<template>@docs/missing.md</template>\n<code>@docs/missing.md</code>',
}),
[]
);
});