From 8467b9406a950848e9239901ed8705d07e6e0cea Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 18:36:14 +0200 Subject: [PATCH] fix(agents): parse guidance navigation with Markdown tokens --- docs/architecture/m3u-playlist-module.md | 2 +- docs/development/agent-workflow.md | 4 + tools/skills/agent-guidance-markdown.mjs | 97 +++++++++++++ tools/skills/project.json | 11 +- tools/skills/validate-agent-guidance.mjs | 72 ++-------- tools/skills/validate-agent-guidance.test.mjs | 129 ++++++++++++++++++ 6 files changed, 251 insertions(+), 64 deletions(-) create mode 100644 tools/skills/agent-guidance-markdown.mjs diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index fe076c6fa..8ae92d2e7 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1827,7 +1827,7 @@ and playback positions; no deletion happens on merely opening the dialog. Electron only: a `.m3u`/`.m3u8` path passed on the command line, opened through a file association, or delivered by macOS' `open-file` event is normalized to an absolute path in the main process -(`services/playlist-open-request.ts`) and queued there. The renderer +(`apps/electron-backend/src/app/services/playlist-open-request.ts`) and queued there. The renderer (`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the `OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is what makes the main process flush. `OPEN_FILE` is the only way out of the diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 1bc1ac3a8..cba31b279 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -29,6 +29,10 @@ Later normal edits maintain the canonical docs, not duplicate historical prose. Run `pnpm run agents:validate` after guidance changes. It checks line/byte budgets, root imports and local navigation links/anchors, including migration destinations. +Markdown navigation is parsed with the already-declared `marked` dependency; +undefined explicit reference links are errors, and code examples are excluded. +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. ## Protected Markdown edits diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs new file mode 100644 index 000000000..b2a0cc45d --- /dev/null +++ b/tools/skills/agent-guidance-markdown.mjs @@ -0,0 +1,97 @@ +import { Marked, Tokenizer } from 'marked'; + +// Lex only: no Markdown is rendered and no HTML is sanitized or re-emitted. +const markdownLexer = new Marked({ + tokenizer: { + reflink(source, links) { + const token = Tokenizer.prototype.reflink.call(this, source, links); + if (token?.type !== 'text') return token; + + // Marked otherwise turns unresolved references into ordinary text. + // Reuse its grammar, retaining only explicit full/collapsed forms; + // a bare [word] without a definition remains ordinary prose. + const full = this.rules.inline.reflink.exec(source); + const collapsed = this.rules.inline.nolink.exec(source); + const match = + full ?? (collapsed?.[0].endsWith('[]') ? collapsed : undefined); + if (!match) return token; + return { + type: 'unresolved-reference', + raw: match[0], + text: match[0], + label: match[2] || match[1], + }; + }, + }, +}); + +function inlineText(tokens) { + return tokens + .map((token) => { + if (token.type === 'html') return ''; + if (token.tokens) return inlineText(token.tokens); + return token.text ?? ''; + }) + .join(''); +} + +export function guidanceAnchors(markdown) { + const found = new Set(); + const explicit = new Set(); + markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => { + if (token.type === 'heading') { + const slug = inlineText(token.tokens) + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}_\-\s]/gu, '') + .replace(/\s/gu, '-'); + let unique = slug; + let suffix = 0; + 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]); + } + }); + return new Set([...found, ...explicit]); +} + +function isLiteralRepositoryPath(token) { + const local = + /^(?:apps|libs|docs|tools|patches|\.codex|\.claude|\.github|\.changes|\.plans)\//u.test( + token + ) || + /^(?:AGENTS\.md|CLAUDE\.md|README\.md|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|nx\.json|tsconfig\.base\.json|eslint\.config\.mjs|\.nvmrc)$/u.test( + token + ); + return ( + local && !/[\s*?[\]{}<>|]/u.test(token) && !token.includes('YYYY-MM-DD') + ); +} + +export function guidanceReferences(markdown, includeLiterals) { + const result = []; + const destinations = new Set(); + function add(target, literal = false) { + const key = `${literal}:${target}`; + if (!destinations.has(key)) { + destinations.add(key); + result.push({ target, literal }); + } + } + markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => { + if (['link', 'image', 'def'].includes(token.type)) add(token.href); + if (token.type === 'unresolved-reference') + result.push({ unresolvedReference: token.label }); + if ( + includeLiterals && + token.type === 'codespan' && + isLiteralRepositoryPath(token.text) + ) + add(token.text, true); + }); + return result; +} diff --git a/tools/skills/project.json b/tools/skills/project.json index 4ac238238..b84860ef4 100644 --- a/tools/skills/project.json +++ b/tools/skills/project.json @@ -8,7 +8,13 @@ "test": { "executor": "nx:run-commands", "cache": true, - "inputs": ["{projectRoot}/*.mjs", "{projectRoot}/project.json"], + "inputs": [ + "{projectRoot}/*.mjs", + "{projectRoot}/project.json", + { + "externalDependencies": ["marked"] + } + ], "options": { "command": "node --test tools/skills/validate-repository-skills.test.mjs tools/skills/validate-agent-guidance.test.mjs", "cwd": "{workspaceRoot}" @@ -21,7 +27,8 @@ "node --check tools/skills/validate-repository-skills.mjs", "node --check tools/skills/validate-repository-skills.test.mjs", "node --check tools/skills/validate-agent-guidance.mjs", - "node --check tools/skills/validate-agent-guidance.test.mjs" + "node --check tools/skills/validate-agent-guidance.test.mjs", + "node --check tools/skills/agent-guidance-markdown.mjs" ], "cwd": "{workspaceRoot}" }, diff --git a/tools/skills/validate-agent-guidance.mjs b/tools/skills/validate-agent-guidance.mjs index ee0a986d5..116f83780 100644 --- a/tools/skills/validate-agent-guidance.mjs +++ b/tools/skills/validate-agent-guidance.mjs @@ -1,6 +1,10 @@ import { readFile, realpath } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + guidanceAnchors as anchors, + guidanceReferences as references, +} from './agent-guidance-markdown.mjs'; const SURFACES = [ 'AGENTS.md', @@ -38,67 +42,13 @@ function withoutFences(markdown) { .join('\n'); } -function anchors(markdown) { - const found = new Set(); - for (const match of withoutFences(markdown).matchAll( - /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gmu - )) { - const slug = match[1] - .replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1') - .replace(/<[^>]*>/gu, '') - .toLowerCase() - .replace(/[^\p{L}\p{M}\p{N}_\-\s]/gu, '') - .replace(/\s/gu, '-'); - let unique = slug; - let suffix = 0; - while (found.has(unique)) unique = `${slug}-${++suffix}`; - found.add(unique); - } - for (const match of markdown.matchAll( - /<(?:a|[a-z][\w-]*)\b[^>]*\b(?:id|name)=["']([^"']+)["']/giu - )) { - found.add(match[1]); - } - return found; -} - -function references(markdown, includeLiterals) { - const text = withoutFences(markdown); - const result = []; - // Inline destinations may use angle brackets or an optional quoted title. - const prose = text.replace(/`[^`\n]+`/gu, ''); - for (const match of prose.matchAll( - /\[[^\]\n]*\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+["'][^\n]*?["'])?\s*\)/gu - )) { - result.push({ target: match[1] ?? match[2], literal: false }); - } - for (const match of prose.matchAll( - /^\s{0,3}\[[^\]]+\]:\s*(?:<([^>]+)>|(\S+))/gmu - )) { - result.push({ target: match[1] ?? match[2], literal: false }); - } - if (includeLiterals) { - for (const match of text.matchAll(/`([^`\r\n]+)`/gu)) { - const token = match[1]; - const local = - /^(?:apps|libs|docs|tools|patches|\.codex|\.claude|\.github|\.changes|\.plans)\//u.test( - token - ) || - /^(?:AGENTS\.md|CLAUDE\.md|README\.md|package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|nx\.json|tsconfig\.base\.json|eslint\.config\.mjs|\.nvmrc)$/u.test( - token - ); - if ( - local && - !/[\s*?[\]{}<>|]/u.test(token) && - !token.includes('YYYY-MM-DD') - ) - result.push({ target: token, literal: true }); - } - } - return result; -} - -async function validateReference(rootDir, source, { target, literal }) { +async function validateReference( + rootDir, + source, + { target, literal, unresolvedReference } +) { + if (unresolvedReference !== undefined) + return `${source}: unresolved Markdown reference "${unresolvedReference}"`; if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/iu.test(target)) return; let decoded; try { diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index 1cb3c5c1f..a9669c411 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -181,3 +181,132 @@ test('requires all four guidance surfaces', async (t) => { /agent-context-map.md.*missing/ ); }); + +test('balanced parentheses and formatted labels retain complete destinations', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + '[Read **[the guide]**](docs/example(1).md#hello-world)\n', + 'docs/example(1).md': '# Hello, `World`!\n', + }), + [] + ); + const errors = await diagnostics(t, { + 'AGENTS.md': '[Read **[the guide]**](docs/missing(1).md)\n', + }); + assert.match(errors.join('\n'), /does not exist: docs\/missing\(1\)\.md/); +}); + +test('angle and escaped Markdown destinations resolve literal filenames', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + '[Angle]()\n[Escaped](docs/example\\(1\\).md)\n', + 'docs/guide (one).md': '# Guide\n', + 'docs/example(1).md': '# Example\n', + }), + [] + ); +}); + +test('reference definitions resolve full, collapsed and shortcut links', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + '[Read **guide**][GUIDE]\n[Guide][]\n[Guide]\n\n[guide]: "Title"\n', + 'docs/example(1).md': '# Hello, `World`!\n', + }), + [] + ); + const errors = await diagnostics(t, { + 'AGENTS.md': '[Read guide][guide]\n\n[guide]: docs/missing(1).md\n', + }); + assert.match(errors.join('\n'), /does not exist: docs\/missing\(1\)\.md/); +}); + +for (const reference of [ + '[Guide][missing]', + '[Guide][]', + '[**Guide**][missing]', + '[Guide `code`][missing]', + '![Guide][missing]', +]) { + test(`diagnoses unresolved explicit reference ${reference}`, async (t) => { + assert.match( + (await diagnostics(t, { 'AGENTS.md': reference + '\n' })).join( + '\n' + ), + /unresolved.*reference/i + ); + }); +} + +test('lexer visits navigation inside tables, lists, and blockquotes', async (t) => { + const errors = await diagnostics(t, { + 'AGENTS.md': + '| Guide |\n| --- |\n| [Table](docs/table(1).md) |\n\n- [List](docs/list(1).md)\n\n> [Quote][missing]\n', + }); + assert.equal(errors.length, 3); + assert.match(errors.join('\n'), /docs\/table\(1\)\.md/); + assert.match(errors.join('\n'), /docs\/list\(1\)\.md/); + assert.match(errors.join('\n'), /unresolved.*reference/i); +}); + +test('fenced, indented, inline and escaped examples are not navigation', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': [ + '```md', + '[Code](missing.md)', + '[Code][missing]', + '```', + '', + ' [Indented](missing.md)', + ' [Indented][]', + '', + '`[Inline](missing.md)` and ``[Code `label`][missing]``.', + '\\[Escaped][missing]', + ].join('\n'), + }), + [] + ); +}); + +test('heading anchors use nested token text, link labels and code spans', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + '[Heading](docs/example.md#a-bold-emphasis-label-code-hi)\n[Duplicate](docs/example.md#a-bold-emphasis-label-code-hi-1)', + 'docs/example.md': + '# A **bold *emphasis*** [Label `code`](one(two).md) Hi\n\n# A **bold *emphasis*** [Label `code`](one(two).md) Hi\n', + }), + [] + ); +}); + +test('HTML heading tokens are ignored without rendering or tag stripping', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': + '[HTML](docs/example.md#safe-heading)\n[Nested](docs/example.md#unsafe-scriptalert1script)', + 'docs/example.md': + '# Safe Heading\n\n# Unsafe ipt>alert(1)ipt>\n', + }), + [] + ); +}); + +test('explicit HTML anchors work but anchors in code examples do not', async (t) => { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': '[Anchor](docs/example.md#explicit)', + 'docs/example.md': '\n', + }), + [] + ); + const errors = await diagnostics(t, { + 'AGENTS.md': '[Fake](docs/example.md#fake)', + 'docs/example.md': '```html\n\n```\n', + }); + assert.match(errors.join('\n'), /missing anchor/); +});