Files
2026-07-13 12:38:34 +08:00

425 lines
12 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const repoRoot = process.cwd();
// Canonical taxonomy gate. Keep in sync with the skill-routing list in `AGENTS.md`
// and the taxonomy in `GOAL.md`; adding/removing a skill means editing all three.
// This list is used ONLY to assert the on-disk taxonomy — the command scan below
// derives its file set from disk so it cannot drift from this literal.
const expectedSkills = [
'bug-fixing',
'cli-command',
'cli-e2e',
'cross-sdk-parity',
'docs-decisions',
'eve',
'python-providers',
'python-release',
'python-sdk',
'python-testing',
'repo-guidance',
'skill-maintenance',
'typescript-providers',
'typescript-sdk',
'typescript-testing',
];
const requiredAgentFiles = [
'AGENTS.md',
'docs/AGENTS.md',
'ts/AGENTS.md',
'ts/packages/core/AGENTS.md',
'ts/packages/providers/AGENTS.md',
'ts/packages/cli/AGENTS.md',
'ts/e2e-tests/AGENTS.md',
'python/AGENTS.md',
'python/providers/AGENTS.md',
];
const errors = [];
const fail = message => errors.push(message);
const readJson = relativePath =>
JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'));
const rootScripts = new Set(Object.keys(readJson('package.json').scripts ?? {}));
const docsScripts = new Set(Object.keys(readJson('docs/package.json').scripts ?? {}));
const makefile = fs.readFileSync(path.join(repoRoot, 'python/Makefile'), 'utf8');
const makeTargets = new Set([...makefile.matchAll(/^([A-Za-z0-9_-]+):/gm)].map(match => match[1]));
const noxfile = fs.readFileSync(path.join(repoRoot, 'python/noxfile.py'), 'utf8');
const noxSessions = new Set(
[...noxfile.matchAll(/^def ([A-Za-z0-9_]+)\(/gm)].map(match => match[1])
);
for (const match of makefile.matchAll(/\bnox\s+-s\s+([A-Za-z0-9_]+)/g)) {
const session = match[1];
if (!noxSessions.has(session)) {
fail(`python/Makefile calls missing nox session "${session}"`);
}
}
const parseFrontmatter = (content, relativePath) => {
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) {
fail(`${relativePath}: missing YAML frontmatter`);
return null;
}
const data = {};
for (const line of match[1].split('\n')) {
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!keyMatch) {
fail(`${relativePath}: unsupported frontmatter line "${line}"`);
continue;
}
data[keyMatch[1]] = keyMatch[2].replace(/^"(.*)"$/, '$1').trim();
}
const keys = Object.keys(data).sort();
const allowed = ['description', 'name'];
const extra = keys.filter(key => !allowed.includes(key));
if (extra.length > 0) {
fail(`${relativePath}: frontmatter has unsupported keys ${extra.join(', ')}`);
}
for (const key of allowed) {
if (!data[key]) {
fail(`${relativePath}: missing frontmatter key ${key}`);
}
}
return { data };
};
const skillsRoot = path.join(repoRoot, '.agents/skills');
if (!fs.existsSync(skillsRoot)) {
fail('.agents/skills does not exist');
} else {
const actualSkills = fs
.readdirSync(skillsRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
const expected = [...expectedSkills].sort();
if (actualSkills.join('\n') !== expected.join('\n')) {
fail(
`.agents/skills taxonomy mismatch.\nExpected:\n${expected.join('\n')}\nActual:\n${actualSkills.join('\n')}`
);
}
for (const skillName of actualSkills) {
const skillDir = path.join(skillsRoot, skillName);
const skillMd = path.join(skillDir, 'SKILL.md');
const relativeSkillMd = path.relative(repoRoot, skillMd);
if (!fs.existsSync(skillMd)) {
fail(`${skillName}: missing SKILL.md`);
continue;
}
const content = fs.readFileSync(skillMd, 'utf8');
const parsed = parseFrontmatter(content, relativeSkillMd);
if (!parsed) {
continue;
}
if (parsed.data.name !== skillName) {
fail(`${relativeSkillMd}: name "${parsed.data.name}" must match directory "${skillName}"`);
}
if (!/^[a-z0-9-]+$/.test(parsed.data.name)) {
fail(`${relativeSkillMd}: name must use lowercase letters, digits, and hyphens`);
}
if (parsed.data.description.length > 1024) {
fail(`${relativeSkillMd}: description exceeds 1024 characters`);
}
if (!/\bUse\b/.test(parsed.data.description)) {
fail(`${relativeSkillMd}: description must include trigger boundaries with "Use"`);
}
const lineCount = content.split('\n').length;
if (lineCount > 80) {
fail(`${relativeSkillMd}: SKILL.md should stay short; found ${lineCount} lines`);
}
const referencesDir = path.join(skillDir, 'references');
if (!fs.existsSync(referencesDir)) {
fail(`${skillName}: missing references directory`);
continue;
}
const referenceEntries = fs.readdirSync(referencesDir, { withFileTypes: true });
const referenceFiles = referenceEntries.filter(
entry => entry.isFile() && entry.name.endsWith('.md')
);
if (referenceFiles.length === 0) {
fail(`${skillName}: references directory must contain markdown files`);
}
for (const entry of referenceEntries) {
if (entry.isDirectory()) {
fail(`${skillName}: references must be first-level files, found directory ${entry.name}`);
}
if (entry.isFile() && !entry.name.endsWith('.md')) {
fail(`${skillName}: reference ${entry.name} must be markdown`);
}
}
for (const reference of referenceFiles) {
const marker = `references/${reference.name}`;
if (!content.includes(marker)) {
fail(`${relativeSkillMd}: does not link ${marker}`);
}
}
}
}
const claudeSkills = path.join(repoRoot, '.claude/skills');
if (!fs.existsSync(claudeSkills)) {
fail('.claude/skills is missing');
} else {
const stat = fs.lstatSync(claudeSkills);
if (!stat.isSymbolicLink()) {
fail('.claude/skills must be a symlink to .agents/skills');
} else {
const linkTarget = fs.readlinkSync(claudeSkills);
if (linkTarget !== '../.agents/skills') {
fail(`.claude/skills points to ${linkTarget}, expected ../.agents/skills`);
}
}
}
for (const relativePath of requiredAgentFiles) {
if (!fs.existsSync(path.join(repoRoot, relativePath))) {
fail(`missing required nested guidance file ${relativePath}`);
}
}
if (fs.existsSync(path.join(repoRoot, 'docs', '.claude'))) {
fail('docs-specific guidance must live under docs/agent-guidance and docs/decisions');
}
if (fs.existsSync(path.join(repoRoot, 'ts/packages/cli', '.cursor'))) {
fail('CLI Cursor-specific metadata should be retired after migration');
}
if (fs.existsSync(path.join(repoRoot, '.claude', 'rules'))) {
fail('Claude-only rule files should be migrated into neutral guidance');
}
for (const relativePath of ['docs/decisions/README.md', 'docs/agent-guidance/README.md']) {
if (!fs.existsSync(path.join(repoRoot, relativePath))) {
fail(`missing ${relativePath}`);
}
}
const ignoredDirs = new Set([
'.git',
'node_modules',
'.turbo',
'.ruff_cache',
'.venv',
'dist',
'build',
]);
const ignoredFiles = new Set(['GOAL.md', 'PLAN.md', 'HANDOFF.md', 'RELEASE_NOTES.md']);
const textExtensions = new Set([
'.md',
'.mdx',
'.ts',
'.tsx',
'.js',
'.mjs',
'.cjs',
'.json',
'.yml',
'.yaml',
'.toml',
]);
const stalePatterns = [
['docs', '.claude'].join('/'),
['.claude', 'context'].join('/'),
['.claude', 'decisions'].join('/'),
['.claude', 'guides'].join('/'),
['.claude', 'rules'].join('/'),
['.Codex', 'rules'].join('/'),
['.cursor', 'rules'].join('/'),
['', 'workspace', 'zen'].join('/'),
'CLI' + '.md',
];
const walk = dir => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(dir, entry.name);
const relativePath = path.relative(repoRoot, absolutePath);
if (entry.isDirectory()) {
if (ignoredDirs.has(entry.name)) {
continue;
}
walk(absolutePath);
continue;
}
if (!entry.isFile() || ignoredFiles.has(relativePath)) {
continue;
}
if (!textExtensions.has(path.extname(entry.name))) {
continue;
}
const text = fs.readFileSync(absolutePath, 'utf8');
for (const pattern of stalePatterns) {
if (text.includes(pattern)) {
fail(`${relativePath}: stale guidance reference "${pattern}"`);
}
}
}
};
walk(repoRoot);
const validatePnpmCommands = (relativePath, text) => {
const commandLines = text
.split('\n')
.map(line => line.trim().replace(/^\$\s+/, ''))
.filter(line => line.startsWith('pnpm ') || line.startsWith('pnpm run '));
for (const line of commandLines) {
const match = line.match(/^pnpm(?:\s+run)?\s+([^\s`]+)/);
if (!match) {
continue;
}
const command = match[1];
if (
command.startsWith('-') ||
command === 'install' ||
command === 'exec' ||
command === 'turbo'
) {
continue;
}
if (!rootScripts.has(command)) {
fail(`${relativePath}: pnpm command "${command}" is not a root package.json script`);
}
}
};
const validateBunRunCommands = (relativePath, text) => {
const commandLines = text
.split('\n')
.map(line => line.trim().replace(/^\$\s+/, ''))
.filter(line => line.startsWith('bun run '));
for (const line of commandLines) {
const match = line.match(/^bun\s+run\s+([^\s`]+)/);
if (!match) {
continue;
}
const command = match[1];
if (!docsScripts.has(command)) {
fail(`${relativePath}: bun run command "${command}" is not a docs package.json script`);
}
}
};
const validateMakeCommands = (relativePath, text) => {
const commandLines = text
.split('\n')
.map(line => line.trim().replace(/^\$\s+/, ''))
.filter(line => line.startsWith('make '));
for (const line of commandLines) {
const match = line.match(/^make\s+([A-Za-z0-9_-]+)/);
if (!match) {
continue;
}
const command = match[1];
if (!makeTargets.has(command)) {
fail(`${relativePath}: make target "${command}" is not in python/Makefile`);
}
}
};
const validateNoxCommands = (relativePath, text) => {
const commandLines = text
.split('\n')
.map(line => line.trim().replace(/^\$\s+/, ''))
.filter(line => line.startsWith('nox -s '));
for (const line of commandLines) {
const match = line.match(/^nox\s+-s\s+([A-Za-z0-9_]+)/);
if (!match) {
continue;
}
const command = match[1];
if (!noxSessions.has(command)) {
fail(`${relativePath}: nox session "${command}" is not in python/noxfile.py`);
}
}
};
// Derive the command-scan file set from the skills actually on disk so it can never
// drift from `expectedSkills` (which serves only as the taxonomy gate above).
const skillDirNames = fs.existsSync(skillsRoot)
? fs
.readdirSync(skillsRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
: [];
const commandFiles = [
...skillDirNames.flatMap(skillName => {
const skillDir = path.join(skillsRoot, skillName);
if (!fs.existsSync(skillDir)) {
return [];
}
const files = [];
const collect = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const absolutePath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collect(absolutePath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(absolutePath);
}
}
};
collect(skillDir);
return files;
}),
...requiredAgentFiles.map(relativePath => path.join(repoRoot, relativePath)),
];
for (const absolutePath of commandFiles) {
if (!fs.existsSync(absolutePath)) {
continue;
}
const relativePath = path.relative(repoRoot, absolutePath);
const text = fs.readFileSync(absolutePath, 'utf8');
validatePnpmCommands(relativePath, text);
validateBunRunCommands(relativePath, text);
validateMakeCommands(relativePath, text);
validateNoxCommands(relativePath, text);
}
if (errors.length > 0) {
console.error(errors.map(error => `- ${error}`).join('\n'));
process.exit(1);
}
console.log(`Validated ${expectedSkills.length} canonical agent skills and guidance invariants.`);