diff --git a/src/agents/workers/android.ts b/src/agents/workers/android.ts index 9dbd503..a0c5949 100644 --- a/src/agents/workers/android.ts +++ b/src/agents/workers/android.ts @@ -16,6 +16,7 @@ type RenameStats = { const SKIP_SEGMENTS = new Set([ ".git", + ".claude", "build", ".gradle", ".idea", diff --git a/src/agents/workers/ios.ts b/src/agents/workers/ios.ts index da3affd..079865e 100644 --- a/src/agents/workers/ios.ts +++ b/src/agents/workers/ios.ts @@ -14,7 +14,7 @@ type RenameStats = { files_renamed: number; }; -const SKIP_RELATIVE_PATHS = ["/.git", "/DerivedData", "/.build", "/Pods", "/Carthage"]; +const SKIP_RELATIVE_PATHS = ["/.git", "/.claude", "/DerivedData", "/.build", "/Pods", "/Carthage"]; export async function runIosWorker(domain: DomainSpec): Promise { if (isStub("ios")) { diff --git a/src/agents/workers/rails.ts b/src/agents/workers/rails.ts index d6363d6..9930a26 100644 --- a/src/agents/workers/rails.ts +++ b/src/agents/workers/rails.ts @@ -14,7 +14,7 @@ type RenameStats = { files_renamed: number; }; -const SKIP_RELATIVE_PATHS = ["/.git", "/node_modules", "/tmp", "/log", "/vendor/bundle"]; +const SKIP_RELATIVE_PATHS = ["/.git", "/.claude", "/node_modules", "/tmp", "/log", "/vendor/bundle"]; export async function runRailsWorker(domain: DomainSpec): Promise { if (isStub("rails")) { diff --git a/src/validation/layer1.ts b/src/validation/layer1.ts index 7b8f5af..c31e58d 100644 --- a/src/validation/layer1.ts +++ b/src/validation/layer1.ts @@ -1,3 +1,6 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { extname, join, relative, basename } from "node:path"; + export type Layer1Input = { projectDir: string; forbiddenTokens: readonly string[]; @@ -15,7 +18,104 @@ export type Layer1Result = { findings: Layer1Finding[]; }; +const TEXT_EXTS = new Set([ + ".rb", ".erb", ".yml", ".yaml", ".json", ".md", ".gemspec", ".rake", ".ru", + ".txt", ".sample", ".example", ".conf", ".html", ".css", ".scss", ".js", ".mjs", + ".tt", ".lock", + ".swift", ".plist", ".strings", ".xcconfig", ".entitlements", ".pbxproj", + ".xcworkspacedata", ".modulemap", + ".kt", ".kts", ".xml", ".gradle", ".pro", ".toml", ".properties", ".cfg", +]); + +const TEXT_BASENAMES = new Set([ + "Gemfile", "Gemfile.lock", "Rakefile", "Procfile", "Procfile.dev", + "config.ru", "Dockerfile", + "Podfile", "Podfile.lock", "Package.swift", "Cartfile", "Makefile", + "gradlew", "gradlew.bat", "gradle.properties", "local.properties", +]); + +const SKIP_SEGMENTS = new Set([ + ".git", "node_modules", "tmp", "log", "vendor", + "DerivedData", "Pods", "Carthage", "xcuserdata", ".build", + "build", ".gradle", ".idea", ".kotlin", "captures", +]); + export async function runLayer1(input: Layer1Input): Promise { - void input; - throw new Error("runLayer1 not implemented: shell out to rg --json, collect matches, pass=true iff findings is empty"); + if (input.forbiddenTokens.length === 0) { + return { pass: true, findings: [] }; + } + + const root = await realRoot(input.projectDir); + if (!root) { + return { pass: false, findings: [] }; + } + + const regex = buildRegex(input.forbiddenTokens); + const findings: Layer1Finding[] = []; + + for await (const filePath of walk(root)) { + const content = await safeRead(filePath); + if (!content) continue; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + regex.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = regex.exec(line)) !== null) { + findings.push({ + token: m[0], + file: relative(root, filePath), + line: i + 1, + text: line, + }); + } + } + } + + return { pass: findings.length === 0, findings }; +} + +async function realRoot(dir: string): Promise { + try { + const s = await stat(dir); + return s.isDirectory() ? dir : null; + } catch { + return null; + } +} + +function buildRegex(tokens: readonly string[]): RegExp { + const escaped = tokens.map(escapeRegex).join("|"); + return new RegExp(`(?:${escaped})`, "g"); +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function* walk(dir: string): AsyncGenerator { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (SKIP_SEGMENTS.has(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(full); + } else if (entry.isFile() && isTextFile(full)) { + yield full; + } + } +} + +function isTextFile(path: string): boolean { + const base = basename(path); + if (TEXT_BASENAMES.has(base)) return true; + return TEXT_EXTS.has(extname(path)); +} + +async function safeRead(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return null; + } } diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 363b3c4..69e1b3b 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -9,11 +9,10 @@ test("validation layers are exported as functions", () => { assert.equal(typeof runLayer3, "function"); }); -test("runLayer1 rejects until implemented", async () => { - await assert.rejects( - runLayer1({ projectDir: "/tmp", forbiddenTokens: [] }), - /not implemented/i, - ); +test("runLayer1 returns pass when forbiddenTokens is empty", async () => { + const result = await runLayer1({ projectDir: "/tmp", forbiddenTokens: [] }); + assert.equal(result.pass, true); + assert.deepEqual(result.findings, []); }); test("runLayer2 returns a failed result for a non-Rails directory", async () => {