From 09554b6785b34a28a564b81ce7059d69379e9aae Mon Sep 17 00:00:00 2001 From: Benjamin Diedrichsen Date: Tue, 1 Sep 2026 12:48:16 +0200 Subject: [PATCH] [fix] nopy: find the prompt label through zod's wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01DCzYTAm9QUhvLNr2EpdagJ --- packages/nopy/src/nopy.prompts.ts | 47 ++++++++++++++++++++++--- packages/nopy/tests/prompts.pty.test.ts | 6 +++- packages/nopy/tests/prompts.test.ts | 28 +++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/nopy/src/nopy.prompts.ts b/packages/nopy/src/nopy.prompts.ts index a15b226..200b3ac 100644 --- a/packages/nopy/src/nopy.prompts.ts +++ b/packages/nopy/src/nopy.prompts.ts @@ -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( 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', diff --git a/packages/nopy/tests/prompts.pty.test.ts b/packages/nopy/tests/prompts.pty.test.ts index 7ab5a60..dc38b60 100644 --- a/packages/nopy/tests/prompts.pty.test.ts +++ b/packages/nopy/tests/prompts.pty.test.ts @@ -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 }, diff --git a/packages/nopy/tests/prompts.test.ts b/packages/nopy/tests/prompts.test.ts index 4cc87a3..3760f91 100644 --- a/packages/nopy/tests/prompts.test.ts +++ b/packages/nopy/tests/prompts.test.ts @@ -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 });