From c2e14cb5c6529a8a03c0fc81af9c67c08e9f851a Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 3 May 2026 08:19:08 +0900 Subject: [PATCH] Reviewer Phase 2: three-platform endpoint extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends reviewer beyond Phase 1's Rails-only OpenAPI surface (#46) to extract API endpoints from all three platforms. Phase 3 will diff them; this PR proves rename left every platform's network layer parseable and produces the contract objects. New module: src/agents/contract-extract.ts extractRailsContract(railsDir) - Parse out//rails/docs/openapi.yaml via the new `yaml` dep - Return {openapiVersion, title, endpoints, schemaCount} - Endpoint = {method: GET|POST|PUT|PATCH|DELETE, path: string} extractAndroidEndpoints(androidDir) - Walk app/src/main/kotlin/**/data/**/*Api.kt - Regex @METHOD("path") Retrofit annotations - Return Endpoint[] extractIosEndpoints(iosDir) - Walk app's *Request.swift files (under Networking/, Login/, etc.) - Split each file on `struct ... {` boundaries - Within each struct, pair `var method: HTTPMethod { .METHOD }` with `var path: String { "..." }` - Return Endpoint[] Reviewer surfaces counts in trace + diffs[]: rails:openapi=3.1.0 rails:title=VetClinicQueue API rails:endpoints=42 rails:schemas=35 ios:endpoints=24 android:endpoints=23 Pass/fail policy stays "PASS unless extraction fails" for now — count mismatches don't fail the run because iOS/Android legitimately implement a Rails subset (admin endpoints, server-only namespaces). Phase 3 adds path-normalized drift detection with proper coupling to overallPass. Adds `yaml` (^2.8.4) as a runtime dep — first non-Anthropic-SDK runtime dep in the agent. Standard, no transitive deps, MIT. Tests: 17/17 npm run ci green. Real-mode smoke against out/vet-clinic-queue/ confirmed all three extractors land sensible numbers. Out of scope (Phase 3+): - Path normalization (strip {accountId} prefix, normalize {*} placeholders, handle Rails server base url) - Three-way diff (rails-only / ios-only / android-only / mismatch) - Wire reviewer FAIL into JudgeResult.overallPass Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 18 +++- package.json | 3 +- src/agents/contract-extract.ts | 155 +++++++++++++++++++++++++++++++++ src/agents/reviewer.ts | 126 ++++++++++----------------- 4 files changed, 221 insertions(+), 81 deletions(-) create mode 100644 src/agents/contract-extract.ts diff --git a/package-lock.json b/package-lock.json index a59f4ed..126b5ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "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" }, "bin": { "nativeapptemplate-agent": "dist/index.js" @@ -1897,6 +1898,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 123e439..495a3fd 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/agents/contract-extract.ts b/src/agents/contract-extract.ts new file mode 100644 index 0000000..6f5ebfa --- /dev/null +++ b/src/agents/contract-extract.ts @@ -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 = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); + +// Rails: parse out//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 { + 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 { + 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 { + 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 { + 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 { + const out: string[] = []; + async function walk(dir: string): Promise { + 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 { + return typeof v === "object" && v !== null && !Array.isArray(v); +} diff --git a/src/agents/reviewer.ts b/src/agents/reviewer.ts index d482279..d453bbf 100644 --- a/src/agents/reviewer.ts +++ b/src/agents/reviewer.ts @@ -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 = { @@ -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 { 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 => new Promise((r) => { setTimeout(r, ms); }); async function runStubReviewer(input: ReviewerInput): Promise {