mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
docs(agents): compact root guidance and preserve task-specific knowledge (#1645)
* docs(agents): compact root guidance and preserve task-specific knowledge * fix(agents): parse guidance navigation with Markdown tokens * fix(agents): validate generic literal repository paths * fix(agents): distinguish code symbols and shortcut images * fix(agents): recognize SCSS filename literals * fix(agents): handle fenced imports and encoded paths * fix(agents): parse prose and rendered HTML anchors * fix(agents): validate rendered HTML navigation * fix(agents): use GitHub-compatible heading slugs * fix(agents): require standalone top-level Claude import * fix(agents): exclude HTML-contained guidance imports * fix(agents): handle image fragments and quoted imports * fix(agents): validate visible HTML and image source sets * fix(agents): recognize package scopes and route source work * fix(agents): parse JSONC and constrain package exemptions * fix(agents): decode link entities and allow package subpaths * fix(agents): route source work and check extensionless files * fix(agents): support package versions and source fragments * fix(agents): accept qualified package prose * fix(agents): retain rendered context for Markdown references * fix(agents): validate visible headings and spaced paths * fix(agents): validate media and hyphenated literal paths * fix(agents): decode full HTML entities and media assets * fix(agents): recognize possessive package mentions * fix(agents): validate extensionless imports and version comparators * fix(agents): retain visible backticks and explicit path punctuation * fix(agents): validate image-map navigation targets * fix(agents): count all Markdown line endings in budgets * fix(agents): delimit package prose at Unicode punctuation * fix(agents): normalize punctuation for extensionless imports * fix(agents): preserve filenames across prose punctuation * fix(agents): validate iframe document references * fix(agents): inspect document suffix before URL fragments * fix(agents): unify Markdown suffix and encoded import guards * fix(agents): handle wildcard versions and alternate documents * fix(agents): validate document formats and trim HTML URLs * fix(agents): cover document families and guidance basenames * fix(agents): require files for media references * fix(agents): preserve block boundaries and validate embeds * fix(agents): normalize internal HTML URL whitespace * fix(agents): reject empty media and ignore URL at-signs * fix(agents): validate srcdoc references and empty srcset * fix(agents): honor HTML bases and preserve adjacent imports * fix(agents): convert base file URLs to native paths * fix(agents): preserve imports after bare URL punctuation * fix(agents): exclude opaque URI prose from import scans * fix(agents): keep import tokens outside URI scheme matches * fix(agents): restrict opaque URI exemptions to parsed links * fix(agents): handle opening prose delimiters * fix(agents): scan nested imports and share document suffixes * fix(agents): reject pathless media and direct file URLs * fix(agents): reject file bases and preserve quoted URL boundaries * fix(agents): distinguish URL quotes and cover guidance variants * fix(agents): validate SVG images and conventional guides * fix(agents): handle declared package names handles and SVG use * fix(agents): normalize closing punctuation on federated handles * fix(agents): normalize Unicode punctuation on handles * fix(agents): normalize possessive federated handles * fix(agents): separate parenthetical prose from handles * fix(agents): exclude www autolinks from import scanning * ci: allow manual CodeQL validation of PR branches * fix(agents): reject nonportable Windows drive links
This commit is contained in:
1 parent
b02d79805b
commit
faad8fd8fd
24 files changed
+4178
-3256
No files matched your search
@@ -84,6 +84,8 @@ pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/linux-prefix
|
||||
|
||||
### Windows CI pin lifecycle
|
||||
|
||||
The PAT-backed refresh job must pin every third-party action to a full commit.
|
||||
|
||||
Windows package builds consume the one validated record in
|
||||
`windows-runtime-pin.json`; URL and checksum repository variables are not build
|
||||
inputs. Check it locally with:
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import parseSrcset from 'parse-srcset';
|
||||
import GithubSlugger from 'github-slugger';
|
||||
import { Marked, Tokenizer } from 'marked';
|
||||
import { parseFragment } from 'parse5';
|
||||
|
||||
export const DOCUMENT_EXTENSION =
|
||||
/\.(?:md|markdown|mdown|mkd|mdx|txt|json|ya?ml|html?|rst|rest|adoc|asciidoc|pdf|doc[xm]?|dot[xm]?|od[tspgfbm]|ot[tspg]|fod[tspg]|rtf|org|tex|latex)$/iu;
|
||||
|
||||
// Inspection only: generated HTML is parsed in memory, never executed or 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.
|
||||
// Retain explicit full/collapsed forms and shortcut images;
|
||||
// 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?.[0].startsWith('![')
|
||||
? 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.type === 'text'
|
||||
? decodeEntities(token.text ?? '')
|
||||
: (token.text ?? '');
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function decodeEntities(text, attribute = false) {
|
||||
if (attribute) {
|
||||
const html = `<a href="${text.replace(/"/gu, '"')}"></a>`;
|
||||
return parseFragment(html).childNodes[0].attrs[0].value;
|
||||
}
|
||||
// RCDATA decodes the full HTML character-reference grammar without
|
||||
// interpreting literal tags. The prefix preserves an initial newline.
|
||||
const html = `<textarea>x${text.replace(/</gu, '<')}</textarea>`;
|
||||
return parseFragment(html).childNodes[0].childNodes[0].value.slice(1);
|
||||
}
|
||||
|
||||
function htmlNavigation(html, inspect = () => {}) {
|
||||
const anchors = [];
|
||||
const references = [];
|
||||
let baseHref;
|
||||
function visit(node) {
|
||||
if (['script', 'style', 'template'].includes(node.tagName)) return;
|
||||
inspect(node);
|
||||
if (node.tagName === 'base' && baseHref === undefined)
|
||||
baseHref = node.attrs?.find(
|
||||
(attribute) => attribute.name === 'href'
|
||||
)?.value;
|
||||
for (const attribute of node.attrs ?? []) {
|
||||
if (
|
||||
attribute.name === 'id' ||
|
||||
(node.tagName === 'a' && attribute.name === 'name')
|
||||
)
|
||||
anchors.push(attribute.value);
|
||||
if (
|
||||
(['a', 'area', 'image', 'use'].includes(node.tagName) &&
|
||||
attribute.name === 'href') ||
|
||||
([
|
||||
'img',
|
||||
'video',
|
||||
'audio',
|
||||
'source',
|
||||
'track',
|
||||
'iframe',
|
||||
'embed',
|
||||
].includes(node.tagName) &&
|
||||
attribute.name === 'src') ||
|
||||
(node.tagName === 'video' && attribute.name === 'poster') ||
|
||||
(node.tagName === 'object' && attribute.name === 'data') ||
|
||||
(node.tagName === 'input' &&
|
||||
attribute.name === 'src' &&
|
||||
node.attrs.some(
|
||||
(attr) =>
|
||||
attr.name === 'type' &&
|
||||
attr.value.toLowerCase() === 'image'
|
||||
))
|
||||
)
|
||||
references.push({
|
||||
svgUse: node.tagName === 'use',
|
||||
target: attribute.value
|
||||
.replace(/[\t\n\r]/gu, '')
|
||||
.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/gu, ''),
|
||||
image: !['a', 'area', 'iframe', 'object', 'embed'].includes(
|
||||
node.tagName
|
||||
),
|
||||
});
|
||||
if (node.tagName === 'iframe' && attribute.name === 'srcdoc') {
|
||||
const embedded = htmlNavigation(attribute.value);
|
||||
for (const reference of embedded.references)
|
||||
references.push(
|
||||
reference.target.startsWith('#') &&
|
||||
!reference.embeddedAnchors &&
|
||||
!reference.bases?.length
|
||||
? {
|
||||
...reference,
|
||||
embeddedAnchors: embedded.anchors,
|
||||
}
|
||||
: reference
|
||||
);
|
||||
}
|
||||
if (
|
||||
['img', 'source'].includes(node.tagName) &&
|
||||
attribute.name === 'srcset'
|
||||
) {
|
||||
const candidates = parseSrcset(attribute.value);
|
||||
if (!candidates.length)
|
||||
references.push({ target: '', image: true });
|
||||
for (const candidate of candidates)
|
||||
references.push({ target: candidate.url, image: true });
|
||||
}
|
||||
}
|
||||
for (const child of node.childNodes ?? []) visit(child);
|
||||
}
|
||||
visit(parseFragment(html));
|
||||
for (const reference of references) {
|
||||
if (
|
||||
reference.svgUse &&
|
||||
!reference.embeddedAnchors &&
|
||||
reference.target.startsWith('#')
|
||||
) {
|
||||
reference.image = false;
|
||||
reference.embeddedAnchors = anchors;
|
||||
}
|
||||
}
|
||||
return {
|
||||
anchors,
|
||||
references:
|
||||
baseHref === undefined
|
||||
? references
|
||||
: references.map((reference) => ({
|
||||
...reference,
|
||||
bases: [baseHref, ...(reference.bases ?? [])],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function guidanceProse(markdown) {
|
||||
function text(node, preceding = '') {
|
||||
if (
|
||||
['script', 'style', 'template', 'pre', 'code'].includes(
|
||||
node.tagName
|
||||
)
|
||||
)
|
||||
return ' ';
|
||||
if (node.tagName === 'a') {
|
||||
const href = node.attrs?.find(
|
||||
(attribute) => attribute.name === 'href'
|
||||
)?.value;
|
||||
if (
|
||||
href &&
|
||||
/^[a-z][a-z\d+.-]*:(?!\/\/)/iu.test(href) &&
|
||||
node.childNodes?.length === 1 &&
|
||||
node.childNodes[0].nodeName === '#text' &&
|
||||
node.childNodes[0].value === href
|
||||
)
|
||||
return ' ';
|
||||
}
|
||||
if (node.nodeName === '#text')
|
||||
return node.value.replace(
|
||||
/(?:\b[a-z][a-z\d+.-]*:\/\/|\/\/|\bwww\.)[^\s]*?(?=[)\]}>][.,;:!?]*@|\s|$)/giu,
|
||||
(url, offset) => {
|
||||
const opening = (
|
||||
preceding + node.value.slice(0, offset)
|
||||
).at(-1);
|
||||
const closing = {
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
'“': '”',
|
||||
'”': '”',
|
||||
'‘': '’',
|
||||
'’': '’',
|
||||
}[opening];
|
||||
const boundary = closing ? url.indexOf(closing) : -1;
|
||||
return boundary >= 0 &&
|
||||
/^[.,;:!?]*@/u.test(url.slice(boundary + 1))
|
||||
? ' ' + url.slice(boundary)
|
||||
: ' ';
|
||||
}
|
||||
);
|
||||
let content = '';
|
||||
for (const child of node.childNodes ?? [])
|
||||
content += text(child, preceding + content);
|
||||
return [
|
||||
'address',
|
||||
'article',
|
||||
'aside',
|
||||
'details',
|
||||
'summary',
|
||||
'dialog',
|
||||
'dl',
|
||||
'dt',
|
||||
'dd',
|
||||
'fieldset',
|
||||
'legend',
|
||||
'figure',
|
||||
'figcaption',
|
||||
'footer',
|
||||
'form',
|
||||
'header',
|
||||
'hgroup',
|
||||
'hr',
|
||||
'main',
|
||||
'nav',
|
||||
'ol',
|
||||
'ul',
|
||||
'section',
|
||||
'table',
|
||||
'caption',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tfoot',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'p',
|
||||
'li',
|
||||
'blockquote',
|
||||
'div',
|
||||
'br',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
].includes(node.tagName)
|
||||
? '\n' + content + '\n'
|
||||
: content;
|
||||
}
|
||||
return text(parseFragment(new Marked().parse(markdown)));
|
||||
}
|
||||
|
||||
export function guidanceStandaloneImports(markdown) {
|
||||
const candidates = markdownLexer
|
||||
.lexer(markdown)
|
||||
.filter((token) => token.type === 'paragraph')
|
||||
.flatMap((token) => [
|
||||
...token.raw.matchAll(/^ {0,3}@([^\s]+)[\t ]*$/gmu),
|
||||
])
|
||||
.map((match) => match[1]);
|
||||
// Markdown can split an HTML container across several top-level tokens.
|
||||
// Check the parsed output tree as well as raw source formatting. Only text
|
||||
// directly inside a root paragraph can supply the standalone directive.
|
||||
const document = parseFragment(new Marked().parse(markdown));
|
||||
const visible = new Map();
|
||||
for (const node of document.childNodes) {
|
||||
if (node.tagName !== 'p') continue;
|
||||
const text = node.childNodes
|
||||
.map((child) =>
|
||||
child.nodeName === '#text' ? child.value : '\uFFFC'
|
||||
)
|
||||
.join('');
|
||||
for (const match of text.matchAll(/^ {0,3}@([^\s]+)[\t ]*$/gmu))
|
||||
visible.set(match[1], (visible.get(match[1]) ?? 0) + 1);
|
||||
}
|
||||
return candidates.filter((candidate) => {
|
||||
const count = visible.get(candidate) ?? 0;
|
||||
if (!count) return false;
|
||||
visible.set(candidate, count - 1);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function guidanceAnchors(markdown) {
|
||||
const slugger = new GithubSlugger();
|
||||
const found = new Set();
|
||||
const headings = [];
|
||||
const marker = `data-guidance-${randomUUID()}`;
|
||||
const renderer = new Marked({
|
||||
renderer: {
|
||||
heading(token) {
|
||||
const index = headings.push(inlineText(token.tokens)) - 1;
|
||||
return `<h${token.depth} ${marker}="${index}">${this.parser.parseInline(token.tokens)}</h${token.depth}>\n`;
|
||||
},
|
||||
},
|
||||
});
|
||||
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;
|
||||
const explicitRelative = /^(?:\.\/|\.\.\/)/u.test(token);
|
||||
if (!explicitRelative && /[^\p{L}\p{N}_./#-]/u.test(token)) return false;
|
||||
if (token.includes('YYYY-MM-DD') || /(?:^|\/)\.\.\.(?:\/|$)/u.test(token))
|
||||
return false;
|
||||
const path = token.split('#')[0];
|
||||
// Bare dotted identifiers are ambiguous. Recognize conventional file
|
||||
// suffixes; other filenames can be made explicit with ./ or a Markdown link.
|
||||
// This applies to user-defined symbols as well as JavaScript globals.
|
||||
if (
|
||||
/^[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)+$/u.test(path) &&
|
||||
!DOCUMENT_EXTENSION.test(path) &&
|
||||
!/\.(?:md|mdx|json|jsonc|ya?ml|[cm]?[jt]sx?|html?|css|scss|sass|less|toml|xml|txt|sh|py|sql|svg|png|jpe?g|webp|gif|m3u8?|conf|ini|lock)$/iu.test(
|
||||
path
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
path.includes('/') ||
|
||||
/^(?:Dockerfile|Containerfile|Makefile|GNUmakefile|Justfile|Procfile|Gemfile|Rakefile|Vagrantfile|LICENSE|LICENCE|NOTICE|COPYING|AUTHORS|CONTRIBUTORS|README|CHANGELOG)$/u.test(
|
||||
path
|
||||
) ||
|
||||
/^(?:\.[\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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function guidanceReferences(markdown, includeLiterals) {
|
||||
const tokens = markdownLexer.lexer(markdown);
|
||||
const markerTag = `guidance-reference-${randomUUID()}`;
|
||||
const metadata = [];
|
||||
function mark(token, reference) {
|
||||
const index = metadata.push(reference) - 1;
|
||||
token.type = 'html';
|
||||
token.raw = `<${markerTag} data-index="${index}"></${markerTag}>`;
|
||||
token.text = token.raw;
|
||||
}
|
||||
markdownLexer.walkTokens(tokens, (token) => {
|
||||
if (token.type === 'def')
|
||||
mark(token, {
|
||||
target: decodeEntities(token.href, true),
|
||||
definition: true,
|
||||
});
|
||||
else if (token.type === 'unresolved-reference')
|
||||
mark(token, { unresolvedReference: token.label });
|
||||
else if (
|
||||
includeLiterals &&
|
||||
token.type === 'codespan' &&
|
||||
isLiteralRepositoryPath(token.text)
|
||||
)
|
||||
mark(token, { target: token.text, literal: true });
|
||||
});
|
||||
// Let Markdown rendering and HTML tree construction retain container context
|
||||
// for ordinary links and for metadata that has no rendered navigation node.
|
||||
const visible = [];
|
||||
const navigation = htmlNavigation(new Marked().parser(tokens), (node) => {
|
||||
if (node.tagName !== markerTag) return;
|
||||
const index = Number(
|
||||
node.attrs.find((attr) => attr.name === 'data-index')?.value
|
||||
);
|
||||
if (metadata[index]) visible.push(metadata[index]);
|
||||
});
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
function add(reference) {
|
||||
const key = JSON.stringify(reference);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(reference);
|
||||
}
|
||||
}
|
||||
for (const reference of navigation.references) add(reference);
|
||||
const usedTargets = new Set(
|
||||
navigation.references.map((reference) => reference.target)
|
||||
);
|
||||
for (const { definition, ...reference } of visible) {
|
||||
if (!definition || !usedTargets.has(reference.target)) add(reference);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -9,20 +9,36 @@
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"{workspaceRoot}/tools/skills/validate-repository-skills.mjs",
|
||||
"{workspaceRoot}/tools/skills/validate-repository-skills.test.mjs"
|
||||
"{projectRoot}/*.mjs",
|
||||
"{projectRoot}/project.json",
|
||||
{
|
||||
"externalDependencies": [
|
||||
"marked",
|
||||
"parse5",
|
||||
"github-slugger",
|
||||
"parse-srcset",
|
||||
"typescript"
|
||||
]
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"command": "node --test tools/skills/validate-repository-skills.test.mjs",
|
||||
"command": "node --test tools/skills/validate-repository-skills.test.mjs tools/skills/validate-agent-guidance.test.mjs",
|
||||
"cwd": "{workspaceRoot}"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "node --check tools/skills/validate-repository-skills.mjs",
|
||||
"commands": [
|
||||
"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/agent-guidance-markdown.mjs"
|
||||
],
|
||||
"cwd": "{workspaceRoot}"
|
||||
}
|
||||
},
|
||||
"inputs": ["{projectRoot}/*.mjs", "{projectRoot}/project.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import ts from 'typescript';
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import {
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import {
|
||||
DOCUMENT_EXTENSION,
|
||||
guidanceAnchors as anchors,
|
||||
guidanceProse,
|
||||
guidanceStandaloneImports,
|
||||
guidanceReferences as references,
|
||||
} from './agent-guidance-markdown.mjs';
|
||||
|
||||
const MARKDOWN_EXTENSION = /\.(?:md|markdown|mdown|mkd|mdx)$/iu;
|
||||
|
||||
const SURFACES = [
|
||||
'AGENTS.md',
|
||||
'CLAUDE.md',
|
||||
'docs/maintenance/agent-context-map.md',
|
||||
'docs/maintenance/agent-guidance-migration.md',
|
||||
];
|
||||
const LIMITS = { 'AGENTS.md': [200, 16384], 'CLAUDE.md': [30, 2048] };
|
||||
|
||||
function within(root, path) {
|
||||
const local = relative(root, path);
|
||||
return (
|
||||
!isAbsolute(local) && local !== '..' && !local.startsWith(`..${sep}`)
|
||||
);
|
||||
}
|
||||
|
||||
async function validateReference(
|
||||
rootDir,
|
||||
source,
|
||||
{ target, literal, image, unresolvedReference, embeddedAnchors, bases }
|
||||
) {
|
||||
if (unresolvedReference !== undefined)
|
||||
return `${source}: unresolved Markdown reference "${unresolvedReference}"`;
|
||||
if (/^[a-z]:[\\/]/iu.test(target))
|
||||
return `${source}: use a repository-relative path instead of a Windows drive path: ${target}`;
|
||||
if (/^file:/iu.test(target))
|
||||
return `${source}: use a repository-relative path instead of a file URL: ${target}`;
|
||||
if (image && !target) return `${source}: empty media target`;
|
||||
let resolvedBasePath;
|
||||
if (bases?.length) {
|
||||
try {
|
||||
let base = pathToFileURL(resolve(rootDir, source));
|
||||
for (const href of bases) {
|
||||
if (
|
||||
/^(?:file:|[a-z]:[\\/])/iu.test(
|
||||
href.replace(/[\t\n\r]/gu, '').trimStart()
|
||||
)
|
||||
)
|
||||
return `${source}: use a repository-relative HTML base instead of a file URL or Windows drive path`;
|
||||
base = new URL(href, base);
|
||||
}
|
||||
const url = new URL(target, base);
|
||||
if (url.protocol !== 'file:') return;
|
||||
resolvedBasePath = fileURLToPath(url);
|
||||
target = url.pathname + url.search + url.hash;
|
||||
embeddedAnchors = undefined;
|
||||
} catch {
|
||||
return `${source}: malformed HTML base or target: ${target}`;
|
||||
}
|
||||
}
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/iu.test(target)) return;
|
||||
let path;
|
||||
let anchor;
|
||||
try {
|
||||
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}`;
|
||||
}
|
||||
if (image && !path && !resolvedBasePath)
|
||||
return `${source}: media target requires a path: ${target}`;
|
||||
if (embeddedAnchors && !path && !image)
|
||||
return !anchor || embeddedAnchors.includes(anchor)
|
||||
? undefined
|
||||
: `${source}: missing anchor "${anchor}" in iframe srcdoc`;
|
||||
const absolute =
|
||||
resolvedBasePath ??
|
||||
(path
|
||||
? resolve(
|
||||
literal ? rootDir : dirname(resolve(rootDir, source)),
|
||||
path
|
||||
)
|
||||
: resolve(rootDir, source));
|
||||
if (!within(rootDir, absolute))
|
||||
return `${source}: referenced path escapes repository root: ${target}`;
|
||||
try {
|
||||
const actual = await realpath(absolute);
|
||||
if (!within(rootDir, actual))
|
||||
return `${source}: referenced path escapes repository root: ${target}`;
|
||||
if (image && !(await stat(actual)).isFile())
|
||||
return `${source}: media target is not a file: ${target}`;
|
||||
if (
|
||||
anchor &&
|
||||
!image &&
|
||||
MARKDOWN_EXTENSION.test(extname(actual)) &&
|
||||
!anchors(await readFile(actual, 'utf8')).has(anchor)
|
||||
) {
|
||||
return `${source}: missing anchor "${anchor}" in ${target}`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (['ENOENT', 'ENOTDIR'].includes(error.code))
|
||||
return `${source}: referenced path does not exist: ${target}`;
|
||||
if (error.code === 'EISDIR')
|
||||
return `${source}: anchor target is a directory: ${target}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function packageMentions(rootDir) {
|
||||
async function readJson(path) {
|
||||
try {
|
||||
const text = await readFile(resolve(rootDir, path), 'utf8');
|
||||
if (path !== 'tsconfig.base.json') return JSON.parse(text);
|
||||
const parsed = ts.parseConfigFileTextToJson(path, text);
|
||||
if (parsed.error)
|
||||
throw new Error(
|
||||
ts.flattenDiagnosticMessageText(
|
||||
parsed.error.messageText,
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
return parsed.config;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return {};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const manifest = await readJson('package.json');
|
||||
const config = await readJson('tsconfig.base.json');
|
||||
const packages = [
|
||||
...Object.keys(manifest.dependencies ?? {}),
|
||||
...Object.keys(manifest.devDependencies ?? {}),
|
||||
...Object.keys(manifest.optionalDependencies ?? {}),
|
||||
...Object.keys(manifest.peerDependencies ?? {}),
|
||||
];
|
||||
const names = [
|
||||
...packages,
|
||||
...Object.keys(config.compilerOptions?.paths ?? {}),
|
||||
];
|
||||
const declared = names
|
||||
.filter((name) => /^@[^/]+\//u.test(name))
|
||||
.map((name) => name.slice(1));
|
||||
const scopes = new Set(declared.map((name) => name.split('/')[0]));
|
||||
return (raw) => {
|
||||
if (packages.includes(`@${raw}`) || packages.includes(raw)) return true;
|
||||
// ASCII punctuation also belongs to package names and version ranges.
|
||||
let token = raw.split(/[,;:!?([{]|(?=[^\x00-\x7f])\p{P}/u, 1)[0];
|
||||
token = token.replace(/[?!.,;:)"'\]}]+$/u, '');
|
||||
token = token.replace(/['’]s$/iu, '');
|
||||
token = token.replace(
|
||||
/^([^/@]+(?:\/[^/@]+)?)@(?:(?:[~^]|[<>]=?|=)?\d[\w.*+-]*|\*|[a-z][\w-]*)$/iu,
|
||||
'$1'
|
||||
);
|
||||
if (
|
||||
token.split(/[\/\\]/u).some((part) => part === '.' || part === '..')
|
||||
)
|
||||
return false;
|
||||
if (packages.includes(token) || packages.includes(`@${token}`))
|
||||
return true;
|
||||
const path = token.split(/[?#]/u, 1)[0];
|
||||
if (
|
||||
/%[\da-f]{2}/iu.test(token) ||
|
||||
MARKDOWN_EXTENSION.test(path) ||
|
||||
/(?:^|\/)(?:AGENTS|CLAUDE|INSTRUCTIONS|README|LICENSE|LICENCE|NOTICE|COPYING|AUTHORS|CONTRIBUTORS|CHANGELOG|CONTRIBUTING|SECURITY|CODE_OF_CONDUCT|SUPPORT)$/iu.test(
|
||||
path
|
||||
) ||
|
||||
DOCUMENT_EXTENSION.test(path)
|
||||
)
|
||||
return false;
|
||||
if (packages.includes(token)) return true;
|
||||
if (token.endsWith('/*') && scopes.has(token.slice(0, -2))) return true;
|
||||
return declared.some((name) => {
|
||||
const star = name.indexOf('*');
|
||||
return star < 0
|
||||
? token === name ||
|
||||
(packages.includes(`@${name}`) &&
|
||||
token.startsWith(`${name}/`))
|
||||
: token.startsWith(name.slice(0, star)) &&
|
||||
token.endsWith(name.slice(star + 1));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateAgentGuidance({ rootDir }) {
|
||||
rootDir = await realpath(rootDir);
|
||||
const diagnostics = [];
|
||||
const isPackageMention = await packageMentions(rootDir);
|
||||
for (const source of SURFACES) {
|
||||
let markdown;
|
||||
try {
|
||||
markdown = await readFile(resolve(rootDir, source), 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
diagnostics.push(`${source}: required guidance file is missing`);
|
||||
continue;
|
||||
}
|
||||
if (LIMITS[source]) {
|
||||
const [maxLines, maxBytes] = LIMITS[source];
|
||||
const lines =
|
||||
markdown === ''
|
||||
? 0
|
||||
: markdown
|
||||
.replace(/(?:\r\n|[\r\n])$/u, '')
|
||||
.split(/\r\n|[\r\n]/u).length;
|
||||
const bytes = Buffer.byteLength(markdown, 'utf8');
|
||||
if (lines > maxLines)
|
||||
diagnostics.push(
|
||||
`${source}: at most ${maxLines} lines allowed (received ${lines})`
|
||||
);
|
||||
if (bytes > maxBytes)
|
||||
diagnostics.push(
|
||||
`${source}: at most ${maxBytes} UTF-8 bytes allowed (received ${bytes})`
|
||||
);
|
||||
const prose = guidanceProse(markdown);
|
||||
const imports = guidanceStandaloneImports(markdown);
|
||||
const inlineImports = [];
|
||||
for (const match of prose.matchAll(
|
||||
/(?=(?:^|[^\p{L}\p{N}_@])@([^\s]+))/gu
|
||||
)) {
|
||||
const token = match[1];
|
||||
if (isPackageMention(token)) continue;
|
||||
if (
|
||||
/^[\w.-]+@(?:[a-z\d](?:[a-z\d-]*[a-z\d])?\.)+[a-z]{2,}$/iu.test(
|
||||
token
|
||||
.split(/[([{]/u, 1)[0]
|
||||
.replace(/\p{P}+$/gu, '')
|
||||
.replace(/['’]s$/iu, '')
|
||||
)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
/[./\\]/u.test(token) ||
|
||||
/^(?:LICENSE|Makefile|Dockerfile|AGENTS|CLAUDE)(?:$|[.,;)])/u.test(
|
||||
token
|
||||
)
|
||||
) {
|
||||
inlineImports.push(token);
|
||||
continue;
|
||||
}
|
||||
// Check real filenames before interpreting punctuation as prose.
|
||||
const candidates = new Set([
|
||||
token,
|
||||
token.replace(/[?!.,;:)"'\]}]+$/u, ''),
|
||||
]);
|
||||
for (const boundary of token.matchAll(
|
||||
/[,;:!?([{]|(?=[^\x00-\x7f])\p{P}/gu
|
||||
))
|
||||
candidates.add(token.slice(0, boundary.index));
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (
|
||||
(await stat(resolve(rootDir, candidate))).isFile()
|
||||
) {
|
||||
inlineImports.push(token);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
inlineImports.some((token) => token !== 'AGENTS.md') ||
|
||||
(source === 'AGENTS.md' && inlineImports.length) ||
|
||||
(source === 'CLAUDE.md' &&
|
||||
inlineImports.filter((token) => token === 'AGENTS.md')
|
||||
.length !== 1)
|
||||
) {
|
||||
diagnostics.push(
|
||||
`${source}: additional or inline guidance imports are not allowed`
|
||||
);
|
||||
}
|
||||
if (source === 'CLAUDE.md') {
|
||||
if (imports.length !== 1 || imports[0] !== 'AGENTS.md')
|
||||
diagnostics.push(
|
||||
`${source}: exactly one standalone @AGENTS.md import is required; no other imports are allowed`
|
||||
);
|
||||
} else if (imports.length)
|
||||
diagnostics.push(`${source}: imports are not allowed`);
|
||||
}
|
||||
for (const reference of references(
|
||||
markdown,
|
||||
!source.endsWith('agent-guidance-migration.md')
|
||||
)) {
|
||||
const diagnostic = await validateReference(
|
||||
rootDir,
|
||||
source,
|
||||
reference
|
||||
);
|
||||
if (diagnostic) diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
return { checkedFiles: SURFACES.length, diagnostics };
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
const { checkedFiles, diagnostics } = await validateAgentGuidance({
|
||||
rootDir: process.cwd(),
|
||||
});
|
||||
if (diagnostics.length) {
|
||||
for (const diagnostic of diagnostics) console.error(diagnostic);
|
||||
process.exitCode = 1;
|
||||
} else console.log(`Validated ${checkedFiles} agent guidance files.`);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user