fix(agents): exclude HTML-contained guidance imports

This commit is contained in:
4gray committed 2026-09-20 20:32:23 +02:00
1 parent 84596ecb41
commit 87c3e466d2
3 files changed
+45 -2

No files matched your search

+3
View File
@@ -42,6 +42,9 @@ separately so encoded filename delimiters stay in the filename. Fenced and inden
do not count as root guidance imports or satisfy the required Claude import.
The required Claude import must be an unformatted standalone line in a top-level
paragraph; headings, quotes and list items do not satisfy it.
The parsed HTML tree also verifies that this paragraph is outside HTML containers,
including templates split across Markdown tokens. Generated HTML is inspected
in memory only; it is never executed or emitted.
Heading anchors decode HTML character references in text and use `github-slugger`
for GitHub-compatible character filtering and duplicate suffixes. Explicit HTML anchors
use `parse5`, excluding comments, scripts, styles and template contents.
+23 -2
View File
@@ -2,7 +2,7 @@ import GithubSlugger from 'github-slugger';
import { Marked, Tokenizer } from 'marked';
import { parseFragment } from 'parse5';
// Lex only: no Markdown is rendered and no HTML is sanitized or re-emitted.
// Inspection only: generated HTML is parsed in memory, never executed or emitted.
const markdownLexer = new Marked({
tokenizer: {
reflink(source, links) {
@@ -84,13 +84,34 @@ export function guidanceProse(markdown) {
}
export function guidanceStandaloneImports(markdown) {
return markdownLexer
const candidates = markdownLexer
.lexer(markdown)
.filter((token) => token.type === 'paragraph')
.flatMap((token) => [
...token.raw.matchAll(/^ {0,3}@([^\s]+)[\t ]*$/gmu),
])
.map((match) => match[1]);
// Markdown can split an HTML container across several top-level tokens.
// Check the parsed output tree as well as raw source formatting. Only text
// directly inside a root paragraph can supply the standalone directive.
const document = parseFragment(new Marked().parse(markdown));
const visible = new Map();
for (const node of document.childNodes) {
if (node.tagName !== 'p') continue;
const text = node.childNodes
.map((child) =>
child.nodeName === '#text' ? child.value : '\uFFFC'
)
.join('');
for (const match of text.matchAll(/^ {0,3}@([^\s]+)[\t ]*$/gmu))
visible.set(match[1], (visible.get(match[1]) ?? 0) + 1);
}
return candidates.filter((candidate) => {
const count = visible.get(candidate) ?? 0;
if (!count) return false;
visible.set(candidate, count - 1);
return true;
});
}
export function guidanceAnchors(markdown) {
@@ -572,3 +572,22 @@ for (const body of [
);
});
}
for (const tag of ['template', 'div']) {
test(`HTML container cannot supply Claude import: ${tag}`, async (t) => {
assert.match(
(
await diagnostics(t, {
'CLAUDE.md': `<${tag}>\n\n@AGENTS.md\n\n</${tag}>`,
})
).join('\n'),
/standalone @AGENTS.md/
);
assert.deepEqual(
await diagnostics(t, {
'CLAUDE.md': `<${tag}>\n\nExample\n\n</${tag}>\n\n@AGENTS.md`,
}),
[]
);
});
}