425 lines
12 KiB
JavaScript
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.`);
|