mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(agents): parse guidance navigation with Markdown tokens
This commit is contained in:
1 parent
3a2497476b
commit
8467b9406a
6 files changed
+251
-64
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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](<docs/guide (one).md>)\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]: <docs/example(1).md#hello-world> "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) <em>Hi</em>\n\n# A **bold *emphasis*** [Label `code`](one(two).md) <em>Hi</em>\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 <span title="a > b">Heading</span>\n\n# Unsafe <scr<script>ipt>alert(1)</scr</script>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': '<a id="explicit"></a>\n',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const errors = await diagnostics(t, {
|
||||
'AGENTS.md': '[Fake](docs/example.md#fake)',
|
||||
'docs/example.md': '```html\n<a id="fake"></a>\n```\n',
|
||||
});
|
||||
assert.match(errors.join('\n'), /missing anchor/);
|
||||
});
|
||||
Reference in new issue
Block a user