feat: scaffold redsen-lean-harness v0.1.0
Recovered from crashed session (Node OOM). Repo contains full P0-P6 scaffold: plugin.json/marketplace.json, AGENTS.md, ADRs 0001-0006, lh CLI (init/index/graph/lane/run/memory/host/report/doctor), 10 .github/agents, 12 CLI skills, instructions, context7 mcp.json, and unit/e2e test suite. Fixed: run.mjs read --in-tokens/--out-tokens but tests and CLI docs use --input-tokens/--output-tokens, so telemetry totals were always 0. Now accepts both forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `npm run validate` — distribution gate.
|
||||
*
|
||||
* Verifies the plugin manifests and every markdown frontmatter block that the
|
||||
* Copilot CLI / VS Code loaders depend on. Run this before tagging a release
|
||||
* and in CI. Exits 1 on any error.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const seen = [];
|
||||
|
||||
const rel = (p) => path.relative(ROOT, p);
|
||||
const error = (file, msg) => errors.push(`${rel(file)}: ${msg}`);
|
||||
const warn = (file, msg) => warnings.push(`${rel(file)}: ${msg}`);
|
||||
|
||||
/** Minimal YAML frontmatter reader — supports scalars, inline lists, block lists. */
|
||||
function frontmatter(text) {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
||||
if (!match) return null;
|
||||
|
||||
const data = {};
|
||||
let key = null;
|
||||
let blockList = null;
|
||||
let folded = null;
|
||||
|
||||
for (const raw of match[1].split(/\r?\n/)) {
|
||||
const listItem = /^\s*-\s+(.*)$/.exec(raw);
|
||||
if (listItem && key) {
|
||||
blockList = blockList ?? [];
|
||||
blockList.push(unquote(listItem[1]));
|
||||
data[key] = blockList;
|
||||
continue;
|
||||
}
|
||||
|
||||
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(raw);
|
||||
if (pair) {
|
||||
if (folded && key) data[key] = folded.join(' ').trim();
|
||||
folded = null;
|
||||
blockList = null;
|
||||
key = pair[1];
|
||||
const value = pair[2].trim();
|
||||
if (value === '>' || value === '|' || value === '>-' || value === '|-') {
|
||||
folded = [];
|
||||
data[key] = '';
|
||||
} else if (value.startsWith('[') && value.endsWith(']')) {
|
||||
data[key] = value
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((s) => unquote(s.trim()))
|
||||
.filter(Boolean);
|
||||
} else if (value === '') {
|
||||
data[key] = '';
|
||||
} else {
|
||||
data[key] = coerce(unquote(value));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (folded && raw.trim()) folded.push(raw.trim());
|
||||
}
|
||||
if (folded && key) data[key] = folded.join(' ').trim();
|
||||
return data;
|
||||
}
|
||||
|
||||
const unquote = (s) => s.replace(/^['"]|['"]$/g, '');
|
||||
const coerce = (s) => (s === 'true' ? true : s === 'false' ? false : s);
|
||||
|
||||
function readJson(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch (cause) {
|
||||
error(file, `invalid JSON — ${cause.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles(dir, predicate) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...listFiles(full, predicate));
|
||||
else if (predicate(full)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- manifests
|
||||
|
||||
function validatePlugin() {
|
||||
const file = path.join(ROOT, 'plugin.json');
|
||||
if (!fs.existsSync(file)) return error(file, 'missing');
|
||||
const manifest = readJson(file);
|
||||
if (!manifest) return undefined;
|
||||
seen.push('plugin.json');
|
||||
|
||||
for (const field of ['name', 'description', 'version']) {
|
||||
if (!manifest[field]) error(file, `missing required field "${field}"`);
|
||||
}
|
||||
if (manifest.name && !/^[a-z0-9][a-z0-9-]*$/.test(manifest.name)) {
|
||||
error(file, `name "${manifest.name}" must be a lowercase slug (no scope, no slash)`);
|
||||
}
|
||||
if (manifest.version && !/^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(manifest.version)) {
|
||||
error(file, `version "${manifest.version}" is not semver`);
|
||||
}
|
||||
if (!manifest.$schema?.includes('agent-plugins.org')) {
|
||||
warn(file, 'missing the agent-plugins.org $schema reference');
|
||||
}
|
||||
|
||||
const ext = manifest.extensions?.['com.github.copilot'] ?? {};
|
||||
for (const [key, target] of Object.entries(ext)) {
|
||||
const abs = path.resolve(ROOT, target);
|
||||
if (!fs.existsSync(abs)) error(file, `extensions.com.github.copilot.${key} points at missing path "${target}"`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function validateMarketplace(plugin) {
|
||||
const file = path.join(ROOT, 'marketplace.json');
|
||||
if (!fs.existsSync(file)) return warn(file, 'missing (optional)');
|
||||
const manifest = readJson(file);
|
||||
if (!manifest) return undefined;
|
||||
seen.push('marketplace.json');
|
||||
|
||||
if (!manifest.name) error(file, 'missing "name"');
|
||||
if (!Array.isArray(manifest.plugins) || !manifest.plugins.length) {
|
||||
error(file, 'must list at least one plugin');
|
||||
return undefined;
|
||||
}
|
||||
for (const entry of manifest.plugins) {
|
||||
if (!entry.name) error(file, 'a plugin entry is missing "name"');
|
||||
if (!entry.source) error(file, `plugin "${entry.name}" is missing "source"`);
|
||||
else if (!fs.existsSync(path.resolve(ROOT, entry.source))) {
|
||||
error(file, `plugin "${entry.name}" source "${entry.source}" does not exist`);
|
||||
}
|
||||
}
|
||||
if (plugin && !manifest.plugins.some((p) => p.name === plugin.name)) {
|
||||
error(file, `does not list the root plugin "${plugin.name}"`);
|
||||
}
|
||||
if (plugin && manifest.metadata?.version && manifest.metadata.version !== plugin.version) {
|
||||
warn(file, `metadata.version (${manifest.metadata.version}) differs from plugin.json (${plugin.version})`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function validatePackageJson(plugin) {
|
||||
const file = path.join(ROOT, 'package.json');
|
||||
const pkg = readJson(file);
|
||||
if (!pkg) return;
|
||||
seen.push('package.json');
|
||||
if (pkg.type !== 'module') error(file, 'must set "type": "module"');
|
||||
if (plugin && pkg.version !== plugin.version) {
|
||||
error(file, `version ${pkg.version} does not match plugin.json ${plugin.version}`);
|
||||
}
|
||||
const bin = pkg.bin?.lh;
|
||||
if (!bin) error(file, 'missing bin.lh');
|
||||
else if (!fs.existsSync(path.resolve(ROOT, bin))) error(file, `bin.lh points at missing "${bin}"`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ markdown docs
|
||||
|
||||
function validateAgents() {
|
||||
const dir = path.join(ROOT, '.github', 'agents');
|
||||
const files = listFiles(dir, (f) => f.endsWith('.agent.md'));
|
||||
if (!files.length) return warn(dir, 'no .agent.md files found');
|
||||
|
||||
const names = new Map();
|
||||
const declared = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
const fm = frontmatter(fs.readFileSync(file, 'utf8'));
|
||||
seen.push(rel(file));
|
||||
if (!fm) {
|
||||
error(file, 'missing YAML frontmatter');
|
||||
continue;
|
||||
}
|
||||
if (!fm.description) error(file, 'frontmatter "description" is required');
|
||||
if (fm.name) {
|
||||
if (names.has(fm.name)) error(file, `duplicate agent name "${fm.name}" (also in ${names.get(fm.name)})`);
|
||||
names.set(fm.name, path.basename(file));
|
||||
} else {
|
||||
warn(file, 'frontmatter "name" is recommended');
|
||||
}
|
||||
if (fm.agents) declared.set(file, Array.isArray(fm.agents) ? fm.agents : [fm.agents]);
|
||||
}
|
||||
|
||||
// Delegation targets must resolve to a real agent.
|
||||
for (const [file, targets] of declared) {
|
||||
for (const target of targets) {
|
||||
if (!target) continue;
|
||||
const known = [...names.keys()].some((n) => n.toLowerCase() === String(target).toLowerCase());
|
||||
if (!known) error(file, `delegates to unknown agent "${target}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSkills() {
|
||||
const dir = path.join(ROOT, '.github', 'skills');
|
||||
const files = listFiles(dir, (f) => path.basename(f) === 'SKILL.md');
|
||||
if (!files.length) return warn(dir, 'no SKILL.md files found');
|
||||
|
||||
for (const file of files) {
|
||||
const fm = frontmatter(fs.readFileSync(file, 'utf8'));
|
||||
seen.push(rel(file));
|
||||
if (!fm) {
|
||||
error(file, 'missing YAML frontmatter');
|
||||
continue;
|
||||
}
|
||||
if (!fm.name) error(file, 'frontmatter "name" is required');
|
||||
const dirName = path.basename(path.dirname(file));
|
||||
if (fm.name && fm.name !== dirName) {
|
||||
error(file, `frontmatter name "${fm.name}" must match its directory "${dirName}"`);
|
||||
}
|
||||
const desc = String(fm.description ?? '');
|
||||
if (!desc) error(file, 'frontmatter "description" is required');
|
||||
else if (desc.length < 10 || desc.length > 1024) {
|
||||
error(file, `description must be 10-1024 chars, got ${desc.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateInstructions() {
|
||||
const dir = path.join(ROOT, '.github', 'instructions');
|
||||
for (const file of listFiles(dir, (f) => f.endsWith('.instructions.md'))) {
|
||||
const fm = frontmatter(fs.readFileSync(file, 'utf8'));
|
||||
seen.push(rel(file));
|
||||
if (!fm) error(file, 'missing YAML frontmatter');
|
||||
else if (!fm.applyTo) error(file, 'frontmatter "applyTo" glob is required');
|
||||
}
|
||||
}
|
||||
|
||||
function validateMcp() {
|
||||
const file = path.join(ROOT, '.github', 'mcp.json');
|
||||
if (!fs.existsSync(file)) return warn(file, 'missing (optional)');
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const manifest = readJson(file);
|
||||
if (!manifest) return;
|
||||
seen.push('.github/mcp.json');
|
||||
|
||||
const servers = manifest.mcpServers ?? manifest.servers;
|
||||
if (!servers || !Object.keys(servers).length) error(file, 'declares no MCP servers');
|
||||
|
||||
// Hard rule: no secret may ever be committed.
|
||||
const leaks = [
|
||||
[/ctx7sk-[A-Za-z0-9-]{8,}/, 'a Context7 key'],
|
||||
[/gh[pousr]_[A-Za-z0-9]{20,}/, 'a GitHub token'],
|
||||
[/sk-[A-Za-z0-9]{20,}/, 'an API key'],
|
||||
];
|
||||
for (const [pattern, what] of leaks) {
|
||||
if (pattern.test(raw)) error(file, `appears to contain ${what} — secrets must use \${env:...} interpolation`);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- report
|
||||
|
||||
validatePackageJson(validatePlugin());
|
||||
const plugin = readJson(path.join(ROOT, 'plugin.json'));
|
||||
validateMarketplace(plugin);
|
||||
validateAgents();
|
||||
validateSkills();
|
||||
validateInstructions();
|
||||
validateMcp();
|
||||
|
||||
for (const w of warnings) process.stdout.write(`warn ${w}\n`);
|
||||
for (const e of errors) process.stdout.write(`error ${e}\n`);
|
||||
|
||||
process.stdout.write(
|
||||
`\nvalidated ${seen.length} file(s) — ${errors.length} error(s), ${warnings.length} warning(s)\n`,
|
||||
);
|
||||
process.exit(errors.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user