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

+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,
}),
[]
);
});
}