Project Files
src / validation / rules.ts
import type { CommandFile, ValidationRule, RuleResult } from "./types.js";
const ALLOWED_AGENTS = new Set([
"build",
"plan",
"general",
"explore",
"oracle",
]);
export function getAllRules(): ValidationRule[] {
return [
ruleHasFrontmatter(),
ruleValidYaml(),
ruleHasDescription(),
ruleDescriptionLength(),
ruleValidAgent(),
ruleValidModelFormat(),
ruleBodyNotEmpty(),
ruleArgsUsage(),
ruleFileRefFormat(),
ruleMaxLines(),
ruleSubtaskBoolean(),
ruleHasAgentField(),
];
}
function result(
ruleId: string,
severity: RuleResult["severity"],
message: string,
fix?: string,
line?: number,
): RuleResult {
return { ruleId, severity, message, fix, line };
}
// ---- Rule definitions ----
function ruleHasFrontmatter(): ValidationRule {
return {
id: "has-frontmatter",
severity: "error",
description: "Command file must have YAML frontmatter",
check: (cmd) => {
if (cmd.frontmatter === null && !cmd.parseError?.includes("YAML")) {
return result(
"has-frontmatter",
"error",
"File must have a YAML frontmatter block (---)",
);
}
return null;
},
};
}
function ruleValidYaml(): ValidationRule {
return {
id: "valid-yaml",
severity: "error",
description: "Frontmatter must be valid YAML",
check: (cmd) => {
if (
cmd.parseError &&
(cmd.parseError.includes("YAML") ||
cmd.parseError.includes("frontmatter"))
) {
return result("valid-yaml", "error", cmd.parseError);
}
return null;
},
};
}
function ruleHasDescription(): ValidationRule {
return {
id: "has-description",
severity: "error",
description: 'Frontmatter must contain a "description" field',
check: (cmd) => {
if (cmd.frontmatter && !("description" in cmd.frontmatter)) {
return result(
"has-description",
"error",
'Missing required field: "description" in frontmatter',
"Add: description: Short description of what this command does",
);
}
if (
cmd.frontmatter &&
cmd.frontmatter.description !== undefined &&
cmd.frontmatter.description === ""
) {
return result(
"has-description",
"error",
'"description" field is present but empty',
"Add a non-empty description",
);
}
return null;
},
};
}
function ruleDescriptionLength(): ValidationRule {
return {
id: "description-length",
severity: "warning",
description: "Description should be at least 10 characters",
check: (cmd) => {
const desc = cmd.frontmatter?.description;
if (desc && typeof desc === "string" && desc.trim().length < 10) {
return result(
"description-length",
"warning",
`Description is too short (${desc.trim().length} chars). Should be at least 10 characters.`,
`Extend the description to be more descriptive (currently: "${desc.trim()}")`,
);
}
return null;
},
};
}
function ruleValidAgent(): ValidationRule {
return {
id: "valid-agent",
severity: "warning",
description: "Agent field must be a known agent type",
check: (cmd) => {
const agent = cmd.frontmatter?.agent;
if (agent !== undefined && agent !== null) {
const agentStr = String(agent);
if (!ALLOWED_AGENTS.has(agentStr)) {
return result(
"valid-agent",
"warning",
`Unknown agent type: "${agentStr}". Allowed: ${[...ALLOWED_AGENTS].join(", ")}`,
`Change to one of: ${[...ALLOWED_AGENTS].join(", ")}`,
);
}
}
return null;
},
};
}
function ruleValidModelFormat(): ValidationRule {
return {
id: "valid-model-format",
severity: "warning",
description: "Model field should use provider/model format",
check: (cmd) => {
const model = cmd.frontmatter?.model;
if (model !== undefined && model !== null && model !== "") {
const modelStr = String(model);
if (!modelStr.includes("/")) {
return result(
"valid-model-format",
"warning",
`Model "${modelStr}" doesn't follow the "provider/model" format`,
"Use format like: opencode-go/kimi-k2.6 or anthropic/claude-sonnet-4",
);
}
}
return null;
},
};
}
function ruleBodyNotEmpty(): ValidationRule {
return {
id: "body-not-empty",
severity: "error",
description: "Command body (below frontmatter) must not be empty",
check: (cmd) => {
if (!cmd.body || cmd.body.trim().length === 0) {
return result(
"body-not-empty",
"error",
"Command body is empty. Add instructions after the frontmatter block.",
);
}
return null;
},
};
}
function ruleArgsUsage(): ValidationRule {
return {
id: "args-usage",
severity: "warning",
description:
"If command uses $ARGUMENTS or positional args, usage should be documented",
check: (cmd) => {
if (!cmd.body) return null;
const hasGlobalArg = /\$ARGUMENTS\b/.test(cmd.body);
const hasPositional = /\$\d\b/.test(cmd.body);
if (!hasGlobalArg && !hasPositional) return null;
const hasUsageSection = /\b(użycie|usage|przykład|example)\b/i.test(
cmd.body,
);
if (!hasUsageSection) {
let msg = "Command uses ";
if (hasGlobalArg && hasPositional) {
msg += "$ARGUMENTS and positional arguments ($1, $2, ...)";
} else if (hasGlobalArg) {
msg += "$ARGUMENTS";
} else {
msg += "positional arguments ($1, $2, ...)";
}
msg += ' but no "Usage" or "Example" section found';
return result(
"args-usage",
"warning",
msg,
'Add a "## Usage" section explaining how to use the arguments',
);
}
return null;
},
};
}
function ruleFileRefFormat(): ValidationRule {
return {
id: "file-ref-format",
severity: "warning",
description: "@file references should follow valid path format",
check: (cmd) => {
if (!cmd.body) return null;
const refRegex = /@([^\s`"')\]}>]+)/g;
const refs: string[] = [];
let match: RegExpExecArray | null;
while ((match = refRegex.exec(cmd.body)) !== null) {
const ref = match[1];
// Skip if it's an email or URL
if (ref.includes("@") || ref.startsWith("http")) continue;
// Skip markdown links [text](@...)
const before = cmd.body.slice(
Math.max(0, match.index - 1),
match.index,
);
if (before === "(") continue;
refs.push(ref);
}
if (refs.length > 0) {
const brokenRefs = refs.filter(
(r) => r.length > 3 && !r.includes("/") && !r.includes("\\"),
);
if (brokenRefs.length > 0) {
return result(
"file-ref-format",
"warning",
`File references may be invalid: ${brokenRefs.join(", ")}. Expected format: @path/to/file`,
`Ensure paths use format: @src/components/Button.tsx`,
);
}
}
return null;
},
};
}
function ruleMaxLines(): ValidationRule {
return {
id: "max-lines",
severity: "warning",
description: "Command file should not exceed 150 lines",
check: (cmd) => {
if (cmd.lineCount > 150) {
return result(
"max-lines",
"warning",
`File exceeds 150 lines (${cmd.lineCount} lines). Consider splitting into multiple commands.`,
`Currently ${cmd.lineCount} lines. Max recommended: 150.`,
);
}
return null;
},
};
}
function ruleSubtaskBoolean(): ValidationRule {
return {
id: "subtask-boolean",
severity: "warning",
description: "Subtask field must be a boolean (true/false)",
check: (cmd) => {
if (cmd.frontmatter && "subtask" in cmd.frontmatter) {
const val = cmd.frontmatter.subtask;
if (typeof val !== "boolean") {
return result(
"subtask-boolean",
"warning",
`"subtask" must be true or false, got: ${JSON.stringify(val)}`,
`Change to: subtask: ${String(val === "true" || val === true)}`,
);
}
}
return null;
},
};
}
function ruleHasAgentField(): ValidationRule {
return {
id: "has-agent-field",
severity: "info",
description: "Recommend adding an agent field",
check: (cmd) => {
if (cmd.frontmatter && !("agent" in cmd.frontmatter)) {
return result(
"has-agent-field",
"info",
'No "agent" field in frontmatter. Recommended to add one.',
"Add: agent: build",
);
}
return null;
},
};
}