From bdf7017a97bba0c611ea9d4bc15d1c2b31b82c8b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 19:44:07 +0200 Subject: [PATCH] fix(agents): parse prose and rendered HTML anchors --- docs/development/agent-workflow.md | 7 +-- package.json | 1 + pnpm-lock.yaml | 3 ++ tools/skills/agent-guidance-markdown.mjs | 50 +++++++++++++++---- tools/skills/project.json | 2 +- tools/skills/validate-agent-guidance.mjs | 24 +-------- tools/skills/validate-agent-guidance.test.mjs | 44 ++++++++++++++++ 7 files changed, 96 insertions(+), 35 deletions(-) diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 653e06eb3..3be60660c 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -38,10 +38,11 @@ aliases and dotted code symbols are excluded. Bare dotted names with conventiona file suffixes (such as .md, .json or .ts) are treated as filenames. Use a `./` prefix or Markdown link for other ambiguous filenames that resemble code symbols. Multi-part dotfiles are path candidates too. Link paths and fragments are decoded -separately so encoded filename delimiters stay in the filename. Fenced examples +separately so encoded filename delimiters stay in the filename. Fenced and indented examples do not count as root guidance imports or satisfy the required Claude import. -Heading anchors are derived from parsed text, not HTML sanitization. The Nx test -hash includes `marked` so dependency changes invalidate parser coverage. +Heading anchors decode HTML character references in text. Explicit HTML anchors +use `parse5`, excluding comments, scripts, styles and template contents. +The Nx test hash includes `marked` and `parse5` 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 15f8f0fc9..56c00254a 100644 --- a/package.json +++ b/package.json @@ -232,6 +232,7 @@ "node-gyp": "12.4.0", "nx": "23.2.1", "nx-electron": "22.0.0", + "parse5": "8.0.1", "prettier": "^3.9.6", "sharp": "0.35.4", "tailwindcss": "^3.4.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e0501001..890e6fe30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,6 +455,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) + parse5: + specifier: 8.0.1 + version: 8.0.1 prettier: specifier: ^3.9.6 version: 3.9.6 diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index 6be92b437..0e7aa2d34 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -1,4 +1,5 @@ import { Marked, Tokenizer } from 'marked'; +import { parseFragment } from 'parse5'; // Lex only: no Markdown is rendered and no HTML is sanitized or re-emitted. const markdownLexer = new Marked({ @@ -34,14 +35,50 @@ function inlineText(tokens) { .map((token) => { if (token.type === 'html') return ''; if (token.tokens) return inlineText(token.tokens); - return token.text ?? ''; + return token.type === 'text' + ? decodeEntities(token.text ?? '') + : (token.text ?? ''); }) .join(''); } +function decodeEntities(text) { + return text.replace( + /&(?:#(?:x[0-9a-f]+|[0-9]+)|[a-z][a-z0-9]+);/giu, + (entity) => parseFragment(entity).childNodes[0]?.value ?? entity + ); +} + +function htmlAnchors(html) { + const found = []; + function visit(node) { + if (['script', 'style', 'template'].includes(node.tagName)) return; + for (const attribute of node.attrs ?? []) { + if ( + attribute.name === 'id' || + (node.tagName === 'a' && attribute.name === 'name') + ) + found.push(attribute.value); + } + for (const child of node.childNodes ?? []) visit(child); + } + visit(parseFragment(html)); + return found; +} + +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 ?? ''; + } + return markdownLexer.lexer(markdown).map(prose).join('\n'); +} + export function guidanceAnchors(markdown) { const found = new Set(); - const explicit = new Set(); + const html = []; markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => { if (token.type === 'heading') { const slug = inlineText(token.tokens) @@ -53,14 +90,9 @@ export function guidanceAnchors(markdown) { while (found.has(unique)) unique = `${slug}-${++suffix}`; found.add(unique); } - if (token.type === 'html') { - for (const match of token.raw.matchAll( - /<(?:a|[a-z][\w-]*)\b[^>]*\b(?:id|name)=["']([^"']+)["']/giu - )) - explicit.add(match[1]); - } + if (token.type === 'html') html.push(token.raw); }); - return new Set([...found, ...explicit]); + return new Set([...found, ...htmlAnchors(html.join('\n'))]); } function isLiteralRepositoryPath(token) { diff --git a/tools/skills/project.json b/tools/skills/project.json index b84860ef4..0a614e4a7 100644 --- a/tools/skills/project.json +++ b/tools/skills/project.json @@ -12,7 +12,7 @@ "{projectRoot}/*.mjs", "{projectRoot}/project.json", { - "externalDependencies": ["marked"] + "externalDependencies": ["marked", "parse5"] } ], "options": { diff --git a/tools/skills/validate-agent-guidance.mjs b/tools/skills/validate-agent-guidance.mjs index fc8e24823..aaf89f005 100644 --- a/tools/skills/validate-agent-guidance.mjs +++ b/tools/skills/validate-agent-guidance.mjs @@ -3,6 +3,7 @@ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { guidanceAnchors as anchors, + guidanceProse, guidanceReferences as references, } from './agent-guidance-markdown.mjs'; @@ -21,27 +22,6 @@ function within(root, path) { ); } -// Deliberately scoped to authored guidance, not a general Markdown crawler. -function withoutFences(markdown) { - let fence; - return markdown - .split(/\r?\n/u) - .map((line) => { - const match = /^\s{0,3}(`{3,}|~{3,})/u.exec(line); - if (match) { - if (!fence) fence = match[1]; - else if ( - match[1][0] === fence[0] && - match[1].length >= fence.length - ) - fence = undefined; - return ''; - } - return fence ? '' : line; - }) - .join('\n'); -} - async function validateReference( rootDir, source, @@ -109,7 +89,7 @@ export async function validateAgentGuidance({ rootDir }) { diagnostics.push( `${source}: at most ${maxBytes} UTF-8 bytes allowed (received ${bytes})` ); - const unfenced = withoutFences(markdown); + const unfenced = guidanceProse(markdown); const imports = [...unfenced.matchAll(/^\s*@([^\s]+)\s*$/gmu)].map( (match) => match[1] ); diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 038846954..165d9f606 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -454,3 +454,47 @@ for (const [encoded, filename] of [ ); }); } + +for (const source of ['AGENTS.md', 'CLAUDE.md']) { + test(`${source}: indented decorators are not imports`, async (t) => { + assert.deepEqual( + await diagnostics(t, { + [source]: + (source === 'CLAUDE.md' ? '@AGENTS.md\n\n' : '') + + ' @Injectable()\n @docs/missing.md\n', + }), + [] + ); + }); +} +for (const body of [ + '', + ``, + '', +]) { + test(`non-rendered HTML does not define anchors: ${body}`, async (t) => { + assert.match( + ( + await diagnostics(t, { + 'AGENTS.md': '[Old](docs/example.md#old)', + 'docs/example.md': body, + }) + ).join('\n'), + /missing anchor/ + ); + }); +} +for (const [heading, anchor] of [ + ['A & B', 'a--b'], + ['Café A B', 'cafĂ©-a-b'], +]) { + test(`heading entities produce rendered anchors: ${heading}`, async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': `[Heading](docs/example.md#${anchor})`, + 'docs/example.md': '# ' + heading, + }), + [] + ); + }); +}