fix(agents): parse prose and rendered HTML anchors

This commit is contained in:
4gray committed 2026-09-20 19:44:07 +02:00
1 parent 1206f0d255
commit bdf7017a97
7 files changed
+96 -35

No files matched your search

+4 -3
View File
@@ -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
+1
View File
@@ -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",
+3
View File
@@ -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
+41 -9
View File
@@ -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) {
+1 -1
View File
@@ -12,7 +12,7 @@
"{projectRoot}/*.mjs",
"{projectRoot}/project.json",
{
"externalDependencies": ["marked"]
"externalDependencies": ["marked", "parse5"]
}
],
"options": {
+2 -22
View File
@@ -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]
);
@@ -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 [
'<!-- <a id="old"></a> -->',
`<script>const s = '<a id="old"></a>';</script>`,
'<template><a id="old"></a></template>',
]) {
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 &amp; B', 'a--b'],
['Caf&eacute; &#x41; &#66;', '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,
}),
[]
);
});
}