Build a TypeScript pull-request reviewer that combines Git diffs, TypeScript Compiler API diagnostics, local AST rules, and OpenAI structured output. The result is a small CLI that can run locally or in CI while keeping compiler failures separate from contextual AI feedback.
What You Will Build
TypeScript is valuable because it makes contracts visible before code runs. A pull request can weaken those contracts without producing an immediate compiler error: a new any annotation can erase checking at a boundary, an assertion through unknown can force incompatible values into a trusted type, and a diagnostic suppression can hide a real mismatch. These patterns are not always defects, but they deserve deliberate review.
This tutorial builds type-guardian, a command-line reviewer for the current Git branch. It follows a layered approach:
- The Git diff defines the pull-request scope.
- TypeScript AST rules identify narrow, deterministic policy patterns.
- The TypeScript Compiler API collects pre-emit diagnostics from
tsconfig.json. - OpenAI supplies contextual review findings in validated structured JSON.
The compiler remains authoritative for TypeScript errors. Local rules remain authoritative for policies such as reporting @ts-ignore. The model is useful for explaining a potentially unsafe boundary or spotting context that a narrow syntax rule cannot establish. It should not silently change code, bypass the compiler, or become the only merge gate.
This division is particularly useful for engineering teams in the GCC and Middle East that are scaling AI-assisted software delivery alongside governance requirements. Organizations contributing to initiatives such as Saudi Vision 2030 or the UAE National Strategy for Artificial Intelligence can apply the same pattern: enforce deterministic engineering controls locally, then enable external contextual review only after deciding what source material is permitted to leave the development environment.
Prerequisites and Project Setup
You need a TypeScript repository with Git and a tsconfig.json, plus an OpenAI API key if you intend to run the AI phase. The code uses ECMAScript modules and the current OpenAI JavaScript SDK direction: the Responses API. The TypeScript Compiler API is a suitable foundation for source parsing and diagnostics; it is also used in published technical work to parse TypeScript declaration files and model type information.
mkdir type-guardian
cd type-guardian
npm init -y
npm install openai dotenv zod
npm install --save-dev typescript tsx vitest @types/node
npm pkg set type=module
npm pkg set scripts.build="tsc -p tsconfig.json"
npm pkg set scripts.review="tsx src/index.ts --base origin/main"
npm pkg set scripts.test="vitest run"
mkdir src testCreate .env locally. Do not commit it. In CI, inject the key using the CI platform’s secret mechanism. A diff can contain credentials, customer identifiers, generated data, or internal implementation details, so this tutorial deliberately limits the material included in the external request.
OPENAI_API_KEY=your-api-key
OPENAI_MODEL=gpt-5.6
TYPE_GUARDIAN_MAX_DIFF_CHARS=24000
TYPE_GUARDIAN_MAX_FILES=30
TYPE_GUARDIAN_FAIL_ON=highnode_modules/
dist/
.env
.env.*
coverage/The model name is configurable because availability and organizational approval vary. Check the current OpenAI model guidance before selecting a production model. Use --no-ai when you want a fully local compiler-and-policy review.
Step 1: Define Strict Compiler Settings and Shared Types
Create tsconfig.json. The strict settings are intentional: a tool that reports unsafe assumptions should itself make optional values and unknown errors explicit.
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}Now create src/types.ts. These types are the contract shared by local analysis, AI analysis, terminal output, and future CI reporting.
export type Severity = "low" | "medium" | "high" | "critical";
export type FindingCategory =
| "explicit-any"
| "unsafe-type-assertion"
| "typescript-suppression"
| "compiler-error"
| "ai-review";
export interface SourceLocation {
file: string;
line: number;
column: number;
}
export interface Finding {
id: string;
severity: Severity;
category: FindingCategory;
title: string;
explanation: string;
recommendation: string;
evidence: string;
location: SourceLocation;
confidence: number;
}
export interface ChangedFile {
path: string;
patch: string;
}
export interface ReviewOptions {
baseRef: string;
maxDiffChars: number;
maxFiles: number;
includeAiReview: boolean;
}
export interface ReviewReport {
generatedAt: string;
baseRef: string;
changedFiles: number;
compilerDiagnostics: number;
aiReviewIncluded: boolean;
findings: Finding[];
}Locations are one-based because that is the convention developers see in terminals and code-hosting interfaces. The Compiler API uses positions that must be converted at the integration boundary. Confidence is a number from zero to one: deterministic syntax matches can be assigned high confidence, while AI findings remain evidence for a reviewer to assess.
Step 2: Read the Diff and Run Deterministic Checks
Create src/analyze.ts. This file obtains changed TypeScript files from the merge-base comparison, visits the current working-tree source with the TypeScript parser, and obtains compiler diagnostics from the repository configuration. The triple-dot range, base...HEAD, is appropriate for the common pull-request comparison against the merge base.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import type {
ChangedFile,
Finding,
FindingCategory,
ReviewOptions,
Severity,
} from "./types.js";function runGit(args: string[]): string {
try {
return execFileSync("git", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Git command failed: git ${args.join(" ")}: ${message}`);
}
}function finding(
category: FindingCategory,
severity: Severity,
file: string,
line: number,
title: string,
explanation: string,
recommendation: string,
evidence: string,
confidence: number,
): Finding {
return {
id: `${category}:${file}:${line}:${title}`,
category,
severity,
title,
explanation,
recommendation,
evidence: evidence.trim().slice(0, 500),
location: { file, line, column: 1 },
confidence,
};
}export function getChangedFiles(options: ReviewOptions): ChangedFile[] {
const paths = runGit([
"diff", "--name-only", "--diff-filter=ACMR",
`${options.baseRef}...HEAD`, "--", "*.ts", "*.tsx",
])
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean)
.slice(0, options.maxFiles);return paths.map((file) => ({
path: file,
patch: runGit(["diff", "--unified=3", `${options.baseRef}...HEAD`, "--", file]),
}));
}export function findLocalPolicyViolations(files: ChangedFile[]): Finding[] {
const results: Finding[] = [];for (const file of files) {
if (!existsSync(file.path)) continue;
const text = readFileSync(file.path, "utf8");
const lines = text.split(/\r?\n/);
const source = ts.createSourceFile(file.path, text, ts.ScriptTarget.Latest, true);const visit = (node: ts.Node): void => {
const position = source.getLineAndCharacterOfPosition(node.getStart(source));
const line = position.line + 1;
const evidence = lines[position.line] ?? "";if (node.kind === ts.SyntaxKind.AnyKeyword) {
results.push(finding(
"explicit-any", "medium", file.path, line,
"Explicit any weakens a type boundary",
"The any type disables static checking for values flowing through this declaration.",
"Use unknown with runtime validation, or define the smallest accurate type.",
evidence, 0.95,
));
}if (ts.isAsExpression(node) && ts.isAsExpression(node.expression)
...Continue Reading
Log in for free to read the rest of this article and access exclusive AI tools.
Log in / Register