[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:
Benjamin Diedrichsen
2026-09-01 12:48:16 +02:00
co-authored by Claude Opus 5
parent bb8b1bfa5c
commit 09554b6785
3 changed files with 75 additions and 6 deletions
+42 -5
View File
@@ -236,6 +236,43 @@ interface FormChoice {
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.
*
@@ -269,11 +306,11 @@ export async function VariableAssignment<S extends AnyObjectSchema>(
if (Object.keys(variablesToConfigure).length === 0) return;
const choices: FormChoice[] = Object.entries(variablesToConfigure).map(([key, value]) => {
const zodType = schema[key];
const description = zodType?.description || key;
return { name: key, message: description, initial: String(value ?? '') };
});
const choices: FormChoice[] = Object.entries(variablesToConfigure).map(([key, value]) => ({
name: key,
message: promptLabel(schema[key], key),
initial: String(value ?? ''),
}));
const form = new (Enquirer as any).Form({
name: 'variables',
+5 -1
View File
@@ -31,8 +31,12 @@ const hasPython = spawnSync('python3', ['--version']).status === 0;
/** Down arrow — how the form moves from one field to the next. */
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 = [
{ expect: 'ALPHA', send: 'alpha-typed', settle: 0.6 },
{ expect: 'First value', send: 'alpha-typed', settle: 0.6 },
{ send: DOWN, settle: 0.4 },
{ send: 'beta-typed', settle: 0.4 },
{ send: '\r', settle: 1.2 },
+28
View File
@@ -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 () => {
const svc = cube('svc', 'Service', schema);
const variables = new Variables({ port: 2222 });