From d6a35cfa31fc4f8da70ce1e0847e05e3a1901550 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 20:57:34 +0200 Subject: [PATCH] fix(agents): validate visible HTML and image source sets --- docs/development/agent-workflow.md | 6 +- package.json | 1 + pnpm-lock.yaml | 8 +++ tools/skills/agent-guidance-markdown.mjs | 40 +++++++++-- tools/skills/project.json | 7 +- tools/skills/validate-agent-guidance.test.mjs | 66 +++++++++++++++++++ 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 353182b13..753ea009a 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -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 diff --git a/package.json b/package.json index 58811896b..534972f64 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0d13254c..7508d824f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index a977efb44..5628c9857 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -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 }); diff --git a/tools/skills/project.json b/tools/skills/project.json index e3d0c63a1..ea6576258 100644 --- a/tools/skills/project.json +++ b/tools/skills/project.json @@ -12,7 +12,12 @@ "{projectRoot}/*.mjs", "{projectRoot}/project.json", { - "externalDependencies": ["marked", "parse5", "github-slugger"] + "externalDependencies": [ + "marked", + "parse5", + "github-slugger", + "parse-srcset" + ] } ], "options": { diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 3f39827ae..6cce18b55 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -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

Read @docs/example.md

', + }) + ).join('\n'), + /imports are not allowed/ + ); +}); +for (const html of [ + '', + '', +]) { + 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': + '', + '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\n\n\n\n@docs/missing.md', + }), + [] + ); +});