Edgenivo Logo

Generate Structured Reviews

Same model. Same code under review. New output shape.

In 3.1 we got back a paragraph. In this lesson we get back a typed object with severity levels, file paths, and concrete recommendations. We're still calling generateText. The only thing that changed is what we handed it.

Outcome

Add analyzeRepository(files) to src/analyze.ts. It calls generateText with an Output.object built from the reviewSchema in 3.2, takes an array of { path, content } files, and returns a typed Review.

Fast Track

  1. Import Output from ai.
  2. Write analyzeRepository(files) that calls generateText with reviewer instructions, the files as the prompt, and output: Output.object({ schema: reviewSchema }).
  3. Return result.output (typed as Review).

Hands-on exercise

Open src/analyze.ts and add the new function. The schemas and analyzeWithPromptV1 stay where they are:

import { generateText, Output } from 'ai';
import { z } from 'zod';
 
export const findingSchema = z.object({
  severity: z.enum(['low', 'medium', 'high', 'critical']),
  category: z.enum(['security', 'quality', 'performance', 'reliability']),
  file: z.string(),
  summary: z.string(),
  recommendation: z.string()
});
 
export const reviewSchema = z.object({
  overallRisk: z.enum(['low', 'medium', 'high']),
  findings: z.array(findingSchema)
});
 
export type Finding = z.infer<typeof findingSchema>;
export type Review = z.infer<typeof reviewSchema>;
 
export async function analyzeWithPromptV1(source: string): Promise<string> {
  const result = await generateText({
    model: 'openai/gpt-5.3-codex',
    prompt: `Review this code and tell me what is wrong:\n\n${source}`
  });
 
  return result.text;
}
 
export async function analyzeRepository(
  files: Array<{ path: string; content: string }>
): Promise<Review> {
  const result = await generateText({
    model: 'openai/gpt-5.3-codex',
    instructions: [
      'You are a senior application security and code quality reviewer.',
      'Return only findings that are directly supported by the provided source.',
      'Prefer precise, actionable recommendations over generic advice.',
      'If there are no findings, return an empty findings array.'
    ].join('\n'),
    output: Output.object({ schema: reviewSchema }),
    prompt: files.map((f) => `FILE: ${f.path}\n${f.content}`).join('\n')
  });
 
  return result.output;
}

Two things to point out.

The instructions are doing work the schema can't. The schema enforces the shape of the output, but it can't tell the model "be specific" or "don't make things up." That's what instructions is for, and it stays separate from the repository files we pass as prompt. We're telling the model who it is, what to return, and what not to return. Those three sentences raise the quality of findings by a lot.

The file format inside the prompt (FILE: path\ncontent\n) is intentionally plain. We're not using JSON or YAML or anything fancy. Models are good at reading "FILE: x" headers because they look like a lot of the training data.

To verify, replace the temporary main() test caller at the bottom of src/analyze.ts with one that calls the new function:

async function main() {
  const files = [
    {
      path: 'src/auth.ts',
      content: `
        export function login(user: string, password: string) {
          if (password === 'admin') return true;
          return false;
        }
      `
    }
  ];
 
  const review = await analyzeRepository(files);
  console.log(JSON.stringify(review, null, 2));
}
 
main();
Troubleshooting: response did not match schema

If generateText throws "No object generated: response did not match schema", the model returned something that didn't match the shape. Usually this means tightening the prompt ("only use the listed categories") or loosening the schema (allow more enum values).

AI SDK version note

This course targets AI SDK 7. Older examples use generateObject and read result.object; AI SDK 7 marks generateObject deprecated in favor of generateText with an output setting, which returns the parsed result on result.output. If you're porting older code, follow the migration guide instead of mixing examples across versions.

Repository text is untrusted model input

A source file can contain prompt-injection text aimed at the reviewer. Treat model findings as claims to verify, never as instructions to execute, and keep repository content in prompt, out of instructions, and away from credentials.

Troubleshooting: empty findings array

An empty array is a valid response. If the code you're reviewing actually has no issues, findings: [] is what we want. Don't read it as a bug.

Try It

pnpm tsx --env-file=.env src/analyze.ts

Expected output (specific findings will vary, but the shape is fixed):

{
  "overallRisk": "high",
  "findings": [
    {
      "severity": "critical",
      "category": "security",
      "file": "src/auth.ts",
      "summary": "Hardcoded admin password in login function",
      "recommendation": "Replace the hardcoded check with a lookup against a securely hashed password store (bcrypt or argon2) and load the comparison value from environment configuration."
    },
    {
      "severity": "high",
      "category": "quality",
      "file": "src/auth.ts",
      "summary": "Function returns boolean instead of a typed user record",
      "recommendation": "Return a discriminated union like { ok: true, user } | { ok: false, reason } so callers can react to specific failure modes."
    }
  ]
}

Put that next to the prose blob from 3.1 and the difference is obvious. Each finding has a severity you can sort by, a file you can jump to, a recommendation specific enough to act on. The schema did that.

Commit

git add src/analyze.ts
git commit -m "feat(analyze): generate structured reviews with Output.object"

Done-When

  • analyzeRepository(files) is exported from src/analyze.ts
  • It calls generateText with output: Output.object({ schema: reviewSchema })
  • Output is a typed Review object
  • Running against a known-bad file produces concrete, file-anchored findings

Solution

src/analyze.ts
import { generateText, Output } from 'ai';
import { z } from 'zod';
 
export const findingSchema = z.object({
  severity: z.enum(['low', 'medium', 'high', 'critical']),
  category: z.enum(['security', 'quality', 'performance', 'reliability']),
  file: z.string(),
  summary: z.string(),
  recommendation: z.string()
});
 
export const reviewSchema = z.object({
  overallRisk: z.enum(['low', 'medium', 'high']),
  findings: z.array(findingSchema)
});
 
export type Finding = z.infer<typeof findingSchema>;
export type Review = z.infer<typeof reviewSchema>;
 
export async function analyzeWithPromptV1(source: string): Promise<string> {
  const result = await generateText({
    model: 'openai/gpt-5.3-codex',
    prompt: `Review this code and tell me what is wrong:\n\n${source}`
  });
 
  return result.text;
}
 
export async function analyzeRepository(
  files: Array<{ path: string; content: string }>
): Promise<Review> {
  const result = await generateText({
    model: 'openai/gpt-5.3-codex',
    instructions: [
      'You are a senior application security and code quality reviewer.',
      'Return only findings that are directly supported by the provided source.',
      'Prefer precise, actionable recommendations over generic advice.',
      'If there are no findings, return an empty findings array.'
    ].join('\n'),
    output: Output.object({ schema: reviewSchema }),
    prompt: files.map((f) => `FILE: ${f.path}\n${f.content}`).join('\n')
  });
 
  return result.output;
}

Was this helpful?

supported.