From 1206f0d25560218ac041e196e58d202cc292a866 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 19:30:55 +0200 Subject: [PATCH] fix(agents): handle fenced imports and encoded paths --- docs/development/agent-workflow.md | 3 ++ tools/skills/agent-guidance-markdown.mjs | 2 +- tools/skills/validate-agent-guidance.mjs | 16 ++++--- tools/skills/validate-agent-guidance.test.mjs | 48 +++++++++++++++++++ 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 126fefa30..653e06eb3 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -37,6 +37,9 @@ 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. +Multi-part dotfiles are path candidates too. Link paths and fragments are decoded +separately so encoded filename delimiters stay in the filename. Fenced 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. It cannot prove semantic equivalence; review changed contracts as well. diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index cc5e89ab7..6be92b437 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -83,7 +83,7 @@ function isLiteralRepositoryPath(token) { return false; return ( path.includes('/') || - /^(?:\.[\p{L}\p{N}_-]+|[\p{L}\p{N}_-][\p{L}\p{N}_.-]*\.[\p{L}][\p{L}\p{N}_-]*)$/u.test( + /^(?:\.[\p{L}\p{N}_-][\p{L}\p{N}_.-]*|[\p{L}\p{N}_-][\p{L}\p{N}_.-]*\.[\p{L}][\p{L}\p{N}_-]*)$/u.test( path ) ); diff --git a/tools/skills/validate-agent-guidance.mjs b/tools/skills/validate-agent-guidance.mjs index 116f83780..fc8e24823 100644 --- a/tools/skills/validate-agent-guidance.mjs +++ b/tools/skills/validate-agent-guidance.mjs @@ -50,14 +50,17 @@ async function validateReference( if (unresolvedReference !== undefined) return `${source}: unresolved Markdown reference "${unresolvedReference}"`; if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/iu.test(target)) return; - let decoded; + let path; + let anchor; try { - decoded = decodeURIComponent(target); + const hash = target.indexOf('#'); + const pathAndQuery = hash < 0 ? target : target.slice(0, hash); + path = decodeURIComponent(pathAndQuery.split('?')[0]); + anchor = + hash < 0 ? undefined : decodeURIComponent(target.slice(hash + 1)); } catch { return `${source}: malformed local link: ${target}`; } - const [pathAndQuery, anchor] = decoded.split('#'); - const path = pathAndQuery.split('?')[0]; const absolute = path ? resolve(literal ? rootDir : dirname(resolve(rootDir, source)), path) : resolve(rootDir, source); @@ -106,10 +109,11 @@ export async function validateAgentGuidance({ rootDir }) { diagnostics.push( `${source}: at most ${maxBytes} UTF-8 bytes allowed (received ${bytes})` ); - const imports = [...markdown.matchAll(/^\s*@([^\s]+)\s*$/gmu)].map( + const unfenced = withoutFences(markdown); + const imports = [...unfenced.matchAll(/^\s*@([^\s]+)\s*$/gmu)].map( (match) => match[1] ); - const prose = withoutFences(markdown).replace(/`[^`\n]+`/gu, ''); + const prose = unfenced.replace(/`[^`\n]+`/gu, ''); const inlineImports = [...prose.matchAll(/(?:^|[\s(])@([^\s]+)/gu)] .map((match) => match[1]) .filter( diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 5a2a377d8..038846954 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -172,6 +172,8 @@ for (const path of [ 'missing.scss', 'custom.config-format', '.custom-config', + '.env.local', + '.eslintrc.json', 'arbitrary/directory', ]) { test(`checks generic literal repository path ${path}`, async (t) => { @@ -406,3 +408,49 @@ test('explicit HTML anchors work but anchors in code examples do not', async (t) }); assert.match(errors.join('\n'), /missing anchor/); }); + +for (const source of ['AGENTS.md', 'CLAUDE.md']) { + test(`${source}: fenced decorators are not root imports`, async (t) => { + const prefix = source === 'CLAUDE.md' ? '@AGENTS.md\n' : ''; + assert.deepEqual( + await diagnostics(t, { + [source]: + prefix + '```ts\n@Injectable()\n@docs/missing.md\n```\n', + }), + [] + ); + }); +} +test('a fenced import cannot satisfy the required CLAUDE import', async (t) => { + assert.match( + ( + await diagnostics(t, { + 'CLAUDE.md': '```text\n@AGENTS.md\n```\n', + }) + ).join('\n'), + /standalone @AGENTS.md/ + ); +}); +for (const [encoded, filename] of [ + ['guide%23one.md', 'guide#one.md'], + ['guide%3Fone.md', 'guide?one.md'], +]) { + test(`encoded filename delimiters stay in path: ${encoded}`, async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': `[Doc](docs/${encoded}?view=1#hello%2Dworld)`, + [`docs/${filename}`]: '# Hello World\n', + }), + [] + ); + assert.match( + ( + await diagnostics(t, { + 'AGENTS.md': `[Doc](docs/${encoded}#missing)`, + [`docs/${filename}`]: '# Hello World\n', + }) + ).join('\n'), + /missing anchor/ + ); + }); +}