Swadhin Biswas Feature/ai code reviewer (#79) · 8/16/2026
cfd400d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
/**
* OpenAPI bulk generator (WS5-01)
*
* Generates generic-but-valid spec path entries for route files that are
* still undocumented, and appends them to src/lib/openapi.ts as an
* `autoGeneratedPaths` block merged into `paths`.
*
* Hand-tuned entries (auth, pulls, issues core flows) always win: this
* script only fills gaps.
*
* Usage: bun scripts/openapi-generate.ts
*/
import { readdirSync, statSync, readFileSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { openApiSpec } from "@/lib/openapi";
const rootDir = join(import.meta.dirname, "..");
const API_ROOT = join(rootDir, "src", "pages", "api");
const OPENAPI_FILE = join(rootDir, "src", "lib", "openapi.ts");
function collectRouteFiles(dir: string): string[] {
const files: string[] = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop()!;
for (const entry of readdirSync(current)) {
const full = join(current, entry);
const stats = statSync(full);
if (stats.isDirectory()) stack.push(full);
else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts")) files.push(full);
}
}
return files;
}
function routeToSpecPath(routeFile: string): string {
const relativePath = relative(API_ROOT, routeFile).replace(/\.ts$/, "");
const segments = relativePath.split("/");
let path = "/" + segments.map((s) => s.replace(/\[([^\]]+)\]/g, "{$1}")).join("/");
if (path.endsWith("/index")) path = path.slice(0, -"/index".length);
if (path === "") path = "/";
return path;
}
function detectMethods(routeFile: string): string[] {
const content = readFileSync(routeFile, "utf8");
const methods: string[] = [];
for (const m of ["GET", "POST", "PATCH", "PUT", "DELETE"]) {
// Match "export const GET: APIRoute" or "export const GET =" or "export const GET:"
const re = new RegExp(`export\\s+const\\s+${m}\\s*:?\\s*(APIRoute)?\\s*=`);
if (re.test(content)) methods.push(m.toLowerCase());
}
if (methods.length === 0) methods.push("get"); // default
return methods;
}
function extractParams(specPath: string): Array<{ name: string; in: "path" }> {
const matches = [...specPath.matchAll(/\{([^}]+)\}/g)];
return matches.map((m) => ({ name: m[1], in: "path" as const }));
}
function tagFor(path: string): string {
const seg = path.split("/").filter(Boolean)[0] || "API";
return seg.charAt(0).toUpperCase() + seg.slice(1);
}
const routeFiles = collectRouteFiles(API_ROOT);
const currentPaths = openApiSpec.paths ?? {};
// Hand-tuned paths = spec keys that are NOT in the current auto-generated
// block. Parse the block's JSON from the file text so the generator can
// regenerate the FULL auto set (not just the delta) idempotently.
function currentAutoKeys(): Set<string> {
try {
const text = readFileSync(OPENAPI_FILE, "utf8");
const decl = text.indexOf("const autoGeneratedPaths = {");
if (decl === -1) return new Set();
const braceStart = text.indexOf("{", decl);
// brace-count to the matching close — robust against any `};` noise
let depth = 0;
let end = -1;
for (let i = braceStart; i < text.length; i++) {
if (text[i] === "{") depth++;
else if (text[i] === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end === -1) return new Set();
const parsed = JSON.parse(text.slice(braceStart, end + 1));
return new Set(Object.keys(parsed));
} catch {
return new Set();
}
}
const autoKeys = currentAutoKeys();
const handTuned = new Set(
Object.keys(currentPaths).filter((p) => !autoKeys.has(p)),
);
const generated: Record<string, any> = {};
for (const file of routeFiles) {
const specPath = routeToSpecPath(file);
if (handTuned.has(specPath)) continue; // hand-tuned — keep richer entries
const params = extractParams(specPath);
const methods = detectMethods(file);
const ops: Record<string, any> = {};
for (const method of methods) {
ops[method] = {
tags: [tagFor(specPath)],
summary: `${method.toUpperCase()} ${specPath}`,
description: "Auto-generated OpenAPI entry for this route.",
security: [{ bearerAuth: [] }],
parameters: params,
responses: {
200: { description: "Success" },
400: { description: "Invalid request", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
};
}
generated[specPath] = ops;
}
if (Object.keys(generated).length === 0) {
console.log("No missing routes — nothing to generate.");
process.exit(0);
}
// Render as a TS block: `const autoGeneratedPaths = {...};` merged into paths
const snippet = `
// ─────────────────────────────────────────────────────────────
// AUTO-GENERATED path entries (scripts/openapi-generate.ts)
// Generic documentation for routes without hand-tuned entries.
// Re-run the generator after adding routes; refine entries here.
// ─────────────────────────────────────────────────────────────
const autoGeneratedPaths = ${JSON.stringify(generated, null, 4)};`;
let content = readFileSync(OPENAPI_FILE, "utf8");
// Remove any previous auto-generated block to keep the generator
// idempotent. The block starts at the marker comment and ends at the
// first `};` after `const autoGeneratedPaths = {`.
function stripAutoGenerated(text: string): string {
const marker = "// AUTO-GENERATED path entries (scripts/openapi-generate.ts)";
const start = text.indexOf(marker);
if (start === -1) return text;
// back up to the dashes line above the marker (it starts the block)
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
let blockStart = lineStart;
const above = text.slice(0, lineStart).trimEnd();
const dashLine = above.lastIndexOf("\n// ─");
if (dashLine !== -1) {
blockStart = dashLine + 1;
} else if (/\/\/ ─+$/.test(above)) {
// dashes line is the very first line of the file
blockStart = 0;
}
// brace-count to the declaration's matching close (robust against `};` noise)
const declIdx = text.indexOf("const autoGeneratedPaths = {", start);
if (declIdx === -1) return text;
const braceStart = text.indexOf("{", declIdx);
let depth = 0;
let closeIdx = -1;
for (let i = braceStart; i < text.length; i++) {
if (text[i] === "{") depth++;
else if (text[i] === "}") {
depth--;
if (depth === 0) {
closeIdx = i;
break;
}
}
}
if (closeIdx === -1) return text;
let end = closeIdx + 1;
if (text.slice(end, end + 1) === ";") end += 1;
let result = text.slice(0, blockStart) + text.slice(end);
// clean any leading dashes/blank remnants from previous runs
result = result.replace(/^(?:\s*\n)*\/\/ ─+[^\n]*\n(?:\s*\n)*/, "");
result = result.replace(/^\s*\n+/, "");
return result;
}
content = stripAutoGenerated(content);
// Insert the block BEFORE `export const openApiSpec` so the spread in
// `paths: { ...autoGeneratedPaths,` resolves without TDZ errors
content = content.replace(
/(export const openApiSpec = \{)/,
`${snippet.replace(/^\n/, "")}\n\n$1`,
);
// Merge into the paths object
if (!content.includes("...autoGeneratedPaths,")) {
content = content.replace(
"paths: {",
"paths: {\n ...autoGeneratedPaths,",
);
}
writeFileSync(OPENAPI_FILE, content);
console.log(`Generated ${Object.keys(generated).length} spec entries into ${relative(rootDir, OPENAPI_FILE)}`);