feat: MCP server + in-repo skills (agent-friendly platform) (#2269)

* feat(api): support global API key from config.yaml (api.global_api_key)

Accept a config-defined global API key anywhere a web-UI key is accepted
(X-API-Key / Bearer), with no login session and no DB record. Useful for
automated deployments and AI agents (HTTP API + MCP). Defaults to empty
(disabled); does not require the lbk_ prefix.

- templates/config.yaml: add api.global_api_key with security notes
- service/apikey.py: verify_api_key checks global key first (constant-time)
- docs/API_KEY_AUTH.md: document the global key + security guidance
- tests: cover global-key match, prefix-free, fallback-to-db, disabled

* feat(mcp): expose LangBot management as an MCP server at /mcp

Add an MCP (Model Context Protocol) server so external AI agents can manage a
LangBot instance. Reuses the same API-key auth as the HTTP API (including the
config.yaml global API key).

- pkg/api/mcp/server.py: FastMCP server wrapping the service layer; 21 curated
  tools across system/bots/pipelines/models/knowledge/mcp-servers/skills
- pkg/api/mcp/mount.py: ASGI dispatcher fronting Quart; authenticates /mcp
  requests with an API key, runs the streamable-HTTP session manager lifespan
- controller/main.py: serve the wrapped ASGI app via hypercorn (was run_task)
- web: new 'MCP' tab in the API integration dialog showing endpoint, auth, and
  client config; i18n for 8 locales
- tests/manual/mcp_smoke.py: e2e check (401 unauth, list tools, call tools)

Tool surface is intentionally curated (not all ~25 route groups) to keep the
agent surface small, safe, and maintainable. Extend deliberately.

* feat(skills): add in-repo skills/ as the single source of truth

Migrate the agent skills + QA/e2e test harness from the (now archived)
langbot-app/langbot-skills repo into LangBot/skills/, and add four new skills.

Migrated:
- langbot-plugin-dev, langbot-testing (e2e), langbot-env-setup,
  langbot-skills-maintenance, langbot-eba-adapter-dev
- the bin/lbs CLI (src/, test/, scripts/, schemas/, qa-agent-docs/)

New:
- langbot-dev      core backend + web development
- langbot-deploy   Docker/K8s deployment + config.yaml + global API key
- langbot-mcp-ops  operating the LangBot MCP server (/mcp)
- langbot-space-ops operating the Space marketplace MCP server

- src/cli.ts repoRoot(): recognize the skills assets root (skills.index.json +
  bin/lbs) so the CLI works when nested inside the LangBot repo
- README.md: unified skill catalog; skills.index.json regenerated

Parity with source verified: bin/lbs validate + node test suite match the
source repo (only the uncommitted .lbpkg build-artifact fixture differs).

* docs(agents): document agent-facing surfaces + API/MCP/skills sync rule

* docs(readme): add 'Built for AI Agents' section across all locales

Highlight MCP server, in-repo skills (single source of truth), AGENTS.md
sync rule, and llms.txt. Cross-link LangBot Space MCP marketplace.

* style(mcp): fix ruff format + prettier lint in MCP server and API panel

* style(web): prettier format MCP i18n locale entries

* docs(skills): note MCP instance control in dev/testing skills

All development-guidance skills now point to the LangBot instance MCP
server (/mcp) and the Space marketplace MCP server, reusing API keys.
This commit is contained in:
Junyan Chin
2026-06-20 15:14:47 +08:00
committed by GitHub
parent 91906d73be
commit e9dd584792
214 changed files with 25227 additions and 31 deletions
+180
View File
@@ -0,0 +1,180 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { CommandContext } from "../types.ts";
import { parseOptions, optionString, usage, fail } from "../cli.ts";
import { caseModeValues } from "../constants.ts";
import { boolValue, findStructuredItem, getSkill, listValue, loadStructuredItems, scalar, yamlList, yamlQuote } from "../fs.ts";
import { caseAutomationReadiness, caseEnvReadiness, caseFixtureReadiness, caseManualReadiness, runtimeEnv } from "../readiness.ts";
import { setupAutomationEntries } from "../setup-automation.ts";
function casePath(root: string, skillName: string, id: string): string {
const skill = getSkill(root, skillName);
const dir = join(skill.path, "cases");
mkdirSync(dir, { recursive: true });
return join(dir, `${id}.yaml`);
}
export function commandCaseNew(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(2));
const id = positional[0];
const title = optionString(options, "title");
if (!id || !title) usage();
const skill = optionString(options, "skill") ?? "langbot-testing";
const path = casePath(ctx.root, skill, id);
if (existsSync(path)) fail(`case already exists: ${path}`);
const area = optionString(options, "area") ?? "general";
const type = optionString(options, "type") ?? "smoke";
const mode = optionString(options, "mode") ?? "agent-browser";
if (!caseModeValues.includes(mode)) fail(`--mode must be one of ${caseModeValues.join(", ")}`);
const isProbe = mode === "probe";
const text =
`id: ${id}\n` +
`title: ${yamlQuote(title)}\n` +
`mode: ${mode}\n` +
`area: ${area}\n` +
`type: ${type}\n` +
"priority: p2\n" +
"risk: medium\n" +
"ci_eligible: false\n" +
"tags:\n" +
yamlList([type]) +
"\nskills:\n" +
yamlList(["langbot-env-setup", skill]) +
"\nenv:\n" +
yamlList(isProbe ? [] : ["LANGBOT_FRONTEND_URL", "LANGBOT_BACKEND_URL"]) +
"\nsteps:\n" +
yamlList([isProbe ? "Describe the probe command, script, or diagnostic to run." : "Describe the user-visible action to perform."]) +
"\nchecks:\n" +
yamlList(isProbe
? [
"Probe: Describe the expected success signal.",
"Evidence: Required logs, API diagnostics, or filesystem artifacts are written.",
]
: [
"UI: Describe the user-visible success signal.",
"Console: No unexpected frontend errors.",
"Logs: Relevant backend processing completed when applicable.",
]) +
"\nevidence_required:\n" +
yamlList(isProbe ? ["api_diagnostic"] : ["ui", "console"]) +
"\ndiagnostics:\n" +
yamlList([isProbe
? "Use logs, API, or filesystem diagnostics to explain probe failures."
: "Use API/curl/logs only to distinguish frontend failure from backend/runtime failure."]) +
"\ntroubleshooting:\n" +
yamlList([]) +
"\n";
writeFileSync(path, text, "utf8");
console.log(path);
return 0;
}
function caseRow(item: ReturnType<typeof loadStructuredItems>[number], root: string): Record<string, unknown> {
const automation = scalar(item.fields, "automation");
const env = runtimeEnv(root);
const id = scalar(item.fields, "id");
const row = {
skill: item.skill,
id,
title: scalar(item.fields, "title"),
mode: scalar(item.fields, "mode"),
area: scalar(item.fields, "area"),
type: scalar(item.fields, "type"),
priority: scalar(item.fields, "priority"),
risk: scalar(item.fields, "risk"),
ci_eligible: boolValue(item.fields, "ci_eligible") ?? false,
tags: listValue(item.fields, "tags"),
env: listValue(item.fields, "env"),
env_any: listValue(item.fields, "env_any"),
preconditions: listValue(item.fields, "preconditions"),
setup: listValue(item.fields, "setup"),
setup_automation: setupAutomationEntries(item),
setup_provides_env: listValue(item.fields, "setup_provides_env"),
cleanup: listValue(item.fields, "cleanup"),
evidence_required: listValue(item.fields, "evidence_required"),
automation,
automation_exists: automation ? existsSync(resolve(root, automation)) : false,
env_readiness: caseEnvReadiness(item, env),
automation_readiness: caseAutomationReadiness(item, env),
fixture_readiness: caseFixtureReadiness(root, id),
manual_readiness: caseManualReadiness(item),
};
return {
...row,
readiness: readinessLabel(row),
};
}
function hasTag(row: Record<string, unknown>, tag: string): boolean {
const tags = row.tags;
return Array.isArray(tags) && tags.includes(tag);
}
function hasMissingReadiness(row: Record<string, unknown>): boolean {
for (const key of ["env_readiness", "automation_readiness", "fixture_readiness"]) {
const value = row[key] as Record<string, unknown> | undefined;
if (value?.status === "missing") return true;
}
return false;
}
function hasManualCheck(row: Record<string, unknown>): boolean {
const manual = row.manual_readiness as Record<string, unknown> | undefined;
return manual?.status === "manual_check";
}
function readinessLabel(row: Record<string, unknown>): string {
if (hasMissingReadiness(row)) return "not-ready";
return hasManualCheck(row) ? "manual-check" : "ready";
}
export function commandCaseList(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(2));
const skill = positional[0];
const rows = loadStructuredItems(ctx.root, "cases", skill)
.map((item) => caseRow(item, ctx.root))
.filter((row) => !optionString(options, "type") || row.type === optionString(options, "type"))
.filter((row) => !optionString(options, "area") || row.area === optionString(options, "area"))
.filter((row) => !optionString(options, "priority") || row.priority === optionString(options, "priority"))
.filter((row) => !optionString(options, "risk") || row.risk === optionString(options, "risk"))
.filter((row) => !optionString(options, "tag") || hasTag(row, optionString(options, "tag") ?? ""))
.filter((row) => options.automation !== true || Boolean(row.automation))
.filter((row) => options.ci !== true || row.ci_eligible === true)
.filter((row) => options["machine-ready"] !== true || !hasMissingReadiness(row))
.filter((row) => options.ready !== true || (!hasMissingReadiness(row) && !hasManualCheck(row)));
if (options.json === true) {
console.log(JSON.stringify(rows, null, 2));
return 0;
}
for (const row of rows) {
console.log([
row.skill,
row.id,
row.type,
row.area,
row.priority,
row.risk,
row.ci_eligible ? "ci" : "manual",
row.automation ? "automated" : "manual-path",
row.readiness,
row.title,
].join("\t"));
}
return 0;
}
export function commandCaseShow(ctx: CommandContext): number {
const positional = ctx.args.slice(2);
if (positional.length < 1 || positional.length > 2) usage();
const item = positional.length === 1
? findStructuredItem(ctx.root, "cases", positional[0])
: findStructuredItem(ctx.root, "cases", positional[0], positional[1]);
console.log(item.raw.trimEnd());
return 0;
}
+140
View File
@@ -0,0 +1,140 @@
import { existsSync } from "node:fs";
import { Socket } from "node:net";
import type { CommandContext } from "../types.ts";
import { parseOptions } from "../cli.ts";
import { loadEnv } from "../fs.ts";
import { requiredEnvKeys } from "../constants.ts";
import { redactEnvValue } from "../readiness.ts";
export function commandEnvShow(ctx: CommandContext): number {
const { options } = parseOptions(ctx.args.slice(2));
const env = loadEnv(ctx.root);
const outputEnv = Object.fromEntries(
Object.entries(env).map(([key, value]) => [key, redactEnvValue(key, value)]),
);
if (options.json === true) {
console.log(JSON.stringify(outputEnv, null, 2));
return 0;
}
for (const key of Object.keys(outputEnv).sort()) {
console.log(`${key}=${outputEnv[key]}`);
}
return 0;
}
async function checkUrl(label: string, url: string): Promise<{ ok: boolean; message: string }> {
if (!url) return { ok: false, message: `${label}: missing` };
const displayUrl = redactEnvValue(label, url);
try {
const response = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(2500) });
return { ok: response.ok || response.status < 500, message: `${label}: ${displayUrl} -> HTTP ${response.status}` };
} catch (error) {
return { ok: false, message: `${label}: ${displayUrl} -> ${String(error).replace(/\s+/g, " ")}` };
}
}
function endpoint(url: string): { host: string; port: number } | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
return { host: parsed.hostname, port };
} catch {
return null;
}
}
async function checkTcpListener(url: string): Promise<{ ok: boolean; message: string } | null> {
const target = endpoint(url);
if (!target) return null;
return await new Promise((resolve) => {
const socket = new Socket();
let settled = false;
const finish = (ok: boolean, detail: string) => {
if (settled) return;
settled = true;
socket.destroy();
resolve({
ok,
message: `${target.host}:${target.port} ${detail}`,
});
};
socket.setTimeout(1500);
socket.once("connect", () => finish(true, "is listening"));
socket.once("timeout", () => finish(false, "did not accept TCP connection before timeout"));
socket.once("error", (error) => finish(false, `is not listening (${error.message})`));
socket.connect(target.port, target.host);
});
}
function startupHint(label: string, env: Record<string, string>): string | null {
if (label === "LANGBOT_BACKEND_URL" && env.LANGBOT_REPO) {
return `start backend: cd ${env.LANGBOT_REPO} && uv run main.py`;
}
if (label === "LANGBOT_FRONTEND_URL" && env.LANGBOT_WEB_REPO) {
return `start frontend: cd ${env.LANGBOT_WEB_REPO} && pnpm dev`;
}
return null;
}
function compareProxyPair(env: Record<string, string>, upper: string, lower: string): string | null {
const upperValue = process.env[upper] ?? env[upper] ?? "";
const lowerValue = process.env[lower] ?? env[lower] ?? "";
if (upperValue && lowerValue && upperValue !== lowerValue) {
return `${upper}/${lower}: mismatch (${redactEnvValue(upper, upperValue)} vs ${redactEnvValue(lower, lowerValue)})`;
}
return null;
}
export async function commandEnvDoctor(ctx: CommandContext): Promise<number> {
const env = loadEnv(ctx.root);
const failures: string[] = [];
const warnings: string[] = [];
for (const key of requiredEnvKeys) {
if (!env[key]) failures.push(`missing ${key}`);
}
for (const [label, path] of [
["LANGBOT_REPO", env.LANGBOT_REPO],
["LANGBOT_WEB_REPO", env.LANGBOT_WEB_REPO],
["LANGBOT_CHROMIUM_EXECUTABLE", env.LANGBOT_CHROMIUM_EXECUTABLE],
]) {
if (!path || !existsSync(path)) failures.push(`${label}: path does not exist (${path || "missing"})`);
}
if (env.LANGBOT_BROWSER_PROFILE && !existsSync(env.LANGBOT_BROWSER_PROFILE)) {
warnings.push(`LANGBOT_BROWSER_PROFILE: path does not exist yet (${env.LANGBOT_BROWSER_PROFILE})`);
}
for (const mismatch of [
compareProxyPair(env, "HTTP_PROXY", "http_proxy"),
compareProxyPair(env, "HTTPS_PROXY", "https_proxy"),
compareProxyPair(env, "ALL_PROXY", "all_proxy"),
compareProxyPair(env, "NO_PROXY", "no_proxy"),
]) {
if (mismatch) failures.push(mismatch);
}
for (const [label, result] of await Promise.all([
checkUrl("LANGBOT_BACKEND_URL", env.LANGBOT_BACKEND_URL).then((result) => ["LANGBOT_BACKEND_URL", result] as const),
checkUrl("LANGBOT_FRONTEND_URL", env.LANGBOT_FRONTEND_URL).then((result) => ["LANGBOT_FRONTEND_URL", result] as const),
])) {
if (result.ok) console.log(`OK: ${result.message}`);
else {
failures.push(result.message);
const tcp = await checkTcpListener(env[label]);
if (tcp && !tcp.ok) failures.push(`${label}: no HTTP service reachable because ${tcp.message}`);
const hint = startupHint(label, env);
if (hint) warnings.push(`${label}: ${hint}`);
}
}
for (const warning of warnings) console.log(`WARN: ${warning}`);
for (const failure of failures) console.log(`FAIL: ${failure}`);
if (failures.length > 0) return 1;
console.log("OK: environment looks usable");
return 0;
}
+132
View File
@@ -0,0 +1,132 @@
import type { CommandContext } from "../types.ts";
import { parseOptions } from "../cli.ts";
import { loadFixtureItems } from "../fixtures.ts";
import { dirname, join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
function fixtureRows(root: string, skill: string | undefined): ReturnType<typeof loadFixtureItems> {
return loadFixtureItems(root, skill);
}
function qaAgentRunnerSourceFindings(item: ReturnType<typeof loadFixtureItems>["items"][number]) {
if (!item.checks.includes("qa_agent_runner_source") || !item.exists) return [];
const root = dirname(item.absolute_path);
const required = [
"main.py",
"components/agent_runner/default.yaml",
"components/agent_runner/default.py",
"assets/icon.svg",
];
const missing = required
.filter((path) => !existsSync(join(root, path)))
.map((path) => ({
severity: "fail",
kind: "fixture_check_missing_file",
id: item.id,
path: `${item.path.replace(/\/[^/]+$/, "")}/${path}`,
}));
if (missing.length > 0) return missing;
const manifest = readFileSync(item.absolute_path, "utf8");
const runnerYaml = readFileSync(join(root, "components/agent_runner/default.yaml"), "utf8");
const runnerPy = readFileSync(join(root, "components/agent_runner/default.py"), "utf8");
const requiredText = [
[manifest, "AgentRunner", "manifest.yaml"],
[manifest, "QAAgentRunnerPlugin", "manifest.yaml"],
[runnerYaml, "kind: AgentRunner", "components/agent_runner/default.yaml"],
[runnerYaml, "DefaultAgentRunner", "components/agent_runner/default.yaml"],
[runnerPy, "QA_AGENT_RUNNER_OK", "components/agent_runner/default.py"],
[runnerPy, "QA_AGENT_RUNNER_CONTROLLED_FAILURE", "components/agent_runner/default.py"],
];
return requiredText
.filter(([text, needle]) => !text.includes(needle))
.map(([, needle, relativePath]) => ({
severity: "fail",
kind: "fixture_check_missing_text",
id: item.id,
path: `${item.path.replace(/\/[^/]+$/, "")}/${relativePath}`,
detail: `missing ${needle}`,
}));
}
function zipPackageFindings(item: ReturnType<typeof loadFixtureItems>["items"][number]) {
if (!item.checks.includes("zip_package") || !item.exists) return [];
const header = readFileSync(item.absolute_path).subarray(0, 4).toString("binary");
if (header === "PK\u0003\u0004" || header === "PK\u0005\u0006") return [];
return [{
severity: "fail",
kind: "fixture_check_invalid_zip",
id: item.id,
path: item.path,
}];
}
export function commandFixtureList(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(2));
const skill = positional[0];
const result = fixtureRows(ctx.root, skill);
if (options.json === true) {
console.log(JSON.stringify(result.items, null, 2));
return result.errors.length > 0 ? 1 : 0;
}
for (const item of result.items) {
console.log([
item.skill,
item.id,
item.kind,
item.exists ? "present" : "missing",
item.path,
item.title,
].join("\t"));
}
for (const error of result.errors) console.error(`ERROR: ${error}`);
return result.errors.length > 0 ? 1 : 0;
}
export function commandFixtureCheck(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(2));
const skill = positional[0];
const result = fixtureRows(ctx.root, skill);
const findings = [
...result.errors.map((error) => ({ severity: "fail", kind: "invalid_manifest", detail: error })),
...result.items
.filter((item) => !item.exists)
.map((item) => ({
severity: "fail",
kind: "missing_fixture",
id: item.id,
path: item.path,
absolute_path: item.absolute_path,
})),
...result.items.flatMap(qaAgentRunnerSourceFindings),
...result.items.flatMap(zipPackageFindings),
];
const report = {
status: findings.some((finding) => finding.severity === "fail") ? "fail" : "pass",
fixture_count: result.items.length,
findings,
fixtures: result.items,
};
if (options.json === true) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(`# Fixture Check`);
console.log("");
console.log(`status: ${report.status}`);
console.log(`fixture_count: ${report.fixture_count}`);
console.log("");
console.log("## Fixtures");
for (const item of result.items) {
console.log(`- ${item.id}: ${item.exists ? "present" : "missing"} (${item.path})`);
}
console.log("");
console.log("## Findings");
if (findings.length === 0) console.log("- None.");
else for (const finding of findings) console.log(`- [${finding.severity}] ${finding.kind}: ${"detail" in finding ? finding.detail : finding.id}`);
}
return report.status === "pass" ? 0 : 1;
}
+427
View File
@@ -0,0 +1,427 @@
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { setTimeout as delay } from "node:timers/promises";
import { dirname, join, resolve } from "node:path";
import type { CommandContext } from "../types.ts";
import { optionString, parseOptions, usage } from "../cli.ts";
import { findStructuredItem, loadEnv } from "../fs.ts";
import {
latestLangBotLogPath,
logPatternContextFromStructuredItem,
renderLogFinding,
renderLogSuccessSignal,
scanLogSources,
scanLogText,
strictLogGuardExitCode,
type LogFinding,
type LogGuardPatternContext,
type LogGuardResult,
type LogSuccessSignal,
} from "../log-guard.ts";
type LogGuardSession = {
source: "log-guard-session";
run_id: string;
started_at: string;
started_at_local: string;
backend_log: string;
case_id: string;
case_skill: string;
};
type WatchSummary = {
mode: "watch";
status: string;
path: string;
started_at_local: string;
finished_at_local: string;
bytes_read: number;
findings: LogFinding[];
success_signals: LogSuccessSignal[];
};
function pad2(value: number): string {
return String(value).padStart(2, "0");
}
function pad3(value: number): string {
return String(value).padStart(3, "0");
}
function localIsoWithOffset(date: Date): string {
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absoluteOffset = Math.abs(offsetMinutes);
return [
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
`T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`,
`${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`,
].join("");
}
function timestampSlug(localIso: string): string {
return localIso
.replace(/T/, "-")
.replace(/[.:+]/g, "-")
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function writeOrPrint(content: string, output: string | undefined): void {
if (!output) {
console.log(content.trimEnd());
return;
}
const path = resolve(output);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, "utf8");
console.log(path);
}
function positiveIntegerOption(options: Record<string, string | boolean>, key: string, fallback: number): number {
const raw = optionString(options, key);
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!/^\d+$/.test(raw) || parsed <= 0) return fallback;
return parsed;
}
function splitPatternList(value: string | undefined): string[] {
if (!value) return [];
return value
.split(/\s*\|\s*|\s*,\s*/)
.map((item) => item.trim())
.filter(Boolean);
}
function patternContextFromOptions(root: string, options: Record<string, string | boolean>): LogGuardPatternContext {
const caseId = optionString(options, "case");
const base = caseId ? logPatternContextFromStructuredItem(findStructuredItem(root, "cases", caseId)) : {};
return {
successPatterns: [
...(base.successPatterns ?? []),
...splitPatternList(optionString(options, "success-pattern")),
],
failurePatterns: [
...(base.failurePatterns ?? []),
...splitPatternList(optionString(options, "failure-pattern")),
],
expectedFailures: [
...(base.expectedFailures ?? []),
...splitPatternList(optionString(options, "expected-failure")),
],
relatedTroubleshootingIds: base.relatedTroubleshootingIds ?? [],
};
}
function latestOrExplicitBackendLog(root: string, options: Record<string, string | boolean>): string {
const explicit = optionString(options, "backend-log");
if (explicit) return resolve(explicit);
const auto = latestLangBotLogPath(loadEnv(root));
return auto ? resolve(auto) : "";
}
function renderSources(result: LogGuardResult): string[] {
const lines: string[] = [];
if (result.sources.length === 0) {
lines.push("- sources: no log files provided; use --backend-log or configure LANGBOT_REPO.");
return lines;
}
lines.push("- sources:");
for (const source of result.sources) {
const origin = source.auto_detected ? ", auto" : "";
const total = source.total_line_count === undefined ? "" : `/${source.total_line_count}`;
const range = source.start_line === undefined || source.end_line === undefined
? ""
: `, lines ${source.start_line}-${source.end_line}`;
const timestamped = source.timestamped_line_count === undefined ? "" : `, ${source.timestamped_line_count} timestamped`;
lines.push(` - ${source.source}: ${source.path} (${source.status}${origin}, ${source.line_count}${total} lines${range}${timestamped})`);
}
return lines;
}
function renderLogGuardMarkdown(title: string, result: LogGuardResult, extra: string[] = []): string {
const lines: string[] = [];
lines.push(`# ${title}`);
lines.push("");
lines.push(`Generated: ${new Date().toISOString()}`);
lines.push(`Status: ${result.status}`);
lines.push(`Scan mode: ${result.scan.mode}`);
if (result.scan.since) lines.push(`Since: ${result.scan.since}`);
if (result.scan.until) lines.push(`Until: ${result.scan.until}`);
if (result.scan.tail_lines !== undefined) lines.push(`Tail lines: ${result.scan.tail_lines}`);
if (extra.length > 0) {
lines.push("");
lines.push("## Context");
for (const item of extra) lines.push(`- ${item}`);
}
lines.push("");
lines.push("## Sources");
lines.push(...renderSources(result));
if (result.scan.warnings.length > 0) {
lines.push("");
lines.push("## Scan Warnings");
for (const warning of result.scan.warnings) lines.push(`- ${warning}`);
}
lines.push("");
lines.push("## Findings");
if (result.findings.length === 0) lines.push("- None.");
else for (const finding of result.findings) lines.push(renderLogFinding(finding));
lines.push("");
lines.push("## Success Signals");
if (result.success_signals.length === 0) lines.push("- None.");
else for (const signal of result.success_signals) lines.push(renderLogSuccessSignal(signal));
lines.push("");
return `${lines.join("\n").trimEnd()}\n`;
}
function statusFromEvents(findings: LogFinding[], successSignals: LogSuccessSignal[]): string {
if (findings.some((finding) => finding.severity === "fail" || finding.severity === "missing_input")) return "fail";
if (findings.some((finding) => finding.severity === "matched_troubleshooting" && finding.related_to_case !== false)) return "fail";
if (findings.some((finding) => finding.severity === "env_issue")) return "env_issue";
if (findings.some((finding) => finding.severity === "warning")) return "warning";
if (successSignals.length > 0) return "pass";
return "no_activity";
}
function strictSummaryExitCode(status: string): number {
return status === "fail" || status === "env_issue" ? 1 : 0;
}
function sessionDir(options: Record<string, string | boolean>): string {
return optionString(options, "output-dir") ?? join("reports", "log-guards");
}
function sessionPath(options: Record<string, string | boolean>, runId: string): string {
return join(sessionDir(options), `${runId}.json`);
}
function readSession(options: Record<string, string | boolean>): LogGuardSession | undefined {
const runId = optionString(options, "run-id");
const explicitSession = optionString(options, "session");
const path = explicitSession ? resolve(explicitSession) : runId ? resolve(sessionPath(options, runId)) : "";
if (!path || !existsSync(path)) return undefined;
return JSON.parse(readFileSync(path, "utf8")) as LogGuardSession;
}
export function commandLogScan(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(2));
if (positional.length > 0) usage();
const result = scanLogSources(ctx.root, options, patternContextFromOptions(ctx.root, options));
const output = optionString(options, "output");
const content = options.json === true
? `${JSON.stringify(result, null, 2)}\n`
: renderLogGuardMarkdown("Log Guard Scan", result, [
optionString(options, "case") ? `case: ${optionString(options, "case")}` : "case: none",
options.strict === true ? "strict: yes" : "strict: no",
]);
writeOrPrint(content, output);
return options.strict === true ? strictLogGuardExitCode(result) : 0;
}
export async function commandLogWatch(ctx: CommandContext): Promise<number> {
const { positional, options } = parseOptions(ctx.args.slice(2));
if (positional.length > 0) usage();
const path = latestOrExplicitBackendLog(ctx.root, options);
if (!path) {
console.error("ERROR: no backend log found; pass --backend-log or configure LANGBOT_REPO.");
return 1;
}
if (!existsSync(path)) {
console.error(`ERROR: backend log does not exist: ${path}`);
return 1;
}
const context = patternContextFromOptions(ctx.root, options);
const intervalMs = positiveIntegerOption(options, "interval-ms", 1000);
const durationMs = optionString(options, "duration-ms")
? positiveIntegerOption(options, "duration-ms", 0)
: 0;
const startedAtLocal = localIsoWithOffset(new Date());
const findings: LogFinding[] = [];
const successSignals: LogSuccessSignal[] = [];
let bytesRead = 0;
let offset = options["from-start"] === true ? 0 : statSync(path).size;
let baseLineNumber = options["from-start"] === true
? 0
: readFileSync(path).subarray(0, offset).toString("utf8").split(/\r?\n/).length - 1;
let carry = "";
if (options.json !== true) {
console.log(`# Log Guard Watch`);
console.log(`Path: ${path}`);
console.log(`Started: ${startedAtLocal}`);
console.log(`Mode: ${options["from-start"] === true ? "from-start" : "new-lines"}`);
}
const startedMs = Date.now();
let stopRequested = false;
const stop = (): void => {
stopRequested = true;
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
const poll = (): void => {
const buffer = readFileSync(path);
if (buffer.length < offset) {
offset = 0;
baseLineNumber = 0;
carry = "";
}
if (buffer.length === offset) return;
const chunk = buffer.subarray(offset).toString("utf8");
offset = buffer.length;
bytesRead += Buffer.byteLength(chunk);
const text = `${carry}${chunk}`;
const hasCompleteLine = /\r?\n$/.test(text);
const lastNewline = Math.max(text.lastIndexOf("\n"), text.lastIndexOf("\r"));
if (!hasCompleteLine && lastNewline === -1) {
carry = text;
return;
}
const complete = hasCompleteLine ? text : text.slice(0, lastNewline + 1);
carry = hasCompleteLine ? "" : text.slice(lastNewline + 1);
if (!complete) return;
const result = scanLogText(ctx.root, "backend", path, complete, {}, context, baseLineNumber, false);
baseLineNumber += complete.split(/\r?\n/).length - 1;
findings.push(...result.findings);
successSignals.push(...result.success_signals);
if (options.json !== true) {
for (const finding of result.findings) console.log(renderLogFinding(finding));
for (const signal of result.success_signals) console.log(renderLogSuccessSignal(signal));
}
};
try {
do {
poll();
if (stopRequested) break;
if (durationMs > 0 && Date.now() - startedMs >= durationMs) break;
await delay(Math.min(intervalMs, durationMs > 0 ? Math.max(1, durationMs - (Date.now() - startedMs)) : intervalMs));
} while (!stopRequested);
if (carry) {
const result = scanLogText(ctx.root, "backend", path, carry, {}, context, baseLineNumber, false);
findings.push(...result.findings);
successSignals.push(...result.success_signals);
if (options.json !== true) {
for (const finding of result.findings) console.log(renderLogFinding(finding));
for (const signal of result.success_signals) console.log(renderLogSuccessSignal(signal));
}
}
} finally {
process.off("SIGINT", stop);
process.off("SIGTERM", stop);
}
const summary: WatchSummary = {
mode: "watch",
status: statusFromEvents(findings, successSignals),
path,
started_at_local: startedAtLocal,
finished_at_local: localIsoWithOffset(new Date()),
bytes_read: bytesRead,
findings,
success_signals: successSignals,
};
if (options.json === true) {
console.log(JSON.stringify(summary, null, 2));
} else {
console.log(`Status: ${summary.status}`);
console.log(`Bytes read: ${summary.bytes_read}`);
}
return options.strict === true ? strictSummaryExitCode(summary.status) : 0;
}
export function commandLogGuard(ctx: CommandContext): number {
const sub = ctx.args[2];
if (sub === "start") return commandLogGuardStart(ctx);
if (sub === "stop") return commandLogGuardStop(ctx);
usage();
}
function commandLogGuardStart(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(3));
if (positional.length > 0) usage();
const now = new Date();
const startedAtLocal = localIsoWithOffset(now);
const runId = optionString(options, "run-id") ?? `log-guard-${timestampSlug(startedAtLocal)}`;
const caseId = optionString(options, "case") ?? "";
const caseItem = caseId ? findStructuredItem(ctx.root, "cases", caseId) : undefined;
const session: LogGuardSession = {
source: "log-guard-session",
run_id: runId,
started_at: now.toISOString(),
started_at_local: startedAtLocal,
backend_log: latestOrExplicitBackendLog(ctx.root, options),
case_id: caseId,
case_skill: caseItem?.skill ?? "",
};
const path = resolve(sessionPath(options, runId));
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`, "utf8");
const result = {
...session,
path,
stop_command: `bin/lbs log guard stop --run-id ${runId} --output-dir ${sessionDir(options)}`,
};
if (options.json === true) console.log(JSON.stringify(result, null, 2));
else {
console.log(`# Log Guard Session`);
console.log(`Run: ${runId}`);
console.log(`Started: ${startedAtLocal}`);
console.log(`Session: ${path}`);
if (session.backend_log) console.log(`Backend log: ${session.backend_log}`);
if (session.case_id) console.log(`Case: ${session.case_id}`);
console.log(`Stop: ${result.stop_command}`);
}
return 0;
}
function commandLogGuardStop(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(3));
if (positional.length > 0) usage();
const session = readSession(options);
if (!session) {
console.error("ERROR: log guard session not found; pass --run-id with --output-dir or --session.");
return 1;
}
const now = new Date();
const scanOptions: Record<string, string | boolean> = {
...options,
since: optionString(options, "since") ?? session.started_at_local,
until: optionString(options, "until") ?? localIsoWithOffset(now),
};
if (session.backend_log && typeof scanOptions["backend-log"] !== "string") {
scanOptions["backend-log"] = session.backend_log;
}
const caseId = optionString(options, "case") ?? session.case_id;
const context = caseId
? logPatternContextFromStructuredItem(findStructuredItem(ctx.root, "cases", caseId))
: patternContextFromOptions(ctx.root, options);
const result = scanLogSources(ctx.root, scanOptions, context);
const output = optionString(options, "output") ?? join(sessionDir(options), `${session.run_id}.md`);
const content = options.json === true
? `${JSON.stringify({ session, result }, null, 2)}\n`
: renderLogGuardMarkdown("Log Guard Report", result, [
`run_id: ${session.run_id}`,
`started: ${session.started_at_local}`,
`finished: ${scanOptions.until}`,
caseId ? `case: ${caseId}` : "case: none",
]);
writeOrPrint(content, options.json === true ? optionString(options, "output") : output);
return options["no-strict"] === true ? 0 : strictLogGuardExitCode(result);
}
+128
View File
@@ -0,0 +1,128 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { CommandContext } from "../types.ts";
import { fail, optionString, parseOptions, usage } from "../cli.ts";
import { loadFixtureItems } from "../fixtures.ts";
import { boolValue, getSkill, globMarkdownRefs, listValue, loadSkills, loadStructuredItems, scalar, skillsRoot } from "../fs.ts";
export function commandList(ctx: CommandContext): number {
for (const skill of loadSkills(ctx.root)) {
console.log(`${skill.directory}\t${skill.name}\t${skill.description}`);
}
return 0;
}
function buildIndexData(root: string): Record<string, unknown> {
const caseSummary = (item: ReturnType<typeof loadStructuredItems>[number]) => ({
id: scalar(item.fields, "id"),
title: scalar(item.fields, "title"),
mode: scalar(item.fields, "mode"),
area: scalar(item.fields, "area"),
type: scalar(item.fields, "type"),
priority: scalar(item.fields, "priority"),
risk: scalar(item.fields, "risk"),
ci_eligible: boolValue(item.fields, "ci_eligible") ?? false,
tags: listValue(item.fields, "tags"),
automation: scalar(item.fields, "automation"),
setup_automation: listValue(item.fields, "setup_automation"),
setup_provides_env: listValue(item.fields, "setup_provides_env"),
evidence_required: listValue(item.fields, "evidence_required"),
});
const troubleshootingSummary = (item: ReturnType<typeof loadStructuredItems>[number]) => ({
id: scalar(item.fields, "id"),
title: scalar(item.fields, "title"),
category: scalar(item.fields, "category") || "product",
related_cases: listValue(item.fields, "related_cases"),
});
const suiteSummary = (item: ReturnType<typeof loadStructuredItems>[number]) => ({
id: scalar(item.fields, "id"),
title: scalar(item.fields, "title"),
description: scalar(item.fields, "description"),
type: scalar(item.fields, "type"),
priority: scalar(item.fields, "priority"),
tags: listValue(item.fields, "tags"),
cases: listValue(item.fields, "cases"),
});
return {
generated_by: "lbs",
skills: loadSkills(root).map((skill) => ({
directory: skill.directory,
name: skill.name,
description: skill.description,
references: globMarkdownRefs(skill.path),
cases: loadStructuredItems(root, "cases", skill.directory).map((item) => scalar(item.fields, "id")),
case_summaries: loadStructuredItems(root, "cases", skill.directory).map(caseSummary),
suites: loadStructuredItems(root, "suites", skill.directory).map((item) => scalar(item.fields, "id")),
suite_summaries: loadStructuredItems(root, "suites", skill.directory).map(suiteSummary),
fixtures: loadFixtureItems(root, skill.directory).items.map((item) => ({
id: item.id,
title: item.title,
kind: item.kind,
path: item.path,
related_cases: item.related_cases,
})),
troubleshooting: loadStructuredItems(root, "troubleshooting", skill.directory).map((item) => scalar(item.fields, "id")),
troubleshooting_summaries: loadStructuredItems(root, "troubleshooting", skill.directory).map(troubleshootingSummary),
})),
};
}
export function commandIndex(ctx: CommandContext): number {
const { options } = parseOptions(ctx.args.slice(1));
const data = buildIndexData(ctx.root);
const out = join(ctx.root, "skills.index.json");
const content = `${JSON.stringify(data, null, 2)}\n`;
if (options.check === true) {
if (!existsSync(out)) {
console.error(`ERROR: missing index: ${out}`);
return 1;
}
if (readFileSync(out, "utf8") !== content) {
console.error(`ERROR: index is stale: ${out}`);
return 1;
}
console.log(`OK ${out}`);
return 0;
}
writeFileSync(out, content, "utf8");
console.log(out);
return 0;
}
export function commandNewSkill(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(1));
const name = positional[0];
if (!name) usage();
const skillDir = join(skillsRoot(ctx.root), name);
const skillMd = join(skillDir, "SKILL.md");
if (existsSync(skillMd)) fail(`skill already exists: ${skillDir}`);
mkdirSync(skillDir, { recursive: true });
const description = optionString(options, "description") ?? `Use when working with ${name}.`;
const text =
`---\nname: ${name}\ndescription: ${description}\n---\n\n` +
`# ${name}\n\n` +
"Add concise routing and workflow instructions here.\n";
writeFileSync(skillMd, text, "utf8");
console.log(skillMd);
return 0;
}
export function commandNewRef(ctx: CommandContext): number {
const skill = ctx.args[1];
const rawName = ctx.args[2];
if (!skill || !rawName) usage();
const skillDir = getSkill(ctx.root, skill).path;
const refsDir = join(skillDir, "references");
mkdirSync(refsDir, { recursive: true });
const name = rawName.endsWith(".md") ? rawName : `${rawName}.md`;
const refPath = join(refsDir, name);
if (existsSync(refPath)) fail(`reference already exists: ${refPath}`);
const title = name.replace(/\.md$/, "").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
writeFileSync(refPath, `# ${title}\n\nAdd concise reusable instructions here.\n`, "utf8");
console.log(refPath);
return 0;
}
+704
View File
@@ -0,0 +1,704 @@
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { execPath } from "node:process";
import type { CommandContext, StructuredItem } from "../types.ts";
import { fail, optionString, parseOptions, usage } from "../cli.ts";
import { findStructuredItem, getSkill, listValue, loadStructuredItems, scalar, yamlList, yamlQuote } from "../fs.ts";
import { caseAutomationReadiness, caseEnvReadiness, caseFixtureReadiness, caseManualReadiness, runtimeEnv } from "../readiness.ts";
import { lbsScriptPath, setupAutomationEntries } from "../setup-automation.ts";
function suitePath(root: string, skillName: string, id: string): string {
const skill = getSkill(root, skillName);
const dir = join(skill.path, "suites");
mkdirSync(dir, { recursive: true });
return join(dir, `${id}.yaml`);
}
function caseItemById(root: string, id: string): StructuredItem {
return findStructuredItem(root, "cases", id);
}
function suiteCaseSummary(root: string, id: string): Record<string, unknown> {
const item = caseItemById(root, id);
const env = runtimeEnv(root);
const caseId = scalar(item.fields, "id");
return {
skill: item.skill,
id: caseId,
title: scalar(item.fields, "title"),
mode: scalar(item.fields, "mode"),
area: scalar(item.fields, "area"),
type: scalar(item.fields, "type"),
priority: scalar(item.fields, "priority"),
risk: scalar(item.fields, "risk"),
tags: listValue(item.fields, "tags"),
preconditions: listValue(item.fields, "preconditions"),
setup: listValue(item.fields, "setup"),
setup_automation: setupAutomationEntries(item),
setup_provides_env: listValue(item.fields, "setup_provides_env"),
automation: scalar(item.fields, "automation"),
evidence_required: listValue(item.fields, "evidence_required"),
env_readiness: caseEnvReadiness(item, env),
automation_readiness: caseAutomationReadiness(item, env),
fixture_readiness: caseFixtureReadiness(root, caseId),
manual_readiness: caseManualReadiness(item),
};
}
function suiteSummary(item: StructuredItem): Record<string, string | string[]> {
return {
skill: item.skill,
id: scalar(item.fields, "id"),
title: scalar(item.fields, "title"),
description: scalar(item.fields, "description"),
type: scalar(item.fields, "type"),
priority: scalar(item.fields, "priority"),
tags: listValue(item.fields, "tags"),
cases: listValue(item.fields, "cases"),
};
}
function findSuite(root: string, args: string[]): StructuredItem {
if (args.length < 1 || args.length > 2) usage();
return args.length === 1
? findStructuredItem(root, "suites", args[0])
: findStructuredItem(root, "suites", args[0], args[1]);
}
function pad2(value: number): string {
return String(value).padStart(2, "0");
}
function pad3(value: number): string {
return String(value).padStart(3, "0");
}
function localIsoWithOffset(date: Date): string {
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absoluteOffset = Math.abs(offsetMinutes);
return [
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
`T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad3(date.getMilliseconds())}`,
`${sign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`,
].join("");
}
function timestampSlug(localIso: string): string {
return localIso
.replace(/T/, "-")
.replace(/[.:+]/g, "-")
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function writeOrPrint(content: string, output: string | undefined): void {
if (!output) {
console.log(content.trimEnd());
return;
}
const path = resolve(output);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, "utf8");
console.log(path);
}
function suiteCases(root: string, item: StructuredItem): Record<string, unknown>[] {
return listValue(item.fields, "cases").map((id) => suiteCaseSummary(root, id));
}
function statusOf(caseItem: Record<string, unknown>, key: string): string {
const value = caseItem[key] as Record<string, unknown> | undefined;
return typeof value?.status === "string" ? value.status : "not_required";
}
function readinessSummary(cases: Array<Record<string, unknown>>): Record<string, unknown> {
const missingEnv = cases.filter((item) => statusOf(item, "env_readiness") === "missing").map((item) => item.id);
const missingAutomation = cases.filter((item) => statusOf(item, "automation_readiness") === "missing").map((item) => item.id);
const missingFixture = cases.filter((item) => statusOf(item, "fixture_readiness") === "missing").map((item) => item.id);
const manualCheck = cases.filter((item) => statusOf(item, "manual_readiness") === "manual_check").map((item) => item.id);
const missingCount = missingEnv.length + missingAutomation.length + missingFixture.length;
return {
status: missingCount > 0 ? "missing" : manualCheck.length > 0 ? "manual_check" : "ready",
missing_env_cases: missingEnv,
missing_automation_env_cases: missingAutomation,
missing_fixture_cases: missingFixture,
manual_check_cases: manualCheck,
};
}
function hasProbeCases(cases: Array<Record<string, unknown>>): boolean {
return cases.some((caseItem) => caseItem.mode === "probe");
}
function suiteReportGuidance(cases: Array<Record<string, unknown>>): string {
return hasProbeCases(cases)
? "Run each case according to its mode; probe cases may collect non-UI evidence, while agent-browser cases still require browser/UI execution."
: "Run each case through browser/UI first; use test report with the evidence directory and backend log window after execution.";
}
function suiteResultPolicy(cases: Array<Record<string, unknown>>): string[] {
if (hasProbeCases(cases)) {
return [
"A suite is not pass unless every case has a result and required evidence for the same run window.",
"agent-browser cases require UI/browser results; probe cases are judged by their declared checks and required evidence.",
"blocked and env_issue are not product pass; report them separately.",
];
}
return [
"A suite is not pass unless every case has a UI/browser result and required evidence for the same run window.",
"blocked and env_issue are not product pass; report them separately.",
];
}
function suiteEvidencePolicy(cases: Array<Record<string, unknown>>): string[] {
if (hasProbeCases(cases)) {
return [
"Run each case according to its mode. Agent-browser cases use browser/UI; probe cases use their declared probe steps or automation.",
"Use each case evidence_dir for screenshots, console.log, network.log, automation-result.json, result.json, and any probe artifacts.",
"After case execution and report review, run each result_command_template with the final status and collected evidence.",
"After per-case result.json files exist, run the suite report command to aggregate them.",
"blocked and env_issue are not product pass; they must be reported separately from pass.",
];
}
return [
"Run each case through browser/UI. API/curl/log diagnostics cannot make a UI case pass by themselves.",
"Use each case evidence_dir for screenshots, console.log, network.log, automation-result.json, and final result.json.",
"After case execution and report review, run each result_command_template with the final status and collected evidence.",
"After per-case result.json files exist, run the suite report command to aggregate them.",
"blocked and env_issue are not product pass; they must be reported separately from pass.",
];
}
function buildSuitePlan(root: string, item: StructuredItem): Record<string, unknown> {
const suite = suiteSummary(item);
const cases = suiteCases(root, item);
return {
...suite,
cases,
readiness: readinessSummary(cases),
commands: cases.map((caseItem) => ({
id: caseItem.id,
plan: `bin/lbs test plan ${caseItem.id}`,
start: `bin/lbs test start ${caseItem.id}`,
automation: caseItem.automation ? `bin/lbs test run ${caseItem.id} --dry-run` : "",
})),
report_guidance: suiteReportGuidance(cases),
};
}
export function commandSuiteNew(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(2));
const id = positional[0];
const title = optionString(options, "title");
if (!id || !title) usage();
const skill = optionString(options, "skill") ?? "langbot-testing";
const path = suitePath(ctx.root, skill, id);
if (existsSync(path)) fail(`suite already exists: ${path}`);
const text =
`id: ${id}\n` +
`title: ${yamlQuote(title)}\n` +
`description: ${yamlQuote(optionString(options, "description") ?? "Describe when to run this suite.")}\n` +
`type: ${optionString(options, "type") ?? "smoke"}\n` +
`priority: ${optionString(options, "priority") ?? "p2"}\n` +
"tags:\n" +
yamlList([optionString(options, "type") ?? "smoke"]) +
"\ncases:\n" +
yamlList(["webui-login-state"]) +
"\n";
writeFileSync(path, text, "utf8");
console.log(path);
return 0;
}
export function commandSuiteList(ctx: CommandContext): number {
const { positional, options } = parseOptions(ctx.args.slice(2));
const skill = positional[0];
const rows = loadStructuredItems(ctx.root, "suites", skill)
.map(suiteSummary)
.filter((row) => !optionString(options, "type") || row.type === optionString(options, "type"))
.filter((row) => !optionString(options, "priority") || row.priority === optionString(options, "priority"));
if (options.json === true) {
console.log(JSON.stringify(rows, null, 2));
return 0;
}
for (const row of rows) {
console.log([
row.skill,
row.id,
row.type,
row.priority,
Array.isArray(row.cases) ? row.cases.length : 0,
row.title,
].join("\t"));
}
return 0;
}
export function commandSuiteShow(ctx: CommandContext): number {
const item = findSuite(ctx.root, ctx.args.slice(2));
console.log(item.raw.trimEnd());
return 0;
}
export function commandSuitePlan(ctx: CommandContext): number {
const { positional: args, options } = parseOptions(ctx.args.slice(2));
const item = findSuite(ctx.root, args);
const plan = buildSuitePlan(ctx.root, item);
const suite = suiteSummary(item);
const cases = suiteCases(ctx.root, item);
if (options.json === true) {
console.log(JSON.stringify(plan, null, 2));
return 0;
}
console.log(`# Suite Plan: ${suite.id}`);
console.log("");
console.log(`Title: ${suite.title}`);
console.log(`Type: ${suite.type}`);
console.log(`Priority: ${suite.priority}`);
console.log(`Description: ${suite.description}`);
console.log("");
const readiness = readinessSummary(cases);
console.log("## Readiness");
console.log(`Status: ${readiness.status}`);
for (const [key, value] of Object.entries(readiness)) {
if (key === "status" || !Array.isArray(value) || value.length === 0) continue;
console.log(`- ${key}: ${value.join(", ")}`);
}
console.log("");
console.log("## Cases");
for (const [index, caseItem] of cases.entries()) {
console.log(`${index + 1}. ${caseItem.id} [${caseItem.priority}/${caseItem.risk}] ${caseItem.title}`);
console.log(` - plan: bin/lbs test plan ${caseItem.id}`);
console.log(` - start: bin/lbs test start ${caseItem.id}`);
if (caseItem.automation) console.log(` - automation dry-run: bin/lbs test run ${caseItem.id} --dry-run`);
console.log(` - evidence: ${Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required.join(", ") : ""}`);
const envReadiness = caseItem.env_readiness as Record<string, unknown>;
const automationReadiness = caseItem.automation_readiness as Record<string, unknown>;
const fixtureReadiness = caseItem.fixture_readiness as Record<string, unknown>;
const manualReadiness = caseItem.manual_readiness as Record<string, unknown>;
const missing: string[] = [];
if (Array.isArray(envReadiness.missing) && envReadiness.missing.length > 0) missing.push(`env=${envReadiness.missing.join(",")}`);
if (Array.isArray(automationReadiness.missing) && automationReadiness.missing.length > 0) missing.push(`automation_env=${automationReadiness.missing.join(",")}`);
if (Array.isArray(fixtureReadiness.missing) && fixtureReadiness.missing.length > 0) missing.push(`fixture=${fixtureReadiness.missing.join(",")}`);
const manualLabel = manualReadiness.status === "manual_check" ? " manual_check" : "";
console.log(` - readiness: ${missing.length === 0 ? `ready${manualLabel}` : `missing ${missing.join(" ")}`}`);
const preconditions = caseItem.preconditions;
if (Array.isArray(preconditions) && preconditions.length > 0) console.log(` - preconditions: ${preconditions.length}`);
const setupAutomation = caseItem.setup_automation;
if (Array.isArray(setupAutomation) && setupAutomation.length > 0) console.log(` - setup automation: ${setupAutomation.length}`);
}
console.log("");
console.log("## Result Policy");
for (const policy of suiteResultPolicy(cases)) console.log(`- ${policy}`);
return 0;
}
function suiteStartPath(root: string, path: string): string {
return resolve(root, path);
}
function ensureDirectory(root: string, path: string, label: string): void {
const resolvedPath = suiteStartPath(root, path);
if (existsSync(resolvedPath) && !statSync(resolvedPath).isDirectory()) {
fail(`${label} exists and is not a directory: ${resolvedPath}`);
}
mkdirSync(resolvedPath, { recursive: true });
}
function buildSuiteStart(
root: string,
item: StructuredItem,
args: string[],
options: Record<string, string | boolean>,
): Record<string, unknown> {
const now = new Date();
const startedAtLocal = localIsoWithOffset(now);
const suite = suiteSummary(item);
const suiteId = String(suite.id);
const runId = optionString(options, "run-id") ?? `${timestampSlug(startedAtLocal)}-${suiteId}`;
const evidenceRoot = optionString(options, "evidence-dir") ?? join("reports", "evidence", runId);
const reportPath = join("reports", `${runId}.md`);
const manifestPath = join(evidenceRoot, "suite-start.json");
const handoffPath = join(evidenceRoot, "suite-start.md");
const cases = suiteCases(root, item).map((caseItem) => {
const caseId = String(caseItem.id);
const caseRunId = `${runId}-${caseId}`;
const evidenceDir = join(evidenceRoot, caseId);
const consoleLog = join(evidenceDir, "console.log");
const caseReportPath = join("reports", `${caseRunId}.md`);
return {
...caseItem,
run_id: caseRunId,
evidence_dir: evidenceDir,
plan_command: `bin/lbs test plan ${caseId}`,
start_command: `bin/lbs test start ${caseId}`,
automation_command: caseItem.automation
? `bin/lbs test run ${caseId} --run-id ${caseRunId} --output ${evidenceDir}`
: "",
report_command: caseItem.automation
? `bin/lbs test report ${caseId} --since "${startedAtLocal}" --console-log ${consoleLog} --evidence-dir ${evidenceDir} --output ${caseReportPath}`
: `bin/lbs test report ${caseId} --since "${startedAtLocal}" --evidence-dir ${evidenceDir} --output ${caseReportPath}`,
result_command_template: `bin/lbs test result ${caseId} --result <status> --reason "<short reason>" --evidence-dir ${evidenceDir} --run-id ${caseRunId} --started-at "${startedAtLocal}" --evidence ${Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required.join(",") : ""}`,
};
});
const locator = args.join(" ");
return {
run_id: runId,
started_at: now.toISOString(),
started_at_local: startedAtLocal,
suite,
evidence_root: evidenceRoot,
manifest_path: manifestPath,
handoff_path: handoffPath,
cases,
suite_report_path: reportPath,
plan_command: `bin/lbs suite plan ${locator}`,
report_command: `bin/lbs suite report ${locator} --run-id ${runId} --evidence-dir ${evidenceRoot} --output ${reportPath}`,
evidence_policy: suiteEvidencePolicy(cases),
};
}
function writeSuiteStartArtifacts(root: string, start: Record<string, unknown>, rendered: string): void {
const evidenceRoot = String(start.evidence_root || "");
if (!evidenceRoot) return;
ensureDirectory(root, evidenceRoot, "suite evidence directory");
for (const caseItem of start.cases as Array<Record<string, unknown>>) {
const evidenceDir = String(caseItem.evidence_dir || "");
if (evidenceDir) ensureDirectory(root, evidenceDir, "case evidence directory");
}
const manifestPath = String(start.manifest_path || "");
if (manifestPath) {
const path = suiteStartPath(root, manifestPath);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(start, null, 2)}\n`, "utf8");
}
const handoffPath = String(start.handoff_path || "");
if (handoffPath) {
const path = suiteStartPath(root, handoffPath);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, rendered, "utf8");
}
}
function renderSuiteStart(start: Record<string, unknown>): string {
const suite = start.suite as Record<string, unknown>;
const cases = start.cases as Array<Record<string, unknown>>;
const lines: string[] = [];
lines.push(`# Suite Start: ${suite.id}`);
lines.push("");
lines.push(`Run: ${start.run_id}`);
lines.push(`Started: ${start.started_at_local}`);
lines.push(`Title: ${suite.title}`);
lines.push(`Evidence root: ${start.evidence_root}`);
lines.push("");
lines.push("## Commands");
lines.push(`- plan: ${start.plan_command}`);
lines.push(`- report: ${start.report_command}`);
lines.push("");
lines.push("## Cases");
for (const [index, caseItem] of cases.entries()) {
lines.push(`${index + 1}. ${caseItem.id} [${caseItem.priority}/${caseItem.risk}] ${caseItem.title}`);
lines.push(` - evidence_dir: ${caseItem.evidence_dir}`);
lines.push(` - plan: ${caseItem.plan_command}`);
if (caseItem.automation_command) lines.push(` - automation: ${caseItem.automation_command}`);
else lines.push(` - manual start: ${caseItem.start_command}`);
lines.push(` - report: ${caseItem.report_command}`);
lines.push(` - result template: ${caseItem.result_command_template}`);
}
lines.push("");
lines.push("## Evidence Policy");
for (const item of start.evidence_policy as string[]) lines.push(`- ${item}`);
return `${lines.join("\n").trimEnd()}\n`;
}
export function commandSuiteStart(ctx: CommandContext): number {
const { positional: args, options } = parseOptions(ctx.args.slice(2));
const item = findSuite(ctx.root, args);
const start = buildSuiteStart(ctx.root, item, args, options);
const rendered = renderSuiteStart(start);
writeSuiteStartArtifacts(ctx.root, start, rendered);
const content = options.json === true ? `${JSON.stringify(start, null, 2)}\n` : rendered;
writeOrPrint(content, optionString(options, "output"));
return 0;
}
function suiteRunCaseArgs(root: string, caseItem: Record<string, unknown>, headed: boolean): string[] {
const args = [
lbsScriptPath(),
"--root",
root,
"test",
"run",
String(caseItem.id),
"--run-id",
String(caseItem.run_id),
"--output",
String(caseItem.evidence_dir),
];
if (headed) args.push("--headed");
return args;
}
function suiteReportExitCode(status: string): number {
if (status === "pass") return 0;
if (status === "blocked" || status === "env_issue" || status === "flaky") return 2;
return 1;
}
function outputTail(value: string | Buffer | null | undefined): string {
return String(value ?? "").trim().slice(-4000);
}
function executionProblemStatus(executions: Array<Record<string, unknown>>): string {
const statuses = executions.map((item) => String(item.status));
if (statuses.includes("nonzero")) return "fail";
if (statuses.includes("skipped")) return "incomplete";
return "";
}
function missingReadinessReason(caseItem: Record<string, unknown>): string {
const labels: Array<[string, string]> = [
["env", "env_readiness"],
["automation_env", "automation_readiness"],
["fixture", "fixture_readiness"],
];
const missing = labels.flatMap(([label, key]) => {
const value = caseItem[key] as Record<string, unknown> | undefined;
if (value?.status !== "missing") return [];
const names = Array.isArray(value.missing) ? value.missing.filter((item): item is string => typeof item === "string") : [];
return [`${label}=${names.length > 0 ? names.join(",") : "missing"}`];
});
return missing.length > 0
? `case readiness missing (${missing.join(" ")}); rerun with --include-not-ready after fixing or intentionally accepting readiness gaps`
: "";
}
export function commandSuiteRun(ctx: CommandContext): number {
const { positional: args, options } = parseOptions(ctx.args.slice(2));
const item = findSuite(ctx.root, args);
const start = buildSuiteStart(ctx.root, item, args, options);
const renderedStart = renderSuiteStart(start);
const dryRun = options["dry-run"] === true;
if (!dryRun) writeSuiteStartArtifacts(ctx.root, start, renderedStart);
const executions = [];
for (const caseItem of start.cases as Array<Record<string, unknown>>) {
if (statusOf(caseItem, "manual_readiness") === "manual_check" && options["include-manual-check"] !== true) {
executions.push({ id: caseItem.id, status: "skipped", reason: "case requires manual_check; rerun with --include-manual-check after confirming preconditions" });
continue;
}
const missingReadiness = missingReadinessReason(caseItem);
if (missingReadiness && options["include-not-ready"] !== true) {
executions.push({ id: caseItem.id, status: "skipped", reason: missingReadiness });
continue;
}
if (!caseItem.automation) {
executions.push({ id: caseItem.id, status: "skipped", reason: "case has no automation" });
continue;
}
const runArgs = suiteRunCaseArgs(ctx.root, caseItem, options.headed === true);
if (dryRun) {
executions.push({ id: caseItem.id, status: "planned", reason: "dry-run; case automation not executed", command: [execPath, ...runArgs].join(" ") });
continue;
}
if (options.json !== true) console.log(`Suite case: ${caseItem.id}`);
const result = spawnSync(execPath, runArgs, {
cwd: ctx.root,
encoding: "utf8",
stdio: options.json === true ? "pipe" : "inherit",
});
const status = result.error ? 1 : result.status ?? 1;
executions.push({
id: caseItem.id,
status: status === 0 ? "ok" : "nonzero",
exit_status: status,
reason: result.error?.message || "",
stdout: outputTail(result.stdout),
stderr: outputTail(result.stderr),
});
}
const report = buildSuiteReport(ctx.root, item, {
...options,
"run-id": String(start.run_id),
"evidence-dir": String(start.evidence_root),
}, executions);
const payload = {
run_id: start.run_id,
evidence_root: start.evidence_root,
executions,
report,
};
const content = options.json === true
? `${JSON.stringify(payload, null, 2)}\n`
: renderSuiteReport(report);
writeOrPrint(content, optionString(options, "output") ?? (options.json === true || dryRun ? undefined : String(start.suite_report_path || "")));
return dryRun ? 0 : suiteReportExitCode(String(report.status));
}
function arrayField(data: Record<string, unknown>, key: string): string[] {
const value = data[key];
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function readCaseResult(evidenceDir: string, caseId: string, expectedRunId: string, requiredEvidence: string[]): Record<string, unknown> {
const resultPath = join(evidenceDir, "result.json");
if (!existsSync(resultPath)) {
return { status: "missing", path: resultPath, reason: "result.json not found" };
}
try {
const parsed = JSON.parse(readFileSync(resultPath, "utf8")) as Record<string, unknown>;
if (parsed.case_id !== caseId) {
return {
status: "invalid",
path: resultPath,
reason: `result.json case_id mismatch: expected ${caseId}, got ${String(parsed.case_id ?? "missing")}`,
};
}
if (expectedRunId && parsed.run_id !== expectedRunId) {
return {
status: "invalid",
path: resultPath,
reason: `result.json run_id mismatch: expected ${expectedRunId}, got ${String(parsed.run_id ?? "missing")}`,
};
}
const collected = arrayField(parsed, "evidence_collected");
const missing = requiredEvidence.filter((item) => !collected.includes(item));
return {
status: typeof parsed.status === "string" ? parsed.status : "invalid",
path: resultPath,
reason: typeof parsed.reason === "string" ? parsed.reason : "",
started_at_local: typeof parsed.started_at_local === "string" ? parsed.started_at_local : "",
finished_at_local: typeof parsed.finished_at_local === "string" ? parsed.finished_at_local : "",
url: typeof parsed.url === "string" ? parsed.url : "",
evidence_collected: collected,
evidence_required: requiredEvidence,
evidence_missing: missing,
evidence_status: missing.length === 0 ? "complete" : "incomplete",
};
} catch (error) {
return { status: "invalid", path: resultPath, reason: String(error) };
}
}
function suiteStatus(caseResults: Array<Record<string, unknown>>): string {
const statuses = caseResults.map((item) => String(item.status));
if (statuses.length === 0) return "not_run";
if (statuses.includes("fail") || statuses.includes("invalid")) return "fail";
if (statuses.includes("missing")) return "incomplete";
if (caseResults.some((item) => item.status === "pass" && item.evidence_status !== "complete")) return "incomplete";
if (statuses.every((status) => status === "pass")) return "pass";
if (statuses.includes("blocked")) return "blocked";
if (statuses.includes("env_issue")) return "env_issue";
if (statuses.includes("flaky")) return "flaky";
return "unknown";
}
function buildSuiteReport(
root: string,
item: StructuredItem,
options: Record<string, string | boolean>,
executions: Array<Record<string, unknown>> = [],
): Record<string, unknown> {
const suite = suiteSummary(item);
const runId = optionString(options, "run-id") ?? "";
const evidenceRoot = optionString(options, "evidence-dir") ?? (runId ? join("reports", "evidence", runId) : "");
const cases = suiteCases(root, item).map((caseItem) => {
const caseId = String(caseItem.id);
const expectedCaseRunId = runId ? `${runId}-${caseId}` : "";
const evidenceDir = evidenceRoot ? join(evidenceRoot, caseId) : "";
const requiredEvidence = Array.isArray(caseItem.evidence_required) ? caseItem.evidence_required : [];
const result = evidenceDir
? readCaseResult(evidenceDir, caseId, expectedCaseRunId, requiredEvidence)
: { status: "missing", path: "", reason: "Set --evidence-dir or --run-id to locate case result.json files" };
return {
...caseItem,
evidence_dir: evidenceDir,
result,
};
});
const counts: Record<string, number> = {};
for (const item of cases) {
const status = String((item.result as Record<string, unknown>).status);
counts[status] = (counts[status] ?? 0) + 1;
}
const resultStatus = suiteStatus(cases.map((item) => item.result as Record<string, unknown>));
const executionStatus = executionProblemStatus(executions);
return {
generated_at: new Date().toISOString(),
run_id: runId,
suite,
evidence_root: evidenceRoot,
status: executionStatus || resultStatus,
counts,
cases,
execution_status: executionStatus || "ok",
decision_policy: [
"pass requires every case result to be pass.",
"suite run pass also requires every attempted execution to finish ok.",
"blocked and env_issue are not product pass.",
"pass results missing required evidence keep the suite incomplete.",
"result.json must match the expected case_id and suite case run_id.",
"missing or invalid result.json means the suite is incomplete or failed to collect evidence.",
],
};
}
function renderSuiteReport(report: Record<string, unknown>): string {
const suite = report.suite as Record<string, unknown>;
const cases = report.cases as Array<Record<string, unknown>>;
const counts = report.counts as Record<string, number>;
const lines: string[] = [];
lines.push(`# Suite Report: ${suite.id}`);
lines.push("");
lines.push(`Generated: ${report.generated_at}`);
if (report.run_id) lines.push(`Run: ${report.run_id}`);
lines.push(`Title: ${suite.title}`);
lines.push(`Status: ${report.status}`);
lines.push(`Evidence root: ${report.evidence_root || "not provided"}`);
lines.push("");
lines.push("## Counts");
for (const key of Object.keys(counts).sort()) lines.push(`- ${key}: ${counts[key]}`);
if (Object.keys(counts).length === 0) lines.push("- None.");
lines.push("");
lines.push("## Cases");
for (const caseItem of cases) {
const result = caseItem.result as Record<string, unknown>;
lines.push(`- ${caseItem.id}: ${result.status} - ${result.reason || "no reason"}`);
if (Array.isArray(result.evidence_missing) && result.evidence_missing.length > 0) {
lines.push(` evidence_missing: ${result.evidence_missing.join(", ")}`);
}
if (caseItem.evidence_dir) lines.push(` evidence_dir: ${caseItem.evidence_dir}`);
if (result.path) lines.push(` result_json: ${result.path}`);
}
lines.push("");
lines.push("## Decision Policy");
for (const item of report.decision_policy as string[]) lines.push(`- ${item}`);
return `${lines.join("\n").trimEnd()}\n`;
}
export function commandSuiteReport(ctx: CommandContext): number {
const { positional: args, options } = parseOptions(ctx.args.slice(2));
const item = findSuite(ctx.root, args);
const report = buildSuiteReport(ctx.root, item, options);
const content = options.json === true ? `${JSON.stringify(report, null, 2)}\n` : renderSuiteReport(report);
writeOrPrint(content, optionString(options, "output"));
return 0;
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { CommandContext } from "../types.ts";
import { fail, optionString, parseOptions, usage } from "../cli.ts";
import { findStructuredItem, getSkill, loadStructuredItems, scalar, slugify, todayIso, yamlList, yamlQuote } from "../fs.ts";
function troubleshootingYamlPath(root: string, skillName: string, id: string): string {
const skill = getSkill(root, skillName);
const dir = join(skill.path, "troubleshooting");
mkdirSync(dir, { recursive: true });
return join(dir, `${id}.yaml`);
}
function legacyTroubleshootingPath(root: string, skillName: string): string {
const skill = getSkill(root, skillName);
const refsDir = join(skill.path, "references");
mkdirSync(refsDir, { recursive: true });
const path = join(refsDir, "troubleshooting.md");
if (!existsSync(path)) writeFileSync(path, "# Troubleshooting\n\n", "utf8");
return path;
}
export function commandTroubleList(ctx: CommandContext): number {
const skill = ctx.args[2];
const yamlItems = loadStructuredItems(ctx.root, "troubleshooting", skill);
for (const item of yamlItems) {
console.log(`${item.skill}\t${scalar(item.fields, "id")}\t${scalar(item.fields, "title")}`);
}
if (skill && yamlItems.length === 0) {
const legacyPath = legacyTroubleshootingPath(ctx.root, skill);
const text = readFileSync(legacyPath, "utf8");
const headings = Array.from(text.matchAll(/^##\s+(.+)$/gm)).map((match) => match[1]);
for (const heading of headings) console.log(`${skill}\tlegacy\t${heading}`);
}
return 0;
}
export function commandTroubleShow(ctx: CommandContext): number {
const positional = ctx.args.slice(2);
if (positional.length < 1 || positional.length > 2) usage();
const item = positional.length === 1
? findStructuredItem(ctx.root, "troubleshooting", positional[0])
: findStructuredItem(ctx.root, "troubleshooting", positional[0], positional[1]);
console.log(item.raw.trimEnd());
return 0;
}
export function commandTroubleSearch(ctx: CommandContext): number {
const query = ctx.args[2]?.toLowerCase();
if (!query) usage();
const items = loadStructuredItems(ctx.root, "troubleshooting").filter((item) => item.raw.toLowerCase().includes(query));
for (const item of items) {
console.log(`${item.skill}\t${scalar(item.fields, "id")}\t${scalar(item.fields, "title")}`);
}
return 0;
}
export function commandTroubleAdd(ctx: CommandContext): number {
const skill = ctx.args[2];
if (!skill) usage();
const { options } = parseOptions(ctx.args.slice(3));
for (const key of ["title", "symptom", "cause", "fix"]) {
if (!optionString(options, key)) fail(`--${key} is required`);
}
const title = optionString(options, "title") ?? "";
const symptom = optionString(options, "symptom") ?? "";
const id = optionString(options, "id") ?? slugify(title);
const path = troubleshootingYamlPath(ctx.root, skill, id);
if (existsSync(path)) fail(`troubleshooting entry already exists: ${path}`);
const text =
`id: ${id}\n` +
`title: ${yamlQuote(title)}\n` +
`date: ${todayIso()}\n` +
"symptoms:\n" +
yamlList([symptom]) +
"\npatterns:\n" +
yamlList([symptom]) +
"\nlikely_causes:\n" +
yamlList([optionString(options, "cause") ?? ""]) +
"\nfix_steps:\n" +
yamlList([optionString(options, "fix") ?? ""]) +
"\nverification: " +
yamlQuote(optionString(options, "verify") ?? "Add the command, UI signal, or log line that proves the fix worked.") +
"\nrelated_cases:\n" +
yamlList([]) +
"\n";
writeFileSync(path, text, "utf8");
appendFileSync(legacyTroubleshootingPath(ctx.root, skill), `\n## ${id}: ${title}\n\nSee \`../troubleshooting/${id}.yaml\`.\n`, "utf8");
console.log(path);
return 0;
}
+394
View File
@@ -0,0 +1,394 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { stderr } from "node:process";
import type { Skill, StructuredItem } from "../types.ts";
import { loadFixtureItems } from "../fixtures.ts";
import {
caseEvidenceValues,
caseModeValues,
casePriorityValues,
caseRequiredLists,
caseRequiredStrings,
caseRiskValues,
caseTypeValues,
requiredEnvKeys,
suiteRequiredLists,
suiteRequiredStrings,
suiteTypeValues,
troubleRequiredLists,
troubleRequiredStrings,
troubleshootingCategoryValues,
} from "../constants.ts";
import { boolValue, envExamplePath, envPath, listValue, loadSkills, loadStructuredItems, parseEnvFile, scalar } from "../fs.ts";
import { envKeyPattern, isEnvAnyGroup, splitEnvAnyGroup } from "../env-groups.ts";
import { parseSetupAutomationEntry, validateSetupAutomationEntry } from "../setup-automation.ts";
const refRe = /(?:\]\(|`)(references\/[A-Za-z0-9_.\-/]+\.md)(?:\)|`)/g;
function validateStructuredItem(item: StructuredItem, requiredStrings: string[], requiredLists: string[]): string[] {
const errors: string[] = [];
const listKeys = item.path.includes("/cases/") && scalar(item.fields, "mode") === "probe"
? requiredLists.filter((key) => key !== "env")
: requiredLists;
for (const key of requiredStrings) {
if (!scalar(item.fields, key)) errors.push(`${item.path}: missing '${key}'`);
}
for (const key of listKeys) {
if (listValue(item.fields, key).length === 0) errors.push(`${item.path}: missing '${key}' entries`);
}
const id = scalar(item.fields, "id");
if (id && !/^[a-z0-9][a-z0-9_-]*$/.test(id)) {
errors.push(`${item.path}: id must use lowercase letters, digits, dashes, or underscores`);
}
return errors;
}
function validateEnum(item: StructuredItem, key: string, values: string[]): string[] {
const value = scalar(item.fields, key);
if (!value) return [];
return values.includes(value) ? [] : [`${item.path}: '${key}' must be one of ${values.join(", ")}`];
}
function validateListEnum(item: StructuredItem, key: string, values: string[]): string[] {
const allowed = new Set(values);
return listValue(item.fields, key)
.filter((value) => !allowed.has(value))
.map((value) => `${item.path}: '${key}' contains unsupported value '${value}'`);
}
function validateDuplicateListValues(item: StructuredItem, keys: string[]): string[] {
const errors: string[] = [];
for (const key of keys) {
const seen = new Set<string>();
for (const value of listValue(item.fields, key)) {
if (seen.has(value)) errors.push(`${item.path}: '${key}' contains duplicate value '${value}'`);
seen.add(value);
}
}
return errors;
}
function validateEnvKeyList(item: StructuredItem, key: string): string[] {
return listValue(item.fields, key)
.filter((value) => !envKeyPattern.test(value))
.map((value) => `${item.path}: '${key}' contains invalid env key '${value}'`);
}
function validateEnvKeyScalar(item: StructuredItem, key: string): string[] {
const value = scalar(item.fields, key);
if (!value) return [];
return envKeyPattern.test(value)
? []
: [`${item.path}: '${key}' contains invalid env key '${value}'`];
}
function validateJsonScalar(item: StructuredItem, key: string): string[] {
const value = scalar(item.fields, key);
if (!value) return [];
try {
JSON.parse(value);
return [];
} catch (error) {
return [`${item.path}: '${key}' must be valid JSON: ${(error as Error).message}`];
}
}
function validateEnvAnyList(item: StructuredItem, key: string): string[] {
return listValue(item.fields, key)
.filter((value) => !isEnvAnyGroup(value))
.map((value) => `${item.path}: '${key}' contains invalid env any-group '${value}'`);
}
function validateCaseItem(root: string, item: StructuredItem, skillNames: Set<string>, troubleIds: Set<string>, caseIds: Set<string>): string[] {
const errors = [
...validateEnum(item, "mode", caseModeValues),
...validateEnum(item, "type", caseTypeValues),
...validateEnum(item, "priority", casePriorityValues),
...validateEnum(item, "risk", caseRiskValues),
...validateListEnum(item, "evidence_required", caseEvidenceValues),
...validateDuplicateListValues(item, [
"tags",
"skills",
"env",
"env_any",
"automation_env",
"automation_env_any",
"setup_automation",
"setup_provides_env",
"evidence_required",
"troubleshooting",
]),
...validateEnvKeyList(item, "env"),
...validateEnvAnyList(item, "env_any"),
...validateEnvKeyList(item, "automation_env"),
...validateEnvAnyList(item, "automation_env_any"),
...validateEnvKeyList(item, "setup_provides_env"),
...validateEnvKeyScalar(item, "automation_pipeline_url_env"),
...validateEnvKeyScalar(item, "automation_pipeline_name_env"),
...validateJsonScalar(item, "automation_filesystem_checks_json"),
...listValue(item.fields, "setup_automation").flatMap((entry) => (
validateSetupAutomationEntry(root, entry, caseIds).map((error) => `${item.path}: ${error}`)
)),
];
if (boolValue(item.fields, "ci_eligible") === undefined) {
errors.push(`${item.path}: missing or invalid boolean 'ci_eligible'`);
}
for (const skill of listValue(item.fields, "skills")) {
if (!skillNames.has(skill)) errors.push(`${item.path}: references unknown skill '${skill}'`);
}
for (const id of listValue(item.fields, "troubleshooting")) {
if (!troubleIds.has(id)) errors.push(`${item.path}: references unknown troubleshooting '${id}'`);
}
const automation = scalar(item.fields, "automation");
if (!automation && listValue(item.fields, "automation_env").length > 0) {
errors.push(`${item.path}: 'automation_env' requires 'automation'`);
}
if (!automation && listValue(item.fields, "automation_env_any").length > 0) {
errors.push(`${item.path}: 'automation_env_any' requires 'automation'`);
}
if (!automation && (scalar(item.fields, "automation_pipeline_url_env") || scalar(item.fields, "automation_pipeline_name_env"))) {
errors.push(`${item.path}: automation pipeline env aliases require 'automation'`);
}
if (listValue(item.fields, "setup_provides_env").length > 0 && listValue(item.fields, "setup_automation").length === 0) {
errors.push(`${item.path}: 'setup_provides_env' requires 'setup_automation'`);
}
for (const key of ["automation_pipeline_url_env", "automation_pipeline_name_env"]) {
const value = scalar(item.fields, key);
if (!value) continue;
const declared = new Set([
...listValue(item.fields, "env"),
...listValue(item.fields, "env_any").flatMap(splitEnvAnyGroup),
...listValue(item.fields, "automation_env"),
...listValue(item.fields, "automation_env_any").flatMap(splitEnvAnyGroup),
]);
if (!declared.has(value)) {
errors.push(`${item.path}: '${key}' value '${value}' must be listed in env, env_any, automation_env, or automation_env_any`);
}
}
if (automation && !existsSync(join(root, automation))) {
errors.push(`${item.path}: automation script does not exist: ${automation}`);
}
for (const entry of listValue(item.fields, "setup_automation")) {
const spec = parseSetupAutomationEntry(entry);
if (spec.kind === "case" && spec.target === scalar(item.fields, "id")) {
errors.push(`${item.path}: setup_automation cannot reference the same case '${spec.target}'`);
}
}
const timeout = scalar(item.fields, "automation_response_timeout_ms");
if (timeout && (!/^\d+$/.test(timeout) || Number.parseInt(timeout, 10) <= 0)) {
errors.push(`${item.path}: 'automation_response_timeout_ms' must be a positive integer string`);
}
const streamOutput = scalar(item.fields, "automation_stream_output");
if (streamOutput && !["0", "1", "false", "true"].includes(streamOutput)) {
errors.push(`${item.path}: 'automation_stream_output' must be one of 0, 1, false, or true`);
}
const imageBase64Fixture = scalar(item.fields, "automation_image_base64_fixture");
if (imageBase64Fixture && !existsSync(join(root, imageBase64Fixture))) {
errors.push(`${item.path}: automation image fixture does not exist: ${imageBase64Fixture}`);
}
return errors;
}
function validateSetupAutomationCycles(caseItems: StructuredItem[]): string[] {
const byId = new Map(caseItems.map((item) => [scalar(item.fields, "id"), item]));
const visiting = new Set<string>();
const visited = new Set<string>();
const errors: string[] = [];
function visit(id: string, path: string[]): void {
if (visited.has(id)) return;
if (visiting.has(id)) {
const cycle = [...path.slice(path.indexOf(id)), id].join(" -> ");
const item = byId.get(id);
errors.push(`${item?.path ?? id}: setup_automation case cycle detected: ${cycle}`);
return;
}
const item = byId.get(id);
if (!item) return;
visiting.add(id);
for (const entry of listValue(item.fields, "setup_automation")) {
const spec = parseSetupAutomationEntry(entry);
if (spec.kind === "case") visit(spec.target, [...path, spec.target]);
}
visiting.delete(id);
visited.add(id);
}
for (const id of byId.keys()) visit(id, [id]);
return errors;
}
function validateTroubleshootingItem(item: StructuredItem, caseIds: Set<string>): string[] {
const errors = [
...validateEnum(item, "category", troubleshootingCategoryValues),
...validateDuplicateListValues(item, ["symptoms", "patterns", "likely_causes", "fix_steps", "related_cases"]),
];
for (const id of listValue(item.fields, "related_cases")) {
if (!caseIds.has(id)) errors.push(`${item.path}: references unknown case '${id}'`);
}
return errors;
}
function validateFixtures(root: string, caseIds: Set<string>): string[] {
const { items, errors } = loadFixtureItems(root);
const result = [...errors];
const seen = new Map<string, string>();
for (const item of items) {
if (!/^[a-z0-9][a-z0-9_-]*$/.test(item.id)) {
result.push(`${item.manifest_path}: fixture id '${item.id}' must use lowercase letters, digits, dashes, or underscores`);
}
if (seen.has(item.id)) {
result.push(`${item.manifest_path}: duplicate fixture id '${item.id}' also used by ${seen.get(item.id)}`);
} else {
seen.set(item.id, item.manifest_path);
}
if (!item.exists) result.push(`${item.manifest_path}: fixture path does not exist: ${item.path}`);
for (const caseId of item.related_cases) {
if (!caseIds.has(caseId)) result.push(`${item.manifest_path}: fixture '${item.id}' references unknown case '${caseId}'`);
}
}
return result;
}
function validateSuiteItem(item: StructuredItem, caseIds: Set<string>): string[] {
const errors = [
...validateEnum(item, "type", suiteTypeValues),
...validateEnum(item, "priority", casePriorityValues),
...validateDuplicateListValues(item, ["tags", "cases"]),
];
for (const id of listValue(item.fields, "cases")) {
if (!caseIds.has(id)) errors.push(`${item.path}: references unknown case '${id}'`);
}
return errors;
}
function validateDuplicateIds(items: StructuredItem[], label: string): string[] {
const errors: string[] = [];
const seen = new Map<string, string>();
for (const item of items) {
const id = scalar(item.fields, "id");
if (!id) continue;
const key = `${item.skill}:${id}`;
if (seen.has(key)) errors.push(`${item.path}: duplicate ${label} id '${id}' also used by ${seen.get(key)}`);
else seen.set(key, item.path);
}
return errors;
}
function validateGlobalDuplicateIds(items: StructuredItem[], label: string): string[] {
const errors: string[] = [];
const seen = new Map<string, string>();
for (const item of items) {
const id = scalar(item.fields, "id");
if (!id) continue;
if (seen.has(id)) errors.push(`${item.path}: duplicate global ${label} id '${id}' also used by ${seen.get(id)}`);
else seen.set(id, item.path);
}
return errors;
}
function validateEnv(root: string): string[] {
const path = envPath(root);
const examplePath = envExamplePath(root);
const errors: string[] = [];
if (!existsSync(path)) return [`${path}: missing shared env file`];
const env = parseEnvFile(path);
for (const key of requiredEnvKeys) {
if (!(key in env)) errors.push(`${path}: missing ${key}`);
}
if (!existsSync(examplePath)) {
errors.push(`${examplePath}: missing env template`);
} else {
const example = parseEnvFile(examplePath);
for (const key of requiredEnvKeys) {
if (!(key in example)) errors.push(`${examplePath}: missing template key ${key}`);
}
}
return errors;
}
function validateSchemas(root: string): string[] {
const errors: string[] = [];
for (const name of ["case.schema.json", "suite.schema.json", "troubleshooting.schema.json", "skill-index.schema.json"]) {
const path = join(root, "schemas", name);
if (!existsSync(path)) {
errors.push(`${path}: missing schema`);
continue;
}
try {
JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
errors.push(`${path}: invalid JSON schema (${String(error)})`);
}
}
return errors;
}
function validateSkill(skill: Skill): string[] {
const errors: string[] = [];
if (!skill.name) errors.push(`${skill.path}: missing frontmatter name`);
if (!skill.description) errors.push(`${skill.path}: missing frontmatter description`);
if (skill.name && skill.name !== skill.directory) {
errors.push(`${skill.path}: name '${skill.name}' does not match directory '${skill.directory}'`);
}
const refs = new Set<string>();
for (const match of skill.body.matchAll(refRe)) refs.add(match[1]);
for (const ref of Array.from(refs).sort()) {
if (!existsSync(join(skill.path, ref))) {
errors.push(`${skill.path}: referenced file does not exist: ${ref}`);
}
}
const legacyTroubleshooting = join(skill.path, "references", "troubleshooting.md");
if (existsSync(legacyTroubleshooting)) {
const text = readFileSync(legacyTroubleshooting, "utf8");
if (text.includes("\n## ") && !text.includes("### Symptom")) {
errors.push(`${legacyTroubleshooting}: troubleshooting entries should include '### Symptom'`);
}
}
return errors;
}
export function commandValidate(root: string): number {
const skills = loadSkills(root);
const caseItems = loadStructuredItems(root, "cases");
const suiteItems = loadStructuredItems(root, "suites");
const troubleItems = loadStructuredItems(root, "troubleshooting");
const skillNames = new Set(skills.map((skill) => skill.name));
const caseIds = new Set(caseItems.map((item) => scalar(item.fields, "id")).filter(Boolean));
const troubleIds = new Set(troubleItems.map((item) => scalar(item.fields, "id")).filter(Boolean));
const errors = [
...validateEnv(root),
...validateSchemas(root),
...skills.flatMap(validateSkill),
...caseItems.flatMap((item) => validateStructuredItem(item, caseRequiredStrings, caseRequiredLists)),
...caseItems.flatMap((item) => validateCaseItem(root, item, skillNames, troubleIds, caseIds)),
...validateSetupAutomationCycles(caseItems),
...suiteItems.flatMap((item) => validateStructuredItem(item, suiteRequiredStrings, suiteRequiredLists)),
...suiteItems.flatMap((item) => validateSuiteItem(item, caseIds)),
...troubleItems.flatMap((item) => validateStructuredItem(item, troubleRequiredStrings, troubleRequiredLists)),
...troubleItems.flatMap((item) => validateTroubleshootingItem(item, caseIds)),
...validateFixtures(root, caseIds),
...validateDuplicateIds(caseItems, "case"),
...validateDuplicateIds(suiteItems, "suite"),
...validateDuplicateIds(troubleItems, "troubleshooting"),
...validateGlobalDuplicateIds(caseItems, "case"),
...validateGlobalDuplicateIds(suiteItems, "suite"),
...validateGlobalDuplicateIds(troubleItems, "troubleshooting"),
];
if (errors.length > 0) {
for (const error of errors) stderr.write(`ERROR: ${error}\n`);
return 1;
}
console.log("OK");
return 0;
}