From b54dc4c405581e2a707b34478e131bebcecff88f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 22:39:02 +0200 Subject: [PATCH] fix(agents): validate visible headings and spaced paths --- docs/development/agent-workflow.md | 6 ++- tools/skills/agent-guidance-markdown.mjs | 32 ++++++++++++---- tools/skills/validate-agent-guidance.test.mjs | 38 +++++++++++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index ba00a0424..2353f1450 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -37,6 +37,7 @@ Write generic filenames as prose; commands, templates, globs, URLs, package aliases and dotted code symbols are excluded. Bare dotted names with conventional 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. +Explicit relative literal paths may contain spaces; command-option snippets are excluded. Multi-part dotfiles are path candidates too. Conventional extensionless filenames such as Dockerfile, Makefile and LICENSE are also path candidates; use an explicit `./` prefix for other extensionless files. @@ -49,8 +50,9 @@ The parsed HTML tree also verifies that this paragraph is outside HTML container 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. +for GitHub-compatible character filtering and duplicate suffixes. +Only headings present outside inert HTML containers contribute slugs or duplicate counters. +Explicit HTML anchors use `parse5`, excluding comments, scripts, styles and template contents. Rendered HTML anchor hrefs and image sources use the same local-reference checks as Markdown links, including decoded attributes and fragment validation. Image references check file existence without interpreting image fragments as diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index 0768074d9..f6f3171b4 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -148,21 +148,37 @@ export function guidanceStandaloneImports(markdown) { export function guidanceAnchors(markdown) { const slugger = new GithubSlugger(); const found = new Set(); - const html = []; - markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => { - if (token.type === 'heading') { - found.add(slugger.slug(inlineText(token.tokens))); - } - if (token.type === 'html') html.push(token.raw); + const headings = []; + const marker = `data-guidance-${randomUUID()}`; + const renderer = new Marked({ + renderer: { + heading(token) { + const index = headings.push(inlineText(token.tokens)) - 1; + return `${this.parser.parseInline(token.tokens)}\n`; + }, + }, }); - return new Set([...found, ...htmlNavigation(html.join('\n')).anchors]); + const navigation = htmlNavigation(renderer.parse(markdown), (node) => { + const attribute = node.attrs?.find((attr) => attr.name === marker); + if (attribute) + found.add(slugger.slug(headings[Number(attribute.value)])); + }); + return new Set([...found, ...navigation.anchors]); } function isLiteralRepositoryPath(token) { // A typo in the directory or a new root filename must still be checked. // Exclude recognizable prose/code forms instead of allowlisting paths. if (/^(?:@|--|[a-z][a-z\d+.-]*:|\/\/)/iu.test(token)) return false; - if (/[^\p{L}\p{N}_./#-]/u.test(token)) return false; + const explicitRelative = /^(?:\.\/|\.\.\/)/u.test(token); + if ( + (explicitRelative + ? /[^\p{L}\p{N}_./# -]/u + : /[^\p{L}\p{N}_./#-]/u + ).test(token) + ) + return false; + if (/\s+-{1,2}[\p{L}]/u.test(token)) return false; if (token.includes('YYYY-MM-DD') || /(?:^|\/)\.\.\.(?:\/|$)/u.test(token)) return false; const path = token.split('#')[0]; diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index e7c9dde89..488219464 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -883,3 +883,41 @@ for (const content of [ ); }); } + +test('inert headings do not define or consume anchor slugs', async (t) => { + const body = + '\n\n# Visible'; + assert.match( + ( + await diagnostics(t, { + 'AGENTS.md': '[Hidden](docs/example.md#hidden)', + 'docs/example.md': body, + }) + ).join('\n'), + /missing anchor/ + ); + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': '[Visible](docs/example.md#visible)', + 'docs/example.md': body, + }), + [] + ); +}); +test('explicit relative literal paths support spaces', async (t) => { + assert.match( + ( + await diagnostics(t, { + 'AGENTS.md': '`./docs/My Guide.md`', + }) + ).join('\n'), + /does not exist/ + ); + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': '`./docs/My Guide.md`', + 'docs/My Guide.md': '', + }), + [] + ); +});