Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
"@anthropic-ai/sdk": "^0.90.0"
"@anthropic-ai/sdk": "^0.90.0",
"yaml": "^2.8.4"
}
}
155 changes: 155 additions & 0 deletions src/agents/contract-extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";

export type Endpoint = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
};

export type RailsContract = {
openapiVersion: string;
title: string;
endpoints: Endpoint[];
schemaCount: number;
};

const HTTP_METHODS: ReadonlySet<string> = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);

// Rails: parse out/<slug>/rails/docs/openapi.yaml. Returns null if the file
// is missing or doesn't have the canonical OpenAPI 3.x shape.
export async function extractRailsContract(railsDir: string): Promise<RailsContract | null> {
const specPath = join(railsDir, "docs", "openapi.yaml");
let raw: string;
try {
raw = await readFile(specPath, "utf8");
} catch {
return null;
}
let doc: unknown;
try {
doc = parseYaml(raw);
} catch {
return null;
}
if (!isRecord(doc)) return null;
const openapiVersion = typeof doc["openapi"] === "string" ? doc["openapi"] : null;
if (!openapiVersion) return null;

const info = isRecord(doc["info"]) ? doc["info"] : {};
const title = typeof info["title"] === "string" ? info["title"] : "";

const paths = isRecord(doc["paths"]) ? doc["paths"] : {};
const endpoints: Endpoint[] = [];
for (const [path, value] of Object.entries(paths)) {
if (!isRecord(value)) continue;
for (const verb of Object.keys(value)) {
const upper = verb.toUpperCase();
if (HTTP_METHODS.has(upper)) {
endpoints.push({ method: upper as Endpoint["method"], path });
}
}
}

const components = isRecord(doc["components"]) ? doc["components"] : {};
const schemas = isRecord(components["schemas"]) ? components["schemas"] : {};
const schemaCount = Object.keys(schemas).length;

return { openapiVersion, title, endpoints, schemaCount };
}

// Android: walk app/src/main/kotlin/**/data/**/*Api.kt, extract Retrofit
// annotations (@GET("path"), @POST("path"), etc.). The substrate's auth
// data layer also lives under data/login/, so capture all *Api.kt under
// data/.
export async function extractAndroidEndpoints(androidDir: string): Promise<Endpoint[]> {
const dataDir = join(androidDir, "app", "src", "main", "kotlin");
const apiFiles = await collectFiles(dataDir, (path) => /\/data\/.*Api\.kt$/.test(path));

const endpoints: Endpoint[] = [];
const re = /@(GET|POST|PUT|PATCH|DELETE)\(\s*"([^"]+)"\s*\)/g;
for (const file of apiFiles) {
let raw: string;
try {
raw = await readFile(file, "utf8");
} catch {
continue;
}
let match;
while ((match = re.exec(raw)) !== null) {
endpoints.push({ method: match[1] as Endpoint["method"], path: match[2]! });
}
}
return endpoints;
}

// iOS: walk **/Networking/**/*Request.swift and Login/*Request.swift, where
// each struct has matched `var method: HTTPMethod { .METHOD }` and
// `var path: String { "..." }` getters. Pair them by struct order.
export async function extractIosEndpoints(iosDir: string): Promise<Endpoint[]> {
const appDir = await findAppRoot(iosDir);
if (!appDir) return [];
const requestFiles = await collectFiles(appDir, (path) => /Request\.swift$/.test(path));

const endpoints: Endpoint[] = [];
for (const file of requestFiles) {
let raw: string;
try {
raw = await readFile(file, "utf8");
} catch {
continue;
}
endpoints.push(...parseSwiftRequests(raw));
}
return endpoints;
}

function parseSwiftRequests(raw: string): Endpoint[] {
// Split on `struct ... {` boundaries; within each chunk, find the first
// `.METHOD` enum reference and the first `"..."` string literal that follows
// a `var path` declaration. Crude but robust for the substrate's layout.
const structs = raw.split(/\nstruct\s+\w+\b/).slice(1);
const out: Endpoint[] = [];
for (const chunk of structs) {
const methodMatch = chunk.match(/var\s+method:\s*HTTPMethod\s*\{\s*\.(GET|POST|PUT|PATCH|DELETE)\s*\}/);
const pathMatch = chunk.match(/var\s+path:\s*String\s*\{\s*"([^"]+)"\s*\}/);
if (methodMatch && pathMatch) {
out.push({ method: methodMatch[1] as Endpoint["method"], path: pathMatch[1]! });
}
}
return out;
}

async function findAppRoot(iosDir: string): Promise<string | null> {
const entries = await readdir(iosDir, { withFileTypes: true }).catch(() => []);
for (const e of entries) {
if (e.isDirectory() && !e.name.endsWith(".xcodeproj") && !e.name.endsWith("Tests") && !e.name.startsWith(".")) {
const candidate = join(iosDir, e.name);
const sub = await readdir(candidate).catch(() => [] as string[]);
if (sub.some((n) => n === "Networking" || n === "Login")) return candidate;
}
}
return null;
}

async function collectFiles(root: string, predicate: (path: string) => boolean): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === ".git" || e.name === "build" || e.name === ".gradle" || e.name === "Pods") continue;
await walk(full);
} else if (predicate(full)) {
out.push(full);
}
}
}
await walk(root);
return out;
}

function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
126 changes: 47 additions & 79 deletions src/agents/reviewer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { readFile } from "node:fs/promises";
import { resolve, join } from "node:path";
import { resolve } from "node:path";
import { trace } from "../trace.js";
import { isStub } from "../stub.js";
import {
extractRailsContract,
extractAndroidEndpoints,
extractIosEndpoints,
} from "./contract-extract.js";
import type { DomainSpec, ReviewerResult, WorkerResult } from "./types.js";

export type ReviewerInput = {
Expand All @@ -11,100 +15,64 @@ export type ReviewerInput = {
android: WorkerResult;
};

const OPENAPI_PATH = "docs/openapi.yaml";

// Phase 1: extract Rails OpenAPI surface. Confirms the rename pipeline left
// the spec parseable (right top-level shape, nontrivial path + schema counts)
// and surfaces structure metadata so downstream phases can diff against
// iOS networking + Android repository layers.
// Phase 2: extract API surface from all three platforms — Rails OpenAPI,
// Android Retrofit interfaces, iOS Request structs — and surface counts in
// the trace + result. This proves rename left every platform's network
// layer parseable and provides the contract objects Phase 3 will use to
// detect actual drift (paths in Rails but missing from a client, methods
// that disagree, etc.).
//
// Not yet implemented: parsing iOS Swift / Android Kotlin client code to
// extract their per-endpoint shapes, then three-way diffing. Those land in
// Phase 2+ when we add a real YAML parser dependency and AST-level
// extraction for Swift / Kotlin sources.
// For now reviewer stays at contractParity: "pass" unless extraction itself
// fails — count mismatches alone don't fail the run, since iOS / Android
// may legitimately implement a subset of Rails endpoints (e.g. the Rails
// admin namespace isn't called from mobile clients). Phase 3 wires the
// pass/fail logic against a normalized path-level diff.
export async function runReviewer(input: ReviewerInput): Promise<ReviewerResult> {
if (isStub("reviewer")) return runStubReviewer(input);

const { domain, rails } = input;
const { domain, rails, ios, android } = input;
const railsDir = resolve(process.cwd(), rails.outDir);
const specPath = join(railsDir, OPENAPI_PATH);

trace("reviewer", `extracting OpenAPI from ${rails.outDir}/${OPENAPI_PATH}`);

let raw: string;
try {
raw = await readFile(specPath, "utf8");
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
trace("reviewer", `${domain.displayName}: contract parity FAIL — ${reason}`);
return { contractParity: "fail", diffs: [`OpenAPI spec not readable: ${reason}`] };
}

const surface = extractRailsSurface(raw);
if (surface === null) {
trace("reviewer", `${domain.displayName}: contract parity FAIL — spec did not parse as OpenAPI`);
return { contractParity: "fail", diffs: ["OpenAPI spec missing top-level openapi/info/paths sections"] };
const iosDir = resolve(process.cwd(), ios.outDir);
const androidDir = resolve(process.cwd(), android.outDir);

trace("reviewer", `extracting Rails OpenAPI from ${rails.outDir}`);
const railsContract = await extractRailsContract(railsDir);
if (!railsContract) {
trace("reviewer", `${domain.displayName}: contract parity FAIL — Rails OpenAPI unreadable`);
return {
contractParity: "fail",
diffs: ["rails: OpenAPI spec missing or did not parse as OpenAPI 3.x"],
};
}

trace(
"reviewer",
`Rails surface: ${surface.pathCount} paths, ${surface.schemaCount} schemas, openapi=${surface.openapiVersion}, title="${surface.title}"`,
`Rails: ${railsContract.endpoints.length} endpoints, ${railsContract.schemaCount} schemas, openapi=${railsContract.openapiVersion}, title="${railsContract.title}"`,
);
trace("reviewer", "iOS / Android contract diff — not yet implemented (Phase 2+)");
trace("reviewer", `${domain.displayName}: contract parity PASS (Rails-only Phase 1)`);

trace("reviewer", `extracting iOS Request structs from ${ios.outDir}`);
const iosEndpoints = await extractIosEndpoints(iosDir);
trace("reviewer", `iOS: ${iosEndpoints.length} request endpoints`);

trace("reviewer", `extracting Android Retrofit annotations from ${android.outDir}`);
const androidEndpoints = await extractAndroidEndpoints(androidDir);
trace("reviewer", `Android: ${androidEndpoints.length} Retrofit endpoints`);

trace("reviewer", "three-way diff — not yet implemented (Phase 3+)");
trace("reviewer", `${domain.displayName}: contract parity PASS (Phase 2 extraction-only)`);

return {
contractParity: "pass",
diffs: [
`rails:openapi=${surface.openapiVersion}`,
`rails:title=${surface.title}`,
`rails:paths=${surface.pathCount}`,
`rails:schemas=${surface.schemaCount}`,
`rails:openapi=${railsContract.openapiVersion}`,
`rails:title=${railsContract.title}`,
`rails:endpoints=${railsContract.endpoints.length}`,
`rails:schemas=${railsContract.schemaCount}`,
`ios:endpoints=${iosEndpoints.length}`,
`android:endpoints=${androidEndpoints.length}`,
],
};
}

type RailsSurface = {
openapiVersion: string;
title: string;
pathCount: number;
schemaCount: number;
};

// Lightweight regex extraction — avoids pulling in a YAML parser for Phase 1.
// Works against the well-formed OpenAPI 3.x YAML the substrate ships and
// fails gracefully (returns null) for anything missing the canonical shape.
// Replace with a real parser once Phase 2+ needs deeper structure.
function extractRailsSurface(raw: string): RailsSurface | null {
const openapi = raw.match(/^openapi:\s*['"]?([^'"\s]+)['"]?\s*$/m);
if (!openapi || !openapi[1]) return null;

const titleMatch = raw.match(/^\s+title:\s*(.+?)\s*$/m);
const title = (titleMatch?.[1] ?? "").trim();

// Top-level `paths:` block; each child is ` /something:` indented 2 spaces.
const pathsIdx = raw.indexOf("\npaths:");
let pathCount = 0;
if (pathsIdx >= 0) {
const afterPaths = raw.slice(pathsIdx + 1);
const nextTopLevel = afterPaths.search(/\n[a-zA-Z]/);
const pathsBlock = nextTopLevel === -1 ? afterPaths : afterPaths.slice(0, nextTopLevel + 1);
pathCount = (pathsBlock.match(/^ {2}\/[^\n]+:\s*$/gm) ?? []).length;
}

// schemas: under components: — children indented 4 spaces, names start uppercase.
const schemasIdx = raw.indexOf("\n schemas:");
let schemaCount = 0;
if (schemasIdx >= 0) {
const afterSchemas = raw.slice(schemasIdx + 1);
const nextSiblingTop = afterSchemas.search(/\n {0,2}[a-zA-Z]/m);
const schemasBlock = nextSiblingTop === -1 ? afterSchemas : afterSchemas.slice(0, nextSiblingTop + 1);
schemaCount = (schemasBlock.match(/^ {4}[A-Z][A-Za-z0-9_]*:\s*$/gm) ?? []).length;
}

return { openapiVersion: openapi[1], title, pathCount, schemaCount };
}

const delay = (ms: number): Promise<void> => new Promise((r) => { setTimeout(r, ms); });

async function runStubReviewer(input: ReviewerInput): Promise<ReviewerResult> {
Expand Down
Loading