diff --git a/apps/cli/src/serve.test.ts b/apps/cli/src/serve.test.ts index 208659d..f2a2866 100644 --- a/apps/cli/src/serve.test.ts +++ b/apps/cli/src/serve.test.ts @@ -649,6 +649,23 @@ describe('generation through a live server', () => { expect(code).toContain('name') }, 30_000) + // Plan 6 audit, finding 8: this used to be a 200 with `code: ""`, and the + // panel's Copy types put that empty string on the clipboard. + it('400s types for a `[]` body with a message, rather than printing an empty string', async () => { + writeMocks({ + 'GET /todos': { default: 'ok', responses: { ok: { status: 200, body: [] } } }, + }) + handle = await startServer({ root, config }) + + const res = await get( + `/__laqi/api/endpoints/${encodeURIComponent('GET /todos')}/types?response=ok`, + ) + expect(res.status).toBe(400) + const { message } = (await res.json()) as { message: string } + expect(message).toMatch(/printed no declaration for Todos/) + expect(message).toMatch(/empty array/) + }, 30_000) + it('404s types for an endpoint or response that does not exist', async () => { writeMocks({ 'GET /x': { default: 'ok', responses: { ok: { status: 200 } } } }) handle = await startServer({ root, config }) diff --git a/apps/documentation/src/content/docs/trying-v2.md b/apps/documentation/src/content/docs/trying-v2.md index f480308..a7a0868 100644 --- a/apps/documentation/src/content/docs/trying-v2.md +++ b/apps/documentation/src/content/docs/trying-v2.md @@ -223,7 +223,7 @@ export interface Todo { Click **Create**. The endpoint is written to `laqi/api.json` immediately, with a response body already filled with seeded data generated from that -shape — `title` reads like a real sentence, `createdAt` is a date, `id` is +shape — `title` is a few lorem-ipsum words, `createdAt` is a date, `id` is sequential — and the panel drops you straight into the endpoint's detail view. diff --git a/packages/generate/src/export-types.test.ts b/packages/generate/src/export-types.test.ts index 1b13fff..995b427 100644 --- a/packages/generate/src/export-types.test.ts +++ b/packages/generate/src/export-types.test.ts @@ -51,6 +51,30 @@ describe('exporting a stored schema', () => { it('says which language it does not know, rather than printing the wrong one', async () => { await expect(exportTypes(OBJECT, 'cobol')).rejects.toThrow(/unknown language/) }, 30_000) + + // Plan 6 audit, finding 8, re-verified against the schema path: a stored + // schema whose root is an array of anything has nothing quicktype can + // declare, so every translated target fails with a message rather than + // handing back an empty string. TypeScript is laqi's own printer and has + // no such gap. + describe('a schema whose root is an array of anything', () => { + const ANYTHING_LIST = snapshotOf({ type: 'array', items: {} }, 'Todos') + + it('fails with a message in a translated target instead of exporting nothing', async () => { + await expect(exportTypes(ANYTHING_LIST, 'typescript-zod')).rejects.toThrow( + /printed no declaration for Todos/, + ) + await expect(exportTypes(ANYTHING_LIST, 'python')).rejects.toThrow( + /printed no declaration for Todos/, + ) + }, 30_000) + + it('still exports as TypeScript, which laqi prints itself', async () => { + const exported = await exportTypes(ANYTHING_LIST) + expect(exported.code).toContain('Todos') + expect(exported.code).toContain('unknown[]') + }) + }) }) describe('what the exporter cannot express', () => { diff --git a/packages/generate/src/generate.test.ts b/packages/generate/src/generate.test.ts index 4792793..655ad51 100644 --- a/packages/generate/src/generate.test.ts +++ b/packages/generate/src/generate.test.ts @@ -83,16 +83,23 @@ describe('generate', () => { // so it degraded to plain "ends with id" — paid/valid/void/rapid were all // id-classified. This test failed before the rule-table refactor and // passes after it. + // + // The Plan 6 audit found an earlier version of this test vacuous: it + // asserted `>= 0`, which a sequential id satisfies too. An id-classified + // field is a per-field counter, so across three array items it reads + // exactly 1, 2, 3 — and that is what is asserted NOT to happen, next to + // the real `id` for which it must. it('does not match paid/valid/void/rapid with endsWith("id") — word boundaries only', async () => { + const lookalikes = ['paid', 'valid', 'void', 'rapid', 'identifier'] + for (const name of lookalikes) { + expect(ruleFor(name, 'integer'), `expected "${name}" to be a plain number`).toBe('number') + } + // Test with INTEGER type so the id branch is actually reached - const shape: Shape = { + const item: Shape = { kind: 'object', fields: [ - { name: 'paid', shape: primitive('integer'), optional: false }, - { name: 'valid', shape: primitive('integer'), optional: false }, - { name: 'void', shape: primitive('integer'), optional: false }, - { name: 'rapid', shape: primitive('integer'), optional: false }, - { name: 'identifier', shape: primitive('integer'), optional: false }, + ...lookalikes.map((name) => ({ name, shape: primitive('integer'), optional: false })), { name: 'userId', shape: primitive('integer'), optional: false }, { name: 'user_id', shape: primitive('integer'), optional: false }, { name: 'orderId', shape: primitive('integer'), optional: false }, @@ -100,23 +107,31 @@ describe('generate', () => { { name: '_id', shape: primitive('integer'), optional: false }, ], } - const value = (await generate(shape, { seed: 42 })) as Record - // paid, valid, void, rapid, identifier should NOT be sequential IDs - expect(value.paid).toBeGreaterThanOrEqual(0) - expect(value.valid).toBeGreaterThanOrEqual(0) - expect(value.void).toBeGreaterThanOrEqual(0) - expect(value.rapid).toBeGreaterThanOrEqual(0) - expect(value.identifier).toBeGreaterThanOrEqual(0) - // id and _id should be sequential (1, 2, ...) - expect(value.id).toBe(1) - expect(value._id).toBe(2) + const list = (await generate( + { kind: 'array', items: item }, + { seed: 42, arrayLength: 3 }, + )) as Record[] + const column = (name: string) => list.map((value) => value[name]) + + // id and _id share one counter (same normalized name), so the sequence + // interleaves: 1, 3, 5 for id and 2, 4, 6 for _id. + expect(column('id')).toEqual([1, 3, 5]) + expect(column('_id')).toEqual([2, 4, 6]) + // The lookalikes are plain numbers: not a counter, never 1, 2, 3. + for (const name of lookalikes) { + expect(column(name), `"${name}" was generated as a sequential id`).not.toEqual([1, 2, 3]) + for (const value of column(name)) { + expect(value).toBeGreaterThanOrEqual(0) + expect(value).toBeLessThanOrEqual(1000) + } + } // Foreign keys should be in range [1, 1000] - expect(value.userId).toBeGreaterThanOrEqual(1) - expect(value.userId).toBeLessThanOrEqual(1000) - expect(value.user_id).toBeGreaterThanOrEqual(1) - expect(value.user_id).toBeLessThanOrEqual(1000) - expect(value.orderId).toBeGreaterThanOrEqual(1) - expect(value.orderId).toBeLessThanOrEqual(1000) + for (const name of ['userId', 'user_id', 'orderId']) { + for (const value of column(name)) { + expect(value).toBeGreaterThanOrEqual(1) + expect(value).toBeLessThanOrEqual(1000) + } + } }) it('combined fixture with paid, valid, emailVerifiedAt, filename, username, id, userId, orderId', async () => { diff --git a/packages/generate/src/print-types.test.ts b/packages/generate/src/print-types.test.ts index e129424..8b3e6bd 100644 --- a/packages/generate/src/print-types.test.ts +++ b/packages/generate/src/print-types.test.ts @@ -63,6 +63,35 @@ describe('printTypes', () => { expect(code).toMatch(/string/) }) + // Plan 6 audit, finding 8: a `[]` body is `array`, which + // quicktype prints as nothing — no declaration, no error — so Copy types + // put an empty string on the clipboard with a 200. + describe('a root quicktype cannot name', () => { + const emptyBody: Shape = { kind: 'array', items: { kind: 'unknown' } } + + it('fails with a message instead of printing an empty string', async () => { + await expect(printTypes(emptyBody, { typeName: 'Todos' })).rejects.toThrow( + /printed no declaration for Todos.*empty array/s, + ) + }, 30_000) + + it('fails the same way where quicktype prints only an import line', async () => { + await expect( + printTypes(emptyBody, { typeName: 'Todos', lang: 'typescript-zod' }), + ).rejects.toThrow(/printed no declaration for Todos/) + }, 30_000) + + it('still prints an empty array nested inside an object', async () => { + const shape: Shape = { + kind: 'object', + fields: [{ name: 'items', shape: emptyBody, optional: false }], + } + const { code } = await printTypes(shape, { typeName: 'Todos' }) + expect(code).toContain('Todos') + expect(code).toContain('items') + }, 30_000) + }) + it('smoke-emits every advertised language', async () => { // Deep assertions only for TS and Zod; the rest must at least emit // non-empty code without throwing. diff --git a/packages/generate/src/print-types.ts b/packages/generate/src/print-types.ts index 25d33b4..e1568f4 100644 --- a/packages/generate/src/print-types.ts +++ b/packages/generate/src/print-types.ts @@ -92,9 +92,39 @@ export const printDocumentEffect = ( catch: (e) => new PrintError({ message: String(e) }), }) + // quicktype declares object types and nothing else. Hand it a root that + // is not one — an empty array, an array of bare values, a lone value — + // and most targets print nothing at all, Zod prints a bare import line, + // and none of them errors. A `[]` body is exactly that root, so without + // this check the panel's Copy types put an empty string on the clipboard + // with a 200. + if (!declares(code, options.typeName)) { + return yield* Effect.fail( + new PrintError({ + message: + `${lang} printed no declaration for ${options.typeName}: this target only names ` + + 'object types, and the root of this schema is not one (an empty array, an array ' + + 'of bare values, or a lone value — typically a body that is `[]`). Give the body ' + + 'an object to describe, or build a schema from a model, and export again.', + }), + ) + } + return { code, language: lang } }) +/** + * Whether printed code names the type at all. Folded to lowercase + * alphanumerics on both sides, because a target may render `TodoItem` as + * `todo_item` or `TodoItemElement` and still have declared it; what no + * target does is declare a type without its name appearing somewhere. + */ +function declares(code: string, typeName: string): boolean { + const fold = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, '') + const name = fold(typeName) + return name.length === 0 || fold(code).includes(name) +} + /** A Shape prints as the document it maps to. */ export const printTypesEffect = ( shape: Shape,