mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 16:19:28 +08:00
334 lines
12 KiB
JavaScript
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();
|
|
}
|