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