mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
* refactor(cli): derive contract-backed command options from the tool contract Each contract-backed CLI command restated its contract's input fields as hand-written incur options and mapped them back to snake_case input with conditional spreads. Nothing checked that the two schemas agreed. Add registerContractCommand: a per-command projection names only what the CLI spells differently (positionals, caller-relative path fields, option renames, CLI help wording, omitted optional fields with a reason). Option schemas, decimal argv tokens and the contract's cross-field rules are derived from the contract input schema, and parsed options map back to the contract input mechanically. Migrate inspect-sqlite-database and the thirteen flat binary-diagnostics inspectors. Their --schema output is unchanged apart from property order, and help lists options in contract order. Delete the SQLite superRefine that mirrored the contract and the per-command spreads. Add a parity test: every projected command exposes each contract field or declares its omission, and each field admits exactly what the contract admits for empty, NUL, relative-path, bound and numeric-spelling probes. * refactor(cli): refuse unprojected contract input forms at registration Address review findings on the contract projection: - Bind inspect-sqlite-database to its contract by name instead of catalog position. - Accept only field forms argv can carry (string, number, boolean, enum, and arrays of strings or enums) instead of rejecting a short list, so arrays of objects, tuples and other forms fail at registration. - Refuse root input keywords other than the projected object constraints and presentation annotations, so a contract rule such as `not` cannot disappear from --schema. * refactor(cli): describe contract commands independently of the CLI framework Split the contract projection into a framework-neutral description and a thin Incur adapter, so a later REA-owned CLI layer (#10583) can render the same description. - contractCliDescription.ts derives each positional and option from the contract and projection: key and short flag, help, argv admission schema, boolean-flag/repeatable/decimal/caller-path facts, object constraints renamed to CLI keys, the optional argv-time cross-field check, and the mapping back to contract input. - contractCommand.ts only renders that description as Incur args and options and runs the handler. Cross-field rules now refuse at argv parsing only when a projection asks (inspect-sqlite-database does, keeping its behavior); otherwise the service's contract parse refuses them as before. Exact-optional fields are projected as optional because argv cannot carry an explicit undefined and Incur's help reads flag and choice types only through optional. The parity guard reads the descriptions, and also checks that boolean fields parse as bare flags and that no projected field stays exact-optional.
266 lines
8.6 KiB
TypeScript
266 lines
8.6 KiB
TypeScript
import { Parser, z } from "incur";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { createCli } from "../../../src/cli.js";
|
|
import {
|
|
describeContractCommand,
|
|
type ContractCliDescription,
|
|
type ContractCliParameter,
|
|
} from "../../../src/cli/contractCliDescription.js";
|
|
import { contractCommandDescriptions } from "../../../src/cli/contractCommand.js";
|
|
import {
|
|
visitRegisteredCommands,
|
|
type RegisteredCommand,
|
|
} from "../../../src/cli/visitRegisteredCommands.js";
|
|
import { absoluteCallerPath } from "../../../src/filesystem/callerPath.js";
|
|
|
|
const DECIMAL_TOKEN = /^[0-9]+$/u;
|
|
|
|
const cli = createCli({});
|
|
const registered = new Map<string, RegisteredCommand>();
|
|
visitRegisteredCommands(cli, (command, path) => {
|
|
registered.set(path, command);
|
|
});
|
|
const descriptions = contractCommandDescriptions(cli);
|
|
|
|
const inputJsonSchema = (field: z.ZodType): Record<string, unknown> =>
|
|
z.toJSONSchema(field, { io: "input", unrepresentable: "any" });
|
|
|
|
/** Values at and around each advertised bound, plus spellings argv can carry. */
|
|
const probes = (field: z.ZodType): readonly unknown[] => {
|
|
const json = inputJsonSchema(field);
|
|
const values: unknown[] = [
|
|
undefined,
|
|
null,
|
|
"",
|
|
"\0",
|
|
"a\0b",
|
|
"\uD800",
|
|
"x",
|
|
"rel/x",
|
|
"/abs/x",
|
|
true,
|
|
false,
|
|
-1,
|
|
0,
|
|
1,
|
|
1.5,
|
|
64,
|
|
4096,
|
|
2 ** 31,
|
|
2 ** 53,
|
|
[],
|
|
["x"],
|
|
[""],
|
|
["a\0b"],
|
|
"0",
|
|
"1",
|
|
"010",
|
|
"0x10",
|
|
"1e3",
|
|
"+1",
|
|
" 1",
|
|
"-1",
|
|
"1.5",
|
|
];
|
|
for (const bound of ["minimum", "maximum", "minLength", "maxLength"]) {
|
|
const value = json[bound];
|
|
if (typeof value !== "number") continue;
|
|
const near = [value - 1, value, value + 1];
|
|
values.push(...near, ...near.filter((n) => n >= 0).map(String));
|
|
if (bound.endsWith("Length"))
|
|
values.push(...near.filter((n) => n >= 0).map((n) => "x".repeat(n)));
|
|
}
|
|
if (Array.isArray(json.enum)) values.push(...json.enum, `${json.enum[0]}!`);
|
|
return values;
|
|
};
|
|
|
|
const isNumeric = (field: z.ZodType): boolean => {
|
|
const json = inputJsonSchema(field);
|
|
return json.type === "integer" || json.type === "number";
|
|
};
|
|
|
|
/** Argv omission is an absent key, never an explicit undefined. */
|
|
const admits = (field: z.ZodType, value: unknown): boolean =>
|
|
value === undefined
|
|
? z.object({ value: field }).safeParse({}).success
|
|
: field.safeParse(value).success;
|
|
|
|
/** What the contract admits for one value the caller typed on the CLI. */
|
|
const expectedAdmission = (
|
|
parameter: ContractCliParameter,
|
|
contractField: z.ZodType,
|
|
value: unknown,
|
|
): boolean => {
|
|
if (parameter.callerPath) {
|
|
// A caller path is validated as typed: an empty value never becomes the
|
|
// working directory. A nonempty value is resolved, then the contract rules.
|
|
if (typeof value !== "string") return admits(contractField, value);
|
|
return value !== "" && admits(contractField, absoluteCallerPath(value));
|
|
}
|
|
if (typeof value === "string" && isNumeric(contractField))
|
|
return DECIMAL_TOKEN.test(value) && admits(contractField, Number(value));
|
|
return admits(contractField, value);
|
|
};
|
|
|
|
const registeredField = (
|
|
description: ContractCliDescription,
|
|
parameter: ContractCliParameter,
|
|
): z.ZodType => {
|
|
const command = registered.get(description.command)!;
|
|
const group = (
|
|
parameter.positional ? command.args : command.options
|
|
) as z.ZodObject;
|
|
return group.shape[parameter.key] as z.ZodType;
|
|
};
|
|
|
|
const contractShape = (description: ContractCliDescription) =>
|
|
description.contract.inputSchema.shape as Record<string, z.ZodType>;
|
|
|
|
const each = descriptions.map(
|
|
(description) => [description.command, description] as const,
|
|
);
|
|
|
|
describe("CLI commands projected from tool contracts", () => {
|
|
it("registers the migrated commands", () => {
|
|
expect(descriptions.map(({ command }) => command)).toEqual(
|
|
expect.arrayContaining([
|
|
"inspect-sqlite-database",
|
|
"inspect-elf-notes",
|
|
"inspect-pe-resources",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it.each(each)(
|
|
"%s exposes every contract field or declares its omission",
|
|
(_command, description) => {
|
|
expect(
|
|
[
|
|
...description.parameters.map(({ field }) => field),
|
|
...Object.keys(description.omitted),
|
|
]
|
|
.toSorted()
|
|
.join(),
|
|
).toBe(Object.keys(contractShape(description)).toSorted().join());
|
|
const command = registered.get(description.command)!;
|
|
const argKeys = Object.keys(
|
|
(command.args as z.ZodObject | undefined)?.shape ?? {},
|
|
);
|
|
const optionKeys = Object.keys(
|
|
(command.options as z.ZodObject | undefined)?.shape ?? {},
|
|
);
|
|
expect(argKeys).toEqual(
|
|
description.parameters
|
|
.filter(({ positional }) => positional)
|
|
.map(({ key }) => key),
|
|
);
|
|
expect(optionKeys.toSorted()).toEqual(
|
|
description.parameters
|
|
.filter(({ positional }) => !positional)
|
|
.map(({ key }) => key)
|
|
.toSorted(),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(each)(
|
|
"%s admits exactly what its contract admits",
|
|
(_command, description) => {
|
|
const mismatches: string[] = [];
|
|
for (const parameter of description.parameters) {
|
|
const cliField = registeredField(description, parameter);
|
|
const contractField = contractShape(description)[parameter.field]!;
|
|
// Incur's help reads flag and choice types only through `optional`.
|
|
expect(cliField, parameter.key).not.toBeInstanceOf(z.ZodExactOptional);
|
|
if (parameter.callerPath) {
|
|
// Only the absolute-path rule is relaxed, and only by resolution.
|
|
expect(admits(contractField, "rel/x"), parameter.field).toBe(false);
|
|
expect(admits(contractField, absoluteCallerPath("rel/x"))).toBe(true);
|
|
}
|
|
for (const value of probes(contractField)) {
|
|
const expected = expectedAdmission(parameter, contractField, value);
|
|
if (admits(cliField, value) !== expected)
|
|
mismatches.push(
|
|
`${parameter.key}=${JSON.stringify(value)} expected ${String(expected)}`,
|
|
);
|
|
}
|
|
}
|
|
expect(mismatches).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it.each(each)(
|
|
"%s takes each boolean contract field as a bare flag",
|
|
(_command, description) => {
|
|
for (const parameter of description.parameters) {
|
|
if (parameter.positional || !parameter.booleanFlag) continue;
|
|
const options = z.object({
|
|
[parameter.key]: registeredField(description, parameter),
|
|
});
|
|
const flag = `--${parameter.key.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`)}`;
|
|
expect(Parser.parse([flag], { options }).options, flag).toEqual({
|
|
[parameter.key]: true,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(each)(
|
|
"%s maps parsed argv to an admitted contract input",
|
|
(_command, description) => {
|
|
const command = registered.get(description.command)!;
|
|
const values = {
|
|
...(command.options as z.ZodObject).parse({}),
|
|
...Object.fromEntries(
|
|
description.parameters
|
|
.filter(({ positional }) => positional)
|
|
.map(({ key }) => [key, "rel/target"]),
|
|
),
|
|
};
|
|
const input = description.toContractInput(values);
|
|
expect(description.contract.inputSchema.safeParse(input).error).toBe(
|
|
undefined,
|
|
);
|
|
for (const parameter of description.parameters)
|
|
if (parameter.callerPath && parameter.positional) {
|
|
expect(input[parameter.field]).toBe(absoluteCallerPath("rel/target"));
|
|
expect(description.callerInputs(values)[parameter.field]).toBe(
|
|
"rel/target",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["an object", z.object({ value: z.string() }).optional()],
|
|
["a record", z.record(z.string(), z.string()).optional()],
|
|
["a union", z.union([z.string(), z.boolean()]).optional()],
|
|
["a pipe", z.string().pipe(z.string()).optional()],
|
|
["an array of objects", z.array(z.object({})).optional()],
|
|
["an array of numbers", z.array(z.number()).optional()],
|
|
["a tuple", z.tuple([z.string()]).optional()],
|
|
])("refuses to describe a field that is %s", (_kind, field) => {
|
|
expect(() =>
|
|
describeContractCommand(
|
|
{ name: "probe", inputSchema: z.object({ field }) },
|
|
{ command: "probe", description: "Probe" },
|
|
),
|
|
).toThrow("probe.field has no argv projection");
|
|
});
|
|
|
|
it("refuses an input constraint the CLI schema cannot carry", () => {
|
|
expect(() =>
|
|
describeContractCommand(
|
|
{
|
|
name: "probe",
|
|
inputSchema: z
|
|
.object({ value: z.string().optional() })
|
|
.meta({ not: { required: ["value"] } }),
|
|
},
|
|
{ command: "probe", description: "Probe" },
|
|
),
|
|
).toThrow("probe input keyword not has no CLI projection");
|
|
});
|
|
});
|