fix: harden TaskTree CLI contracts

This commit is contained in:
root
2026-07-18 09:10:40 +02:00
parent c926dbfb91
commit 9562e63ec4
6 changed files with 271 additions and 30 deletions
+21 -8
View File
@@ -43,20 +43,28 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
const tasks = await store.listTasks(task.groupId);
data = { ...taskNode(task, tasks), reports: await store.listReports(task.id) };
}
else if (operation === "task.create") data = await store.createTask({
...command,
title: validTitle(command.title),
startAt: optionalDate(command.startAt, "startAt"),
dueAt: optionalDate(command.dueAt, "dueAt")
});
else if (operation === "task.create") {
const startAt = optionalDate(command.startAt, "startAt");
const dueAt = optionalDate(command.dueAt, "dueAt");
validTimeRange(startAt, dueAt);
data = await store.createTask({
...command,
title: validTitle(command.title),
startAt,
dueAt
});
}
else if (operation === "task.create-batch") {
const titles = command.titles.map(validTitle);
if (titles.length < 1) throw codedError("invalid_input", "at least one title is required");
const startAt = optionalDate(command.startAt, "startAt");
const dueAt = optionalDate(command.dueAt, "dueAt");
validTimeRange(startAt, dueAt);
data = { tasks: await store.createTasks({
...command,
titles,
startAt: optionalDate(command.startAt, "startAt"),
dueAt: optionalDate(command.dueAt, "dueAt")
startAt,
dueAt
}) };
}
else if (operation === "task.update") {
@@ -122,6 +130,11 @@ function optionalDate(value: string | undefined, field: string): string | undefi
function nullableDate(value: string | null | undefined, field: string): string | null | undefined { return value === null || value === undefined ? value : validDate(value, field); }
function validDate(value: unknown, field: string): string { const date = new Date(String(value ?? "")); if (Number.isNaN(date.valueOf())) throw codedError("invalid_input", `${field} must be an ISO date`); return date.toISOString(); }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
function validTimeRange(startAt?: string, dueAt?: string) {
if (startAt !== undefined && dueAt !== undefined && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) {
throw codedError("invalid_time_range", "startAt must be before or equal to dueAt");
}
}
type TaskNode = TaskItem & { children: TaskNode[] };
+1
View File
@@ -2,6 +2,7 @@ import { TaskTreeStore } from "./store.ts";
export function taskTreeRuntime(env: Record<string, string | undefined> = process.env) {
const databaseUrl = env.TASKTREE_DATABASE_URL || env.DATABASE_URL || "";
if (!databaseUrl) throw Object.assign(new Error("TASKTREE_DATABASE_URL or DATABASE_URL is required"), { code: "missing_database_url" });
const store = new TaskTreeStore(databaseUrl);
return {
store,
+33 -6
View File
@@ -155,6 +155,7 @@ export class TaskTreeStore {
async createTasks(input: { groupId: string; titles: string[]; description?: string; startAt?: string; dueAt?: string; parentId?: string }): Promise<TaskItem[]> {
await this.ensureSchema();
ensureTimeRange(input.startAt ?? null, input.dueAt ?? null);
const client = await this.pool.connect();
try {
await client.query("BEGIN");
@@ -195,10 +196,13 @@ export class TaskTreeStore {
const current = await this.pool.query("SELECT * FROM tasktree_tasks WHERE id=$1", [id]);
if (!current.rows[0]) return null;
const row = taskRow(current.rows[0]);
const startAt = patch.startAt === undefined ? row.startAt : patch.startAt;
const dueAt = patch.dueAt === undefined ? row.dueAt : patch.dueAt;
ensureTimeRange(startAt, dueAt);
const result = await this.pool.query(
`UPDATE tasktree_tasks SET title=$2,description=$3,status=$4,start_at=$5,due_at=$6,updated_at=now() WHERE id=$1 RETURNING *`,
[id, patch.title ?? row.title, patch.description ?? row.description, patch.status ?? row.status,
patch.startAt === undefined ? row.startAt : patch.startAt, patch.dueAt === undefined ? row.dueAt : patch.dueAt]
startAt, dueAt]
);
await this.pool.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [row.groupId]);
return taskRow(result.rows[0]);
@@ -239,11 +243,29 @@ export class TaskTreeStore {
async createMilestone(input: { groupId: string; title: string; occursAt: string; taskId?: string }): Promise<Milestone> {
await this.ensureSchema();
const result = await this.pool.query(
"INSERT INTO tasktree_milestones (id,group_id,task_id,title,occurs_at) VALUES ($1,$2,$3,$4,$5) RETURNING *",
[`tm_${randomUUID()}`, input.groupId, input.taskId ?? null, input.title, input.occursAt]
);
return milestoneRow(result.rows[0]);
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const group = await client.query("SELECT id FROM tasktree_groups WHERE id=$1 FOR UPDATE", [input.groupId]);
if (!group.rows[0]) throw domainError("group_not_found", "taskgroup was not found");
if (input.taskId) {
const task = await client.query("SELECT group_id FROM tasktree_tasks WHERE id=$1", [input.taskId]);
if (!task.rows[0]) throw domainError("task_not_found", "task was not found");
if (task.rows[0].group_id !== input.groupId) throw domainError("task_group_mismatch", "task belongs to another taskgroup");
}
const result = await client.query(
"INSERT INTO tasktree_milestones (id,group_id,task_id,title,occurs_at) VALUES ($1,$2,$3,$4,$5) RETURNING *",
[`tm_${randomUUID()}`, input.groupId, input.taskId ?? null, input.title, input.occursAt]
);
await client.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [input.groupId]);
await client.query("COMMIT");
return milestoneRow(result.rows[0]);
} catch (error) {
await client.query("ROLLBACK").catch(() => {});
throw error;
} finally {
client.release();
}
}
async createReport(input: { taskId: string; title: string; body: string; status?: string }): Promise<ExecutionReport> {
@@ -355,3 +377,8 @@ function reportRow(row: any): ExecutionReport { return { id: row.id, taskId: row
function iso(value: unknown): string { return new Date(value as any).toISOString(); }
function nullableIso(value: unknown): string | null { return value == null ? null : iso(value); }
function domainError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
function ensureTimeRange(startAt: string | null, dueAt: string | null) {
if (startAt !== null && dueAt !== null && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) {
throw domainError("invalid_time_range", "startAt must be before or equal to dueAt");
}
}
+45
View File
@@ -31,8 +31,10 @@ test("HTTP command endpoint returns the same dispatcher DTO", async () => {
test("dispatcher keeps batch validation atomic and completion gated", async () => {
let batchCalls = 0;
let createCalls = 0;
let updateCalls = 0;
const store = {
async createTask() { createCalls += 1; return null; },
async createTasks() { batchCalls += 1; return []; },
async updateTask() { updateCalls += 1; return null; },
async deleteTask() { return false; }
@@ -44,6 +46,17 @@ test("dispatcher keeps batch validation atomic and completion gated", async () =
assert.equal(batch.error?.code, "invalid_title");
assert.equal(batchCalls, 0);
const invalidRange = await dispatch({
operation: "task.create",
groupId: "tg_1",
title: "Invalid range",
startAt: "2026-07-19T00:00:00.000Z",
dueAt: "2026-07-18T00:00:00.000Z"
});
assert.equal(invalidRange.ok, false);
assert.equal(invalidRange.error?.code, "invalid_time_range");
assert.equal(createCalls, 0);
const bypass = await dispatch({ operation: "task.update", taskId: "tt_1", status: "completed" });
assert.equal(bypass.ok, false);
assert.equal(bypass.error?.code, "completion_command_required");
@@ -52,6 +65,10 @@ test("dispatcher keeps batch validation atomic and completion gated", async () =
const missing = await dispatch({ operation: "task.delete", taskId: "tt_missing" });
assert.equal(missing.ok, false);
assert.equal(missing.error?.code, "task_not_found");
const workflow = await dispatch({ operation: "workflow.start", taskId: "tt_1" });
assert.equal(workflow.ok, false);
assert.equal(workflow.error?.code, "temporal_address_required");
});
test("native PostgreSQL store exposes overview and three task levels", async () => {
@@ -59,6 +76,7 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test");
const store = new TaskTreeStore(databaseUrl);
const group = await store.createGroup(`TaskTree test ${Date.now()}`);
const otherGroup = await store.createGroup(`TaskTree cross-group test ${Date.now()}`);
try {
const task = await store.createTask({
groupId: group.id,
@@ -71,6 +89,32 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
const batch = await store.createTasks({ groupId: group.id, parentId: task.id, titles: ["Batch one", "Batch two"] });
assert.deepEqual(batch.map((item) => item.title), ["Batch one", "Batch two"]);
await store.createMilestone({ groupId: group.id, taskId: task.id, title: "Review", occursAt: "2026-07-17T00:00:00.000Z" });
await assert.rejects(
store.createMilestone({ groupId: otherGroup.id, taskId: task.id, title: "Wrong group", occursAt: "2026-07-17T00:00:00.000Z" }),
(error: any) => error?.code === "task_group_mismatch"
);
await assert.rejects(
store.createMilestone({ groupId: "tg_missing", title: "Missing group", occursAt: "2026-07-17T00:00:00.000Z" }),
(error: any) => error?.code === "group_not_found"
);
await assert.rejects(
store.createMilestone({ groupId: group.id, taskId: "tt_missing", title: "Missing task", occursAt: "2026-07-17T00:00:00.000Z" }),
(error: any) => error?.code === "task_not_found"
);
await assert.rejects(
store.createTask({
groupId: group.id,
title: "Invalid range",
startAt: "2026-07-19T00:00:00.000Z",
dueAt: "2026-07-18T00:00:00.000Z"
}),
(error: any) => error?.code === "invalid_time_range"
);
await assert.rejects(
store.updateTask(task.id, { dueAt: "2026-07-15T00:00:00.000Z" }),
(error: any) => error?.code === "invalid_time_range"
);
assert.equal((await store.getTask(task.id))?.dueAt, "2026-07-18T00:00:00.000Z");
await assert.rejects(
store.completeTask(task.id),
(error: any) => error?.code === "execution_report_required"
@@ -117,6 +161,7 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
assert.equal(overview?.startAt, "2026-07-16T00:00:00.000Z");
assert.equal(overview?.dueAt, "2026-07-18T00:00:00.000Z");
} finally {
await store.deleteGroup(otherGroup.id);
await store.deleteGroup(group.id);
await store.close();
}
+70
View File
@@ -68,6 +68,10 @@ test("TaskTree command surface covers hierarchy, lifecycle, batch, stats, and re
test("TaskTree help and public operations do not expose the legacy domain term", async () => {
const result = await runTaskTreeCli(["--help"]);
assert.doesNotMatch(JSON.stringify(result), /mdtodo/iu);
assert.ok(result.usage.includes("hwlab-cli tasktree group list|overview"));
assert.ok(result.usage.includes("hwlab-cli tasktree group get|delete|stats --group ID"));
assert.match(result.usage.join("\n"), /task update .*--start ISO\|null.*--due ISO\|null/u);
assert.doesNotMatch(result.usage.join("\n"), /report create/u);
});
test("TaskTree rejects ambiguous title input and empty update patches before transport", async () => {
@@ -80,3 +84,69 @@ test("TaskTree rejects ambiguous title input and empty update patches before tra
(error: any) => error?.code === "update_patch_required"
);
});
test("TaskTree rejects unknown, duplicate, hidden, and extra CLI arguments", async () => {
const cases: Array<[string[], string]> = [
[["group", "list", "unexpected"], "invalid_arguments"],
[["group", "list", "--bogus", "value"], "invalid_option"],
[["group", "create", "--name", "One", "--name", "Two"], "duplicate_option"],
[["report", "create", "--task", "tt_1", "--title", "Hidden", "--body", "body"], "unsupported_command"],
[["group", "list", "--api-url", "http://tasktree.test"], "invalid_option"],
[["group", "list", "--stdin"], "invalid_option"]
];
for (const [argv, code] of cases) {
await assert.rejects(runTaskTreeCli(argv), (error: any) => error?.code === code);
}
});
test("TaskTree accepts an explicit empty description value", async () => {
const originalFetch = globalThis.fetch;
let received: unknown;
globalThis.fetch = (async (_input, init) => {
received = JSON.parse(String(init?.body));
return Response.json({ ok: true, operation: "group.create", data: {} });
}) as typeof fetch;
try {
await runTaskTreeCli(
["group", "create", "--name", "Group", "--description", "", "--overapi"],
{ HWLAB_TASKTREE_API_URL: "http://tasktree.test" }
);
assert.deepEqual(received, { operation: "group.create", name: "Group", description: "" });
} finally {
globalThis.fetch = originalFetch;
}
});
test("TaskTree returns stable transport and runtime configuration errors", async () => {
await assert.rejects(
runTaskTreeCli(["group", "list", "--overapi"]),
(error: any) => error?.code === "missing_api_url"
);
await assert.rejects(
runTaskTreeCli(["health"], {}),
(error: any) => error?.code === "missing_database_url"
);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () => { throw new Error("connection refused"); }) as typeof fetch;
await assert.rejects(
runTaskTreeCli(["group", "list", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }),
(error: any) => error?.code === "api_unreachable"
);
for (const response of [
new Response("not json", { status: 502 }),
Response.json({ ok: true, operation: "task.get", data: {} }),
Response.json({ ok: true, operation: "group.list", data: {} }, { status: 500 })
]) {
globalThis.fetch = (async () => response) as typeof fetch;
await assert.rejects(
runTaskTreeCli(["group", "list", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }),
(error: any) => error?.code === "invalid_api_response"
);
}
} finally {
globalThis.fetch = originalFetch;
}
});
+101 -16
View File
@@ -8,6 +8,7 @@ import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts";
export async function runTaskTreeCli(argv: string[], env: Record<string, string | undefined> = process.env, stdin?: string) {
const parsed = parse(argv);
if (parsed.help || parsed.positionals.length === 0) return help();
validateArguments(parsed);
const command = await commandFrom(parsed, stdin);
if (parsed.overapi) return overApi(command, env, parsed);
const runtime = taskTreeRuntime(env);
@@ -25,20 +26,24 @@ function help() {
mode: "local-dispatcher-default",
usage: [
"hwlab-cli tasktree health [--overapi]",
"hwlab-cli tasktree group list|overview|get|create|delete|stats [--group ID] [--name TEXT]",
"hwlab-cli tasktree group list|overview",
"hwlab-cli tasktree group get|delete|stats --group ID",
"hwlab-cli tasktree group create --name TEXT [--description TEXT]",
"hwlab-cli tasktree group import-markdown --file PATH [--name TEXT] [--dry-run]",
"hwlab-cli tasktree task list|get --group ID|--task ID",
"hwlab-cli tasktree task create --group ID [--parent ID] (--title TEXT|--stdin)",
"hwlab-cli tasktree task create-batch --group ID [--parent ID] TITLE...",
"hwlab-cli tasktree task update --task ID [--title TEXT|--stdin] [--description TEXT] [--status STATUS]",
"hwlab-cli tasktree task list --group ID",
"hwlab-cli tasktree task get --task ID",
"hwlab-cli tasktree task create --group ID [--parent ID] (--title TEXT|--stdin) [--description TEXT] [--start ISO] [--due ISO]",
"hwlab-cli tasktree task create-batch --group ID [--parent ID] [--description TEXT] [--start ISO] [--due ISO] TITLE...",
"hwlab-cli tasktree task update --task ID [--title TEXT|--stdin] [--description TEXT] [--status STATUS] [--start ISO|null] [--due ISO|null]",
"hwlab-cli tasktree task start|complete|done|delete|remove --task ID",
"hwlab-cli tasktree milestone create --group ID [--task ID] --title TEXT --at ISO",
"hwlab-cli tasktree report list|get --task ID|--report ID",
"hwlab-cli tasktree report write --task ID --title TEXT (--body TEXT|--body-file PATH|--stdin)",
"hwlab-cli tasktree report write --task ID --title TEXT (--body TEXT|--body-file PATH|--stdin) [--status STATUS]",
"hwlab-cli tasktree timeline --group ID [--overapi]",
"hwlab-cli tasktree workflow start --task ID"
],
transportContract: "--overapi only changes transport; commands and options are identical",
transportOptions: "--overapi [--api-url URL]",
hierarchy: "taskgroup -> task -> subtask -> subsubtask",
completionContract: "a task requires at least one execution report before completion"
};
@@ -100,17 +105,33 @@ async function commandFrom(parsed: Parsed, stdin?: string): Promise<TaskTreeComm
if (group === "report" && action === "list") return { operation: "report.list", taskId: required(parsed, "task") };
if (group === "report" && action === "get") return { operation: "report.get", reportId: required(parsed, "report") };
if (group === "report" && action === "write") return { operation: "report.write", taskId: required(parsed, "task"), title: required(parsed, "title"), body: await bodyInput(parsed, stdin), status: parsed.values.status };
if (group === "report" && action === "create") return { operation: "report.create", taskId: required(parsed, "task"), title: required(parsed, "title"), body: parsed.values.body ?? await readFile(required(parsed, "body-file"), "utf8"), status: parsed.values.status };
if (group === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") };
throw new Error(`unsupported TaskTree command: ${group} ${action}`.trim());
throw codedError("unsupported_command", `unsupported TaskTree command: ${group} ${action}`.trim());
}
async function overApi(command: TaskTreeCommand, env: Record<string, string | undefined>, parsed: Parsed) {
const baseUrl = parsed.values["api-url"] || env.HWLAB_TASKTREE_API_URL;
if (!baseUrl) throw new Error("HWLAB_TASKTREE_API_URL or --api-url is required with --overapi");
const response = await fetch(`${baseUrl.replace(/\/$/u, "")}/v1/tasktree/commands`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(command) });
if (!baseUrl) throw codedError("missing_api_url", "HWLAB_TASKTREE_API_URL or --api-url is required with --overapi");
let response: Response;
try {
response = await fetch(`${baseUrl.replace(/\/$/u, "")}/v1/tasktree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(command)
});
} catch {
throw codedError("api_unreachable", "TaskTree API request failed");
}
const body = await response.json().catch(() => null);
if (!body || typeof body !== "object") throw new Error(`TaskTree API returned invalid JSON (${response.status})`);
if (
!body
|| typeof body !== "object"
|| typeof (body as any).ok !== "boolean"
|| (body as any).operation !== command.operation
|| response.ok !== (body as any).ok
) {
throw codedError("invalid_api_response", `TaskTree API returned an invalid response (${response.status})`);
}
return { ...(body as object), transport: "api", httpStatus: response.status };
}
@@ -119,21 +140,28 @@ function parse(argv: string[]): Parsed {
const parsed: Parsed = { positionals: [], values: {}, flags: new Set(), overapi: false, help: false };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--overapi") parsed.overapi = true;
else if (token === "--dry-run") parsed.flags.add("dry-run");
else if (token === "--stdin") parsed.flags.add("stdin");
if (token === "--overapi") {
if (parsed.overapi) throw codedError("duplicate_option", "--overapi may only be provided once");
parsed.overapi = true;
}
else if (token === "--dry-run" || token === "--stdin") {
const flag = token.slice(2);
if (parsed.flags.has(flag)) throw codedError("duplicate_option", `${token} may only be provided once`);
parsed.flags.add(flag);
}
else if (token === "--help" || token === "-h") parsed.help = true;
else if (token.startsWith("--")) {
const key = token.slice(2);
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${token} requires a value`);
if (parsed.values[key] !== undefined) throw codedError("duplicate_option", `${token} may only be provided once`);
if (value === undefined || value.startsWith("--")) throw codedError("missing_option_value", `${token} requires a value`);
parsed.values[key] = value;
index += 1;
} else parsed.positionals.push(token);
}
return parsed;
}
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw new Error(`--${key} is required`); return value; }
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw codedError("missing_option", `--${key} is required`); return value; }
function titleInput(parsed: Parsed, stdin?: string) {
if (parsed.flags.has("stdin") && parsed.values.title !== undefined) throw codedError("title_source_conflict", "--title and --stdin are mutually exclusive");
const value = parsed.flags.has("stdin") ? stripOneLineEnding(stdin ?? "") : parsed.values.title;
@@ -152,3 +180,60 @@ function nullableDateOption(parsed: Parsed, key: string): string | null | undefi
}
function stripOneLineEnding(value: string) { return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value; }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
type CommandSpec = {
positionals: number | { min: number; max?: number };
options?: string[];
flags?: string[];
};
const COMMAND_SPECS: Record<string, CommandSpec> = {
health: { positionals: 1 },
timeline: { positionals: 1, options: ["group"] },
"group list": { positionals: 2 },
"group overview": { positionals: 2 },
"group get": { positionals: 2, options: ["group"] },
"group create": { positionals: 2, options: ["name", "description"] },
"group delete": { positionals: 2, options: ["group"] },
"group stats": { positionals: 2, options: ["group"] },
"group import-markdown": { positionals: 2, options: ["file", "name"], flags: ["dry-run"] },
"task list": { positionals: 2, options: ["group"] },
"task get": { positionals: 2, options: ["task"] },
"task create": { positionals: 2, options: ["group", "parent", "title", "description", "start", "due"], flags: ["stdin"] },
"task create-batch": { positionals: { min: 3 }, options: ["group", "parent", "description", "start", "due"] },
"task update": { positionals: 2, options: ["task", "title", "description", "status", "start", "due"], flags: ["stdin"] },
"task delete": { positionals: 2, options: ["task"] },
"task remove": { positionals: 2, options: ["task"] },
"task start": { positionals: 2, options: ["task"] },
"task complete": { positionals: 2, options: ["task"] },
"task done": { positionals: 2, options: ["task"] },
"milestone create": { positionals: 2, options: ["group", "task", "title", "at"] },
"report list": { positionals: 2, options: ["task"] },
"report get": { positionals: 2, options: ["report"] },
"report write": { positionals: 2, options: ["task", "title", "body", "body-file", "status"], flags: ["stdin"] },
"workflow start": { positionals: 2, options: ["task"] }
};
function validateArguments(parsed: Parsed) {
const key = parsed.positionals.slice(0, 2).join(" ");
const spec = COMMAND_SPECS[key] ?? COMMAND_SPECS[parsed.positionals[0]];
if (!spec) throw codedError("unsupported_command", `unsupported TaskTree command: ${key}`);
const positionals = typeof spec.positionals === "number"
? { min: spec.positionals, max: spec.positionals }
: spec.positionals;
if (parsed.positionals.length < positionals.min || (positionals.max !== undefined && parsed.positionals.length > positionals.max)) {
throw codedError("invalid_arguments", `unexpected positional arguments for ${key}`);
}
const allowedOptions = new Set(spec.options ?? []);
if (parsed.overapi) allowedOptions.add("api-url");
for (const option of Object.keys(parsed.values)) {
if (!allowedOptions.has(option)) throw codedError("invalid_option", `--${option} is not valid for ${key}`);
}
if (parsed.values["api-url"] !== undefined && !parsed.overapi) {
throw codedError("invalid_option", "--api-url requires --overapi");
}
const allowedFlags = new Set(spec.flags ?? []);
for (const flag of parsed.flags) {
if (!allowedFlags.has(flag)) throw codedError("invalid_option", `--${flag} is not valid for ${key}`);
}
}