Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
198 lines
6.8 KiB
JavaScript
198 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* lint-command-contract.cjs (ADR-0002)
|
|
*
|
|
* Enforces the commands/msd/*.md contract across all 65 command files:
|
|
*
|
|
* 1. name: present, non-empty, matches msd: or msd- prefix
|
|
* 2. description: present, non-empty
|
|
* 3. allowed-tools: block present, non-empty, all entries from CANONICAL_TOOLS
|
|
* 4. execution_context @-refs: every @-reference resolves to an existing file on disk
|
|
* 5. execution_context @-refs: each appears on its own line (no trailing prose)
|
|
* 6. every msd-core/workflows/*.md file is reachable from at least one
|
|
* commands/agents/skills loader, transitively through msd-core/**
|
|
* (repo-level check, runs once — not per command file)
|
|
*
|
|
* Exit 0 = clean. Exit 1 = violations (with diagnostics).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
function resolveRoot(argv) {
|
|
const idx = argv.indexOf('--root');
|
|
if (idx === -1) return path.join(__dirname, '..');
|
|
const value = argv[idx + 1];
|
|
if (!value) {
|
|
throw new Error('lint-command-contract: --root requires a directory argument');
|
|
}
|
|
return path.resolve(value);
|
|
}
|
|
|
|
const ROOT = resolveRoot(process.argv.slice(2));
|
|
const COMMANDS_DIR = path.join(ROOT, 'commands', 'msd');
|
|
const MSD_ROOT = path.join(ROOT, 'msd-core');
|
|
|
|
const {
|
|
CANONICAL_TOOLS,
|
|
parseFrontmatter,
|
|
executionContextRefs: extractExecutionContextRefs,
|
|
unreachableWorkflows,
|
|
} = require('./command-contract-helpers.cjs');
|
|
|
|
const { runMain } = require('./lib/cli-exit.cjs');
|
|
|
|
// ─── rule 6: repo-level workflow reachability ─────────────────────────────────
|
|
|
|
function walkMarkdownFiles(dir, acc) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (e) {
|
|
return acc;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walkMarkdownFiles(full, acc);
|
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
acc.push(full);
|
|
}
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
function toMsdRelative(absPath) {
|
|
return path.relative(MSD_ROOT, absPath).split(path.sep).join('/');
|
|
}
|
|
|
|
function checkWorkflowReachability() {
|
|
const loaderFiles = [
|
|
...walkMarkdownFiles(path.join(ROOT, 'commands'), []),
|
|
...walkMarkdownFiles(path.join(ROOT, 'agents'), []),
|
|
...walkMarkdownFiles(path.join(ROOT, 'skills'), []),
|
|
];
|
|
const loaderContents = loaderFiles.map(f => fs.readFileSync(f, 'utf-8'));
|
|
|
|
const msdFileAbsPaths = walkMarkdownFiles(MSD_ROOT, []);
|
|
const msdFiles = new Map();
|
|
for (const abs of msdFileAbsPaths) {
|
|
msdFiles.set(toMsdRelative(abs), fs.readFileSync(abs, 'utf-8'));
|
|
}
|
|
|
|
const workflowAbsPaths = walkMarkdownFiles(path.join(MSD_ROOT, 'workflows'), []);
|
|
const workflowPaths = workflowAbsPaths.map(toMsdRelative);
|
|
|
|
const unreachable = unreachableWorkflows(loaderContents, msdFiles, workflowPaths);
|
|
|
|
return { workflowCount: workflowPaths.length, unreachable };
|
|
}
|
|
|
|
// ─── check one file ───────────────────────────────────────────────────────────
|
|
|
|
function check(filePath) {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const rel = path.relative(ROOT, filePath);
|
|
const fm = parseFrontmatter(content);
|
|
const violations = [];
|
|
|
|
// 1. name: present + msd: / msd- prefix
|
|
if (!fm.name || !fm.name.trim()) {
|
|
violations.push('name: field missing or empty');
|
|
} else if (!/^msd[:-]/.test(fm.name.trim())) {
|
|
violations.push(`name: must start with "msd:" or "msd-", got "${fm.name.trim()}"`);
|
|
}
|
|
|
|
// 2. description: present + non-empty
|
|
if (!fm.description || !fm.description.trim()) {
|
|
violations.push('description: field missing or empty');
|
|
}
|
|
|
|
// 3. allowed-tools: present + non-empty + all entries canonical
|
|
if (!fm['allowed-tools'] || !fm['allowed-tools'].trim()) {
|
|
violations.push('allowed-tools: block missing or empty');
|
|
} else {
|
|
const tools = fm['allowed-tools'].split('\n').map(t => t.trim()).filter(Boolean);
|
|
for (const tool of tools) {
|
|
const valid =
|
|
CANONICAL_TOOLS.has(tool) ||
|
|
(tool.startsWith('mcp__context7__') && CANONICAL_TOOLS.has('mcp__context7__*'));
|
|
if (!valid) violations.push(`allowed-tools: unknown tool "${tool}"`);
|
|
}
|
|
}
|
|
|
|
// 4+5. execution_context @-refs resolve + no trailing prose
|
|
const refs = extractExecutionContextRefs(content);
|
|
for (const { token, normalized, trailingProse } of refs) {
|
|
const absPath = path.join(MSD_ROOT, normalized);
|
|
if (!fs.existsSync(absPath)) {
|
|
violations.push(`execution_context: @-ref "${normalized}" does not exist on disk`);
|
|
}
|
|
if (trailingProse) {
|
|
violations.push(`execution_context: @-ref "${token}" has trailing prose on the same line`);
|
|
}
|
|
}
|
|
|
|
if (violations.length === 0) return null;
|
|
return { file: rel, violations };
|
|
}
|
|
|
|
// ─── run ─────────────────────────────────────────────────────────────────────
|
|
|
|
function main() {
|
|
const commandFiles = fs
|
|
.readdirSync(COMMANDS_DIR)
|
|
.filter(f => f.endsWith('.md'))
|
|
.map(f => path.join(COMMANDS_DIR, f));
|
|
|
|
const results = commandFiles.map(check).filter(Boolean);
|
|
const { workflowCount, unreachable } = checkWorkflowReachability();
|
|
|
|
let ok = true;
|
|
|
|
if (results.length === 0) {
|
|
console.log(
|
|
`ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`,
|
|
);
|
|
} else {
|
|
ok = false;
|
|
const total = results.reduce((n, r) => n + r.violations.length, 0);
|
|
process.stderr.write(
|
|
`\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`,
|
|
);
|
|
for (const r of results) {
|
|
process.stderr.write(` ${r.file}\n`);
|
|
for (const v of r.violations) {
|
|
process.stderr.write(` - ${v}\n`);
|
|
}
|
|
process.stderr.write('\n');
|
|
}
|
|
process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n');
|
|
}
|
|
|
|
if (unreachable.length === 0) {
|
|
console.log(
|
|
`ok lint-command-contract: ${workflowCount} workflow files, ${workflowCount} reachable, 0 unreachable`,
|
|
);
|
|
} else {
|
|
ok = false;
|
|
process.stderr.write(
|
|
`\nERROR lint-command-contract: ${unreachable.length} unreachable workflow file(s) of ${workflowCount}\n\n`,
|
|
);
|
|
for (const p of unreachable) {
|
|
process.stderr.write(` msd-core/${p}\n`);
|
|
}
|
|
process.stderr.write(
|
|
'\nEach file above ships to every runtime but is never referenced by any command,\n' +
|
|
'agent, or skill loader (directly or transitively). Either wire it to a loader\n' +
|
|
'or delete it — removing a command must sweep its orphaned workflow.\n\n',
|
|
);
|
|
}
|
|
|
|
return ok ? 0 : 1;
|
|
}
|
|
|
|
runMain(main);
|