[fix] nopy: find the prompt label through zod's wrappers
Closes DOCS-AUDIT §2.3.
The cube contract says each schema field is `.describe()`d and that the
description is the prompt label. Whether it was depended on the order the
manifest happened to chain in: zod 4 keys a description to the schema
*instance* and `.default()` returns a new `ZodDefault` around the described
type rather than mutating it, so the wrapper carries no description of its own
and the prompt, reading the outer node, fell back to the bare key.
z.boolean().describe('Update package cache').default(false) -> 'UPDATE'
z.boolean().default(false).describe('Update package cache') -> the sentence
15 of the 22 core cubes are written the first way, so most prompts showed a key.
`promptLabel()` walks down through `default` / `optional` / `nullable` looking
for a description, which makes the two orders equivalent — the answer that
cannot regress, where re-ordering every manifest and hoping the next one written
gets it right can. It discriminates on `zodKind`, not `instanceof`, for the
reason recorded on that helper: a manifest built by a different zod copy fails
every `instanceof` in the module.
The mocked test asserts all four shapes, including a doubly-wrapped
`describe().optional().default()` and a field with no description at all. The
pty test is the one that carries the weight: its probe schema is written in the
losing order, and it now waits for `First value` on a real enquirer render, so
removing the unwrapping fails a test that talks to an actual terminal rather
than to a mocked `Form`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCzYTAm9QUhvLNr2EpdagJ
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb8b1bfa5c
commit
09554b6785
@@ -236,6 +236,43 @@ interface FormChoice {
|
|||||||
initial: string;
|
initial: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label to prompt a schema field with: its `.describe()`, read through the
|
||||||
|
* wrappers that hide it, falling back to the bare key.
|
||||||
|
*
|
||||||
|
* In zod 4 a description lives in `z.globalRegistry` keyed by the schema
|
||||||
|
* *instance*, and `.default()` returns a new `ZodDefault` around the described
|
||||||
|
* type rather than mutating it. So the wrapper carries no description of its
|
||||||
|
* own, and the chaining order used to decide whether the label survived:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* z.boolean().describe('Update package cache').default(false) → 'UPDATE'
|
||||||
|
* z.boolean().default(false).describe('Update package cache') → the sentence
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 15 of the 22 core cubes were written the first way, so most prompts showed a
|
||||||
|
* bare key. Unwrapping makes the two orders equivalent, which is the answer that
|
||||||
|
* cannot regress — the alternative was to re-order every manifest and hope the
|
||||||
|
* next one written gets it right.
|
||||||
|
*
|
||||||
|
* Discriminates on {@link zodKind}, not `instanceof`, for the reason given
|
||||||
|
* there. Falling open here only costs an ugly label, but there is no reason to.
|
||||||
|
*/
|
||||||
|
function promptLabel(zodType: unknown, key: string): string {
|
||||||
|
let current = zodType;
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
const description = (current as { description?: string }).description;
|
||||||
|
if (description) return description;
|
||||||
|
|
||||||
|
const kind = zodKind(current);
|
||||||
|
if (kind !== 'default' && kind !== 'optional' && kind !== 'nullable') break;
|
||||||
|
current = zodInner(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks the user for a cube's variables and records the answers.
|
* Asks the user for a cube's variables and records the answers.
|
||||||
*
|
*
|
||||||
@@ -269,11 +306,11 @@ export async function VariableAssignment<S extends AnyObjectSchema>(
|
|||||||
|
|
||||||
if (Object.keys(variablesToConfigure).length === 0) return;
|
if (Object.keys(variablesToConfigure).length === 0) return;
|
||||||
|
|
||||||
const choices: FormChoice[] = Object.entries(variablesToConfigure).map(([key, value]) => {
|
const choices: FormChoice[] = Object.entries(variablesToConfigure).map(([key, value]) => ({
|
||||||
const zodType = schema[key];
|
name: key,
|
||||||
const description = zodType?.description || key;
|
message: promptLabel(schema[key], key),
|
||||||
return { name: key, message: description, initial: String(value ?? '') };
|
initial: String(value ?? ''),
|
||||||
});
|
}));
|
||||||
|
|
||||||
const form = new (Enquirer as any).Form({
|
const form = new (Enquirer as any).Form({
|
||||||
name: 'variables',
|
name: 'variables',
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ const hasPython = spawnSync('python3', ['--version']).status === 0;
|
|||||||
/** Down arrow — how the form moves from one field to the next. */
|
/** Down arrow — how the form moves from one field to the next. */
|
||||||
const DOWN = '\u001b[B';
|
const DOWN = '\u001b[B';
|
||||||
|
|
||||||
|
// Waits for the *description*, not the key. The probe schema is written
|
||||||
|
// `.describe(…).default(…)` — the order that used to lose the label — so this
|
||||||
|
// step also witnesses the zod-wrapper unwrapping through a real enquirer render
|
||||||
|
// rather than through the mocked Form in `prompts.test.ts`.
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ expect: 'ALPHA', send: 'alpha-typed', settle: 0.6 },
|
{ expect: 'First value', send: 'alpha-typed', settle: 0.6 },
|
||||||
{ send: DOWN, settle: 0.4 },
|
{ send: DOWN, settle: 0.4 },
|
||||||
{ send: 'beta-typed', settle: 0.4 },
|
{ send: 'beta-typed', settle: 0.4 },
|
||||||
{ send: '\r', settle: 1.2 },
|
{ send: '\r', settle: 1.2 },
|
||||||
|
|||||||
@@ -356,6 +356,34 @@ describe('VariableAssignment', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('finds the label whichever side of .default() it was declared on', async () => {
|
||||||
|
// zod 4 keys a description to the schema *instance* and `.default()` returns
|
||||||
|
// a new wrapper, so `describe().default()` used to prompt with the bare key.
|
||||||
|
// 15 of the 22 core cubes were written that way round.
|
||||||
|
const both = cube(
|
||||||
|
'both',
|
||||||
|
'Both',
|
||||||
|
z.object({
|
||||||
|
BEFORE: z.boolean().describe('Update package cache').default(false),
|
||||||
|
AFTER: z.boolean().default(false).describe('Restart afterwards'),
|
||||||
|
WRAPPED: z.string().describe('Optional note').optional().default('n/a'),
|
||||||
|
NEITHER: z.string().default('x'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const variables = new Variables();
|
||||||
|
variables.assign('both', 'default', both.getDefaults());
|
||||||
|
formRun.mockResolvedValue({});
|
||||||
|
|
||||||
|
await VariableAssignment(both, variables);
|
||||||
|
|
||||||
|
expect(formChoices().map((c) => c.message)).toEqual([
|
||||||
|
'Update package cache',
|
||||||
|
'Restart afterwards',
|
||||||
|
'Optional note',
|
||||||
|
'NEITHER',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('offers the value the run would use, not the bare schema default', async () => {
|
it('offers the value the run would use, not the bare schema default', async () => {
|
||||||
const svc = cube('svc', 'Service', schema);
|
const svc = cube('svc', 'Service', schema);
|
||||||
const variables = new Variables({ port: 2222 });
|
const variables = new Variables({ port: 2222 });
|
||||||
|
|||||||
Reference in New Issue
Block a user