Files
nocobase/scripts/check-agents-md.mjs
T

334 lines
12 KiB
JavaScript

// Keeps the agent instruction entry files small and their links working.
//
// An agent reads the repository root `AGENTS.md`, and an application generated from a template reads that template's
// `AGENTS.md`, in full at the start of every session. Every byte there is paid on every task, whether or not the task
// needs it, and a file that keeps growing eventually pushes the guidance an agent actually needs out of its attention.
// So each entry file holds only the rules that always apply plus an index, and detailed guidance lives in
// `.agents/rules/<topic>.md` next to it, read when the index says the task needs it.
//
// That arrangement only works while the index resolves: a rule moved or renamed without updating its link leaves the
// agent pointed at nothing, and nothing else reports it. This script checks both halves:
//
// 1. Each entry file is at most MAX_BYTES.
// 2. Every relative Markdown link in an entry file and in the Markdown under its `.agents/rules/` resolves.
//
// Links into `.agents/skills/` are the exception to resolving on disk. In a template that directory is generated by
// `nocobase skills sync` from installed packages and is not committed, so such a link is checked against the Skill's
// source in this repository instead. At the repository root `.agents/skills/` holds links to `skills/`, created by
// `scripts/sync-skills.mjs` on install, so it is checked against `skills/` for the same reason.
import { existsSync } from 'node:fs';
import { readFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
export const MAX_BYTES = 32 * 1024;
/** Markdown links whose target is a URL, not a file: `http:`, `mailto:`, `vscode:` and the like. */
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
/** The Skills package every template synchronizes from; checked first because nearly every link points into it. */
const APP_SKILLS_SOURCE = 'packages/app/app-skills/skills';
/**
* The entry files to check: the repository root `AGENTS.md` and each template's. Paths are absolute.
*/
export async function findAgentsFiles(repositoryRoot) {
const files = [];
const rootFile = path.join(repositoryRoot, 'AGENTS.md');
if (existsSync(rootFile)) files.push(rootFile);
const templatesDirectory = path.join(repositoryRoot, 'packages', 'templates');
let entries = [];
try {
entries = await readdir(templatesDirectory, { withFileTypes: true });
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isDirectory()) continue;
const file = path.join(templatesDirectory, entry.name, 'AGENTS.md');
if (existsSync(file)) files.push(file);
}
return files;
}
async function collectMarkdownFiles(directory) {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return [];
throw error;
}
const files = [];
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory())
files.push(...(await collectMarkdownFiles(entryPath)));
else if (entry.isFile() && entry.name.endsWith('.md'))
files.push(entryPath);
}
return files;
}
/**
* Replaces fenced code blocks and inline code spans with spaces, keeping line breaks, so that a link shown as an
* example is not checked and line numbers of the remaining links stay correct.
*/
export function stripCode(source) {
const lines = source.split('\n');
let fence = null;
const kept = lines.map((line) => {
const opening = /^ {0,3}(`{3,}|~{3,})/.exec(line);
if (fence) {
if (
opening &&
opening[1][0] === fence[0] &&
opening[1].length >= fence.length &&
line.trim() === opening[1]
) {
fence = null;
}
return '';
}
if (opening) {
fence = opening[1];
return '';
}
return line;
});
// A code span is closed by a backtick run of the same length; an unmatched run is literal text.
return kept
.join('\n')
.replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, (match) =>
match.replace(/[^\n]/g, ' '),
);
}
/**
* Every inline link and image in the Markdown, outside code, as `{ target, line }`. Reference-style links and
* autolinks are not collected.
*/
export function findLinks(source) {
const stripped = stripCode(source);
const links = [];
const pattern =
/!?\[(?:[^\]\\]|\\.)*\]\(\s*(<[^>\n]*>|[^\s()]*(?:\([^\s()]*\)[^\s()]*)*)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
for (const match of stripped.matchAll(pattern)) {
let target = match[1];
if (target.startsWith('<')) target = target.slice(1, -1);
const line = stripped.slice(0, match.index).split('\n').length;
links.push({ target, line });
}
return links;
}
/**
* The file path a link refers to, or null when it refers to no file in this repository: a URL, an anchor in the same
* document, or an empty target.
*/
export function linkPath(target) {
if (!target || target.startsWith('#') || URL_SCHEME.test(target)) return null;
const withoutFragment = target.replace(/[#?].*$/, '');
if (!withoutFragment) return null;
try {
return decodeURIComponent(withoutFragment);
} catch {
return withoutFragment;
}
}
let skillSourceRoots = null;
/** Every `packages/<group>/<package>/skills` directory, the sources `nocobase skills sync` copies Skills from. */
async function findSkillSourceRoots(repositoryRoot) {
if (skillSourceRoots?.repositoryRoot === repositoryRoot)
return skillSourceRoots.roots;
const roots = [path.join(repositoryRoot, APP_SKILLS_SOURCE)];
const packagesDirectory = path.join(repositoryRoot, 'packages');
let groups = [];
try {
groups = await readdir(packagesDirectory, { withFileTypes: true });
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
for (const group of groups) {
if (!group.isDirectory()) continue;
const packages = await readdir(path.join(packagesDirectory, group.name), {
withFileTypes: true,
});
for (const entry of packages) {
if (!entry.isDirectory()) continue;
const candidate = path.join(
packagesDirectory,
group.name,
entry.name,
'skills',
);
if (!roots.includes(candidate) && existsSync(candidate))
roots.push(candidate);
}
}
skillSourceRoots = { repositoryRoot, roots };
return roots;
}
/**
* Resolves a link into a `.agents/skills/` directory against the Skill's committed source. Returns
* `{ status: 'ok' | 'missing' | 'skipped', source? }`, or null when the path is not inside a `.agents/skills/`.
*/
export async function resolveSkillLink(repositoryRoot, absolutePath) {
const relative = path
.relative(repositoryRoot, absolutePath)
.split(path.sep)
.join('/');
const match = /^(?:(.*)\/)?\.agents\/skills\/([^/]+)(?:\/(.*))?$/.exec(
relative,
);
if (!match) return null;
const [, owner = '', skillName, rest = ''] = match;
if (owner === '') {
// The repository's own `.agents/skills/` links to the committed `skills/`.
const source = path.join(repositoryRoot, 'skills', skillName, rest);
return existsSync(source)
? { status: 'ok', source }
: { status: 'missing', source };
}
let skillFound = false;
for (const root of await findSkillSourceRoots(repositoryRoot)) {
if (!existsSync(path.join(root, skillName))) continue;
skillFound = true;
const source = path.join(root, skillName, rest);
if (existsSync(source)) return { status: 'ok', source };
}
// A Skill no package here provides comes from somewhere this repository cannot see, so it is not a broken link.
if (!skillFound) return { status: 'skipped' };
return {
status: 'missing',
source: path.join(repositoryRoot, APP_SKILLS_SOURCE, skillName, rest),
};
}
const toPosix = (repositoryRoot, file) =>
path.relative(repositoryRoot, file).split(path.sep).join('/');
/**
* Checks every entry file under `repositoryRoot`. Returns the problems found, the sizes of the entry files, and the
* links skipped because their Skill has no source here.
*/
export async function checkAgentsMd({ repositoryRoot, maxBytes = MAX_BYTES }) {
const problems = [];
const sizes = [];
const skipped = [];
for (const agentsFile of await findAgentsFiles(repositoryRoot)) {
const agentsPath = toPosix(repositoryRoot, agentsFile);
const directory = path.dirname(agentsFile);
const rulesPath =
toPosix(repositoryRoot, path.join(directory, '.agents', 'rules')) ||
'.agents/rules';
const { size } = await stat(agentsFile);
sizes.push({ file: agentsPath, bytes: size });
if (size > maxBytes) {
problems.push({
file: agentsPath,
line: 1,
message: `is ${size} bytes, over the ${maxBytes}-byte limit; move detailed guidance into ${rulesPath}/<topic>.md and link it from the AGENTS.md index — AGENTS.md holds only always-on rules and the index`,
});
}
const files = [
agentsFile,
...(await collectMarkdownFiles(path.join(directory, '.agents', 'rules'))),
];
for (const file of files) {
const filePath = toPosix(repositoryRoot, file);
const source = await readFile(file, 'utf8');
for (const { target, line } of findLinks(source)) {
const linked = linkPath(target);
if (linked === null) continue;
const absolute = linked.startsWith('/')
? path.join(repositoryRoot, linked)
: path.resolve(path.dirname(file), linked);
const skill = await resolveSkillLink(repositoryRoot, absolute);
if (skill) {
if (skill.status === 'skipped')
skipped.push({ file: filePath, line, target });
else if (skill.status === 'missing') {
problems.push({
file: filePath,
line,
message: `links to ${target}, a synchronized Skill file whose source ${toPosix(repositoryRoot, skill.source)} does not exist; fix the link or restore the file in the Skill`,
});
}
continue;
}
if (!existsSync(absolute)) {
problems.push({
file: filePath,
line,
message: `links to ${target}, which does not exist at ${toPosix(repositoryRoot, absolute)}; fix the link, or update it to where the file moved`,
});
}
}
}
}
return { problems, sizes, skipped };
}
function parseArguments(argv) {
const options = {
repositoryRoot: path.resolve(import.meta.dirname, '..'),
verbose: false,
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--verbose' || argument === '-v') options.verbose = true;
else if (argument === '--root')
options.repositoryRoot = path.resolve(argv[++index]);
else if (argument.startsWith('--root='))
options.repositoryRoot = path.resolve(argument.slice('--root='.length));
else throw new Error(`Unknown argument: ${argument}`);
}
return options;
}
const kilobytes = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
async function main() {
const { repositoryRoot, verbose } = parseArguments(process.argv.slice(2));
const { problems, sizes, skipped } = await checkAgentsMd({ repositoryRoot });
if (verbose) {
for (const link of skipped) {
console.log(
`skipped ${link.file}:${link.line} ${link.target} (Skill has no source in this repository)`,
);
}
}
if (problems.length > 0) {
for (const problem of problems) {
console.error(`${problem.file}:${problem.line} ${problem.message}`);
if (process.env.GITHUB_ACTIONS) {
console.error(
`::error file=${problem.file},line=${problem.line}::${problem.message}`,
);
}
}
console.error(
`\n${problems.length} problem(s). See scripts/check-agents-md.mjs for why entry files stay small.`,
);
process.exit(1);
}
console.log(
`AGENTS.md entry files are within ${kilobytes(MAX_BYTES)} and their links resolve:`,
);
for (const { file, bytes } of sizes)
console.log(` ${file} ${kilobytes(bytes)}`);
if (skipped.length > 0 && !verbose) {
console.log(
`${skipped.length} link(s) to Skills without a source here were skipped; run with --verbose to list them.`,
);
}
}
if (process.argv[1] === import.meta.filename) {
await main();
}