Files

153 lines
5.2 KiB
JavaScript

#!/usr/bin/env -S deno run --allow-read --allow-run
/**
* Skill Freshness Audit
*
* Scans all skills and reports which ones may be stale based on
* git history (last modification date of SKILL.md).
*
* Usage:
* deno run --allow-read --allow-run scripts/skill-freshness.js
* deno run --allow-read --allow-run scripts/skill-freshness.js --months=3
* deno run --allow-read --allow-run scripts/skill-freshness.js --all
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.join(__dirname, '..');
const skillsDir = path.join(root, '.claude-plugin/plugins/axiom/skills');
// Parse args
const args = process.argv.slice(2);
const monthsArg = args.find(a => a.startsWith('--months='));
const threshold = monthsArg ? parseInt(monthsArg.split('=')[1]) : 6;
const showAll = args.includes('--all');
// Calculate cutoff date
const now = new Date();
const cutoff = new Date(now);
cutoff.setMonth(cutoff.getMonth() - threshold);
const cutoffYYYYMM = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}`;
// Categorize skills by type based on name suffix
function skillType(name) {
if (name.endsWith('-diag')) return 'diagnostic';
if (name.endsWith('-ref')) return 'reference';
if (name.startsWith('axiom-ios-') && !name.endsWith('-ref') && !name.endsWith('-diag')) return 'router';
if (name === 'axiom-using-axiom' || name === 'axiom-getting-started' ||
name === 'axiom-apple-docs' || name === 'axiom-shipping' ||
name === 'axiom-xcode-mcp' || name === 'axiom-ios-ml' ||
name === 'axiom-games' || name === 'axiom-ios-graphics') return 'router';
return 'discipline';
}
// Get last modification date from git
function getLastModified(skillDir) {
const skillFile = path.join(skillDir, 'SKILL.md');
if (!fs.existsSync(skillFile)) return null;
try {
const date = execSync(
`git log -1 --format=%cd --date=format:'%Y-%m' -- "${skillFile}"`,
{ cwd: root, encoding: 'utf-8' }
).trim().replace(/'/g, '');
return date || null;
} catch {
return null;
}
}
// Months between two YYYY-MM strings
function monthsBetween(a, b) {
const [ay, am] = a.split('-').map(Number);
const [by, bm] = b.split('-').map(Number);
return (by - ay) * 12 + (bm - am);
}
const nowYYYYMM = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
// Scan all skills
const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name.startsWith('axiom-'))
.map(d => {
const name = d.name;
const lastMod = getLastModified(path.join(skillsDir, name));
const type = skillType(name);
const age = lastMod ? monthsBetween(lastMod, nowYYYYMM) : null;
const stale = age !== null && age >= threshold;
return { name, type, lastMod, age, stale };
})
.sort((a, b) => {
// Sort: stale first (oldest first), then fresh (oldest first)
if (a.stale !== b.stale) return a.stale ? -1 : 1;
if (a.age !== b.age) return (b.age ?? 999) - (a.age ?? 999);
return a.name.localeCompare(b.name);
});
const stale = entries.filter(e => e.stale);
const neverTracked = entries.filter(e => e.lastMod === null);
const fresh = entries.filter(e => !e.stale && e.lastMod !== null);
// Format output
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function fmtDate(yyyyMm) {
if (!yyyyMm) return 'never';
const [y, m] = yyyyMm.split('-');
return `${months[parseInt(m) - 1]} ${y}`;
}
function fmtAge(age) {
if (age === null) return '';
if (age === 0) return 'this month';
if (age === 1) return '1 month ago';
return `${age} months ago`;
}
// Print report
console.log(`\nSkill Freshness Report`);
console.log(`Threshold: ${threshold} months (cutoff: ${fmtDate(cutoffYYYYMM)})`);
console.log(`Scanned: ${entries.length} skills\n`);
if (stale.length > 0) {
console.log(`⚠️ STALE (>${threshold} months since last change): ${stale.length} skills`);
console.log('─'.repeat(70));
for (const s of stale) {
const shortName = s.name.replace('axiom-', '');
console.log(` ${shortName.padEnd(40)} ${fmtDate(s.lastMod).padEnd(12)} (${fmtAge(s.age)})`);
}
console.log();
}
if (neverTracked.length > 0) {
console.log(`❓ NO GIT HISTORY: ${neverTracked.length} skills`);
console.log('─'.repeat(70));
for (const s of neverTracked) {
const shortName = s.name.replace('axiom-', '');
console.log(` ${shortName}`);
}
console.log();
}
if (showAll && fresh.length > 0) {
console.log(`✅ FRESH (within ${threshold} months): ${fresh.length} skills`);
console.log('─'.repeat(70));
for (const s of fresh) {
const shortName = s.name.replace('axiom-', '');
console.log(` ${shortName.padEnd(40)} ${fmtDate(s.lastMod).padEnd(12)} (${fmtAge(s.age)})`);
}
console.log();
}
// Summary
console.log('─'.repeat(70));
console.log(`Summary: ${stale.length} stale · ${neverTracked.length} untracked · ${fresh.length} fresh`);
if (stale.length === 0 && neverTracked.length === 0) {
console.log('✅ All skills are fresh!\n');
} else {
console.log();
}