diff --git a/README.md b/README.md index 24afcab..5460434 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ validation commands and records a patch attempt under `.clawpatch/`. - Rust `src/main.rs`, `src/bin/*.rs`, `src/lib.rs`, `crates/*`, and `tests/*.rs` - SwiftPM `Sources/*` targets and `Tests/*` suites +- Python project metadata, console scripts, bounded source groups, and pytest suites +- Laravel/PHP projects from `composer.json` and `artisan`, including routes, + controllers, form requests, Artisan commands, jobs, services, models, + migrations, seeders, Composer scripts, and PHP test suites - common project config files Deeper framework mappers and agent-assisted enrichment are next steps. diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 2c6dcc9..a72dc76 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -39,6 +39,9 @@ Supported deterministic mappers today: - nested SwiftPM packages - Apple/Xcode projects from `project.yml`, `.xcodeproj`, or `.xcworkspace` - Gradle/Android modules from `settings.gradle(.kts)` and `build.gradle(.kts)` +- Laravel/PHP projects from `composer.json` and `artisan`, including controllers + referenced by routes, form requests, Artisan commands, jobs, services, models, + migrations, seeders, Composer scripts, and grouped PHP test suites - common config files The mapper does not call a model. It uses repo conventions and cheap filesystem @@ -62,5 +65,7 @@ Known gaps: - no Express/Fastify/Hono route mapper yet - no FastAPI/Flask/Django route mapper yet +- Laravel route parsing is convention-based, does not execute Laravel route discovery, + and may omit prefixes applied by `Route::group(...)` wrappers - no import graph expansion beyond nearby tests yet - no agent enrichment yet diff --git a/docs/index.md b/docs/index.md index c8c091b..b1c2958 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,12 +30,12 @@ stderr so pipes stay parseable. ## What clawpatch does -- **Semantic feature mapping.** Detects npm bins, Next.js routes, Go packages, Rust crates, SwiftPM targets, and common config files as reviewable slices. +- **Semantic feature mapping.** Detects npm bins, Next.js routes, Go packages, Rust crates, SwiftPM targets, Laravel/PHP slices, and common config files as reviewable units. - **Automated code review.** Reviews features with AI providers (Codex CLI today), persists findings with severity, category, and line locations. - **Explicit fix workflow.** `clawpatch fix` runs validated patches for one finding at a time, never commits or pushes automatically. - **Stable state model.** All features, findings, patches live in `.clawpatch/` as JSON, resumable across runs. - **Safety first.** Review is read-only, fix refuses dirty worktrees, never auto-commits, validates before accepting patches. -- **Multi-language.** JavaScript/TypeScript, Go, Rust, Swift today; more mappers planned. +- **Multi-language.** JavaScript/TypeScript, Go, Rust, Swift, Python, and Laravel/PHP today; more mappers planned. ## Pick your path diff --git a/docs/quickstart.md b/docs/quickstart.md index a57ad96..0af44c2 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -48,6 +48,7 @@ This discovers reviewable features: - Python packages, console scripts, and pytest suites - Rust crates and binaries - SwiftPM targets and tests +- Laravel controllers, requests, jobs, commands, services, models, migrations, and tests - Config files Preview mapping without writing: diff --git a/src/detect.ts b/src/detect.ts index b4f69c1..881004d 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -13,6 +13,14 @@ type PackageJson = { bin?: unknown; }; +export type ComposerJson = { + name?: unknown; + type?: unknown; + scripts?: unknown; + require?: unknown; + "require-dev"?: unknown; +}; + type PythonProjectInfo = { dependencies: Set; tools: Set; @@ -22,11 +30,17 @@ type PythonProjectInfo = { export async function detectProject(root: string): Promise { const git = await discoverGit(root); const pkg = await readPackageJson(root); + const composer = await readComposerJson(root); const packageManagers = await detectPackageManagers(root); - const frameworks = detectFrameworks(pkg); + const frameworks = detectFrameworks(pkg, composer); const languages = await detectLanguages(root); - const commands = await detectCommands(root, pkg, languages, packageManagers); - const name = typeof pkg?.name === "string" ? pkg.name : projectNameFromRoot(root, git.remoteUrl); + const commands = await detectCommands(root, pkg, composer, languages, packageManagers); + const name = + typeof pkg?.name === "string" + ? pkg.name + : typeof composer?.name === "string" + ? (composer.name.split("/").at(-1) ?? composer.name) + : projectNameFromRoot(root, git.remoteUrl); const now = new Date().toISOString(); return { schemaVersion: 1, @@ -90,32 +104,88 @@ export function packageBins(pkg: PackageJson | null): Record { return bins; } +export async function readComposerJson(root: string): Promise { + const path = join(root, "composer.json"); + if (!(await pathExists(path))) { + return null; + } + const parsed: unknown = JSON.parse(await readFile(path, "utf8")); + return typeof parsed === "object" && parsed !== null ? (parsed as ComposerJson) : null; +} + +export function composerScripts(composer: ComposerJson | null): Record { + if (typeof composer?.scripts !== "object" || composer.scripts === null) { + return {}; + } + const scripts: Record = {}; + for (const [key, value] of Object.entries(composer.scripts)) { + if (typeof value === "string") { + scripts[key] = value; + } else if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + scripts[key] = value.join(" && "); + } + } + return scripts; +} + +export function composerDependencyNames(composer: ComposerJson | null): Set { + const names = new Set(); + for (const field of [composer?.require, composer?.["require-dev"]]) { + if (typeof field !== "object" || field === null) { + continue; + } + for (const name of Object.keys(field)) { + names.add(name); + } + } + return names; +} + async function detectCommands( root: string, pkg: PackageJson | null, + composer: ComposerJson | null, languages: string[], packageManagers: string[], ): Promise { const scripts = packageScripts(pkg); - const defaults = await languageDefaultCommands(root, languages); + const composerScriptMap = composerScripts(composer); + const defaults = await languageDefaultCommands(root, languages, composer); const packageManager = packageScriptManager(packageManagers); + const composerTestCommand = composerValidationCommand(composerScriptMap, ["test"]); return { typecheck: scripts["typecheck"] !== undefined ? packageRunCommand(packageManager, "typecheck") - : defaults.typecheck, - lint: scripts["lint"] !== undefined ? packageRunCommand(packageManager, "lint") : defaults.lint, + : (composerValidationCommand(composerScriptMap, ["typecheck", "analyse", "analyze"]) ?? + defaults.typecheck), + lint: + scripts["lint"] !== undefined + ? packageRunCommand(packageManager, "lint") + : (composerValidationCommand(composerScriptMap, ["lint"]) ?? defaults.lint), format: scripts["format"] !== undefined ? packageRunCommand(packageManager, "format") - : defaults.format, - test: scripts["test"] !== undefined ? packageRunCommand(packageManager, "test") : defaults.test, + : (composerValidationCommand(composerScriptMap, ["format"]) ?? defaults.format), + test: + scripts["test"] !== undefined + ? packageRunCommand(packageManager, "test") + : (composerTestCommand ?? defaults.test), }; } +function composerValidationCommand( + scripts: Record, + candidates: string[], +): string | null { + const script = candidates.find((candidate) => scripts[candidate] !== undefined); + return script === undefined ? null : `composer ${script}`; +} + async function languageDefaultCommands( root: string, languages: string[], + composer: ComposerJson | null, ): Promise { if (languages.includes("go")) { return { @@ -144,6 +214,9 @@ async function languageDefaultCommands( if (languages.includes("python")) { return pythonDefaultCommands(root); } + if (languages.includes("php")) { + return phpDefaultCommands(root, composer); + } return { typecheck: null, @@ -216,6 +289,9 @@ async function detectPackageManagers(root: string): Promise { ) { found.push("gradle"); } + if (await pathExists(join(root, "composer.json"))) { + found.push("composer"); + } const pythonManagers: Array<[string, string]> = [ ["uv", "uv.lock"], ["poetry", "poetry.lock"], @@ -240,6 +316,25 @@ async function detectPackageManagers(root: string): Promise { const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]); +async function phpDefaultCommands( + root: string, + composer: ComposerJson | null, +): Promise { + const dependencies = composerDependencyNames(composer); + const hasArtisan = await pathExists(join(root, "artisan")); + const hasPint = dependencies.has("laravel/pint"); + const hasPhpunit = + dependencies.has("phpunit/phpunit") || + (await pathExists(join(root, "phpunit.xml"))) || + (await pathExists(join(root, "phpunit.xml.dist"))); + return { + typecheck: null, + lint: hasPint ? "vendor/bin/pint --test" : null, + format: hasPint ? "vendor/bin/pint --test" : null, + test: hasArtisan ? "php artisan test" : hasPhpunit ? "vendor/bin/phpunit" : null, + }; +} + async function pythonDefaultCommands(root: string): Promise { const info = await pythonProjectInfo(root); const runner = await pythonRunner(root); @@ -667,14 +762,18 @@ async function containsSwiftFile(dir: string): Promise { return false; } -function detectFrameworks(pkg: PackageJson | null): string[] { +function detectFrameworks(pkg: PackageJson | null, composer: ComposerJson | null): string[] { const deps = dependencyNames(pkg); + const composerDeps = composerDependencyNames(composer); const frameworks: string[] = []; for (const name of ["next", "express", "fastify", "hono", "vitest"]) { if (deps.has(name)) { frameworks.push(name); } } + if (composerDeps.has("laravel/framework")) { + frameworks.push("laravel"); + } return frameworks; } @@ -702,6 +801,7 @@ async function detectLanguages(root: string): Promise { ["python", "setup.py"], ["python", "setup.cfg"], ["python", "requirements.txt"], + ["php", "composer.json"], ]; const languages: string[] = []; for (const [language, file] of checks) { @@ -726,6 +826,9 @@ async function detectLanguages(root: string): Promise { ) { languages.push("kotlin"); } + if (!languages.includes("php") && (await containsReviewablePhpFile(root))) { + languages.push("php"); + } return languages; } @@ -748,6 +851,15 @@ async function containsReviewablePythonFile(root: string): Promise { return containsFileNamed(root, "__init__.py", 3); } +async function containsReviewablePhpFile(root: string): Promise { + for (const prefix of ["app", "routes", "config", "database", "tests"]) { + if (await containsFileWithExtension(join(root, prefix), ".php", 4)) { + return true; + } + } + return false; +} + async function containsFileNamed(root: string, name: string, maxDepth: number): Promise { return containsFileMatching(root, maxDepth, (entry) => entry === name); } diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 3ef283e..acbd507 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -959,6 +959,483 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) ); }); + it("detects and maps Laravel application slices", async () => { + const root = await fixtureRoot("clawpatch-laravel-map-"); + await writeFixture( + root, + "composer.json", + JSON.stringify( + { + name: "acme/wault", + type: "project", + require: { + php: "^8.3", + "laravel/framework": "^13.0", + }, + "require-dev": { + "laravel/pint": "^1.0", + "phpunit/phpunit": "^12.0", + }, + scripts: { + test: ["@php artisan config:clear --ansi", "@php artisan test"], + "deploy:production:manual": "bash deploy/bin/deploy.sh", + }, + }, + null, + 2, + ), + ); + await writeFixture(root, "artisan", "#!/usr/bin/env php\n"); + await writeFixture(root, "phpunit.xml", "\n"); + await writeFixture( + root, + "routes/web.php", + " feature.title); + const trackController = result.features.find( + (feature) => feature.title === "Laravel controller TrackController", + ); + const service = result.features.find( + (feature) => feature.title === "Laravel service TrackUploadService", + ); + + expect(project.detected.languages).toContain("php"); + expect(project.detected.frameworks).toContain("laravel"); + expect(project.detected.packageManagers).toContain("composer"); + expect(project.detected.commands.test).toBe("composer test"); + expect(project.detected.commands.lint).toBe("vendor/bin/pint --test"); + expect(titles).toContain("Laravel project wault"); + expect(titles).toContain("Composer script test"); + expect(titles).toContain("Composer script deploy:production:manual"); + expect(titles).toContain("Laravel controller TrackController"); + expect(titles).toContain("Laravel controller LandingPageController"); + expect(titles).toContain("Laravel request StoreTrackRequest"); + expect(titles).toContain("Laravel command app:release-cut"); + expect(titles).toContain("Laravel command app:report-catalog-watermarks"); + expect(titles).toContain("Laravel job RunSubmissionAnalysis"); + expect(titles).toContain("Laravel service TrackUploadService"); + expect(titles).toContain("Laravel model Track"); + expect(titles).toContain("Laravel migrations database/migrations"); + expect(titles).toContain("Laravel test suite tests/Feature"); + expect(titles).toContain("Project config composer.json"); + expect(trackController?.entrypoints[0]?.route).toBe("/tracks"); + expect(trackController?.contextFiles).toContainEqual({ + path: "routes/web.php", + reason: "route definition", + }); + expect(trackController?.contextFiles).toContainEqual({ + path: "app/Http/Requests/StoreTrackRequest.php", + reason: "imported application class", + }); + expect(trackController?.tests).toEqual([ + { path: "tests/Feature/TrackControllerTest.php", command: "composer test" }, + ]); + expect(service?.tests).toEqual([ + { path: "tests/Unit/TrackUploadServiceTest.php", command: "composer test" }, + ]); + }); + + it("keeps Laravel routes scoped to same-basename controller namespaces", async () => { + const root = await fixtureRoot("clawpatch-laravel-controller-namespaces-"); + await writeFixture( + root, + "composer.json", + JSON.stringify( + { + name: "acme/namespaced-routes", + require: { + php: "^8.3", + "laravel/framework": "^13.0", + }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "routes/admin.php", + "middleware('auth')->get('/users', UserController::class);\n", + ); + await writeFixture( + root, + "routes/api.php", + " feature.entrypoints[0]?.path === "app/Http/Controllers/Admin/UserController.php", + ); + const apiController = result.features.find( + (feature) => feature.entrypoints[0]?.path === "app/Http/Controllers/Api/UserController.php", + ); + + expect(adminController?.entrypoints[0]?.route).toBe("/admin/users"); + expect(adminController?.contextFiles).toContainEqual({ + path: "routes/admin.php", + reason: "route definition", + }); + expect(adminController?.contextFiles).not.toContainEqual({ + path: "routes/api.php", + reason: "route definition", + }); + expect(apiController?.entrypoints[0]?.route).toBe("/api/users"); + expect(apiController?.contextFiles).toContainEqual({ + path: "routes/api.php", + reason: "route definition", + }); + expect(apiController?.contextFiles).not.toContainEqual({ + path: "routes/admin.php", + reason: "route definition", + }); + }); + + it("maps fully qualified Laravel controller route references", async () => { + const root = await fixtureRoot("clawpatch-laravel-qualified-routes-"); + await writeFixture( + root, + "composer.json", + JSON.stringify( + { + name: "acme/qualified-routes", + require: { + php: "^8.3", + "laravel/framework": "^13.0", + }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "routes/web.php", + " feature.entrypoints[0]?.path === "app/Http/Controllers/QualifiedController.php", + ); + const arrayQualifiedController = result.features.find( + (feature) => + feature.entrypoints[0]?.path === "app/Http/Controllers/ArrayQualifiedController.php", + ); + + expect(qualifiedController?.entrypoints[0]?.route).toBe("/qualified"); + expect(qualifiedController?.contextFiles).toContainEqual({ + path: "routes/web.php", + reason: "route definition", + }); + expect(arrayQualifiedController?.entrypoints[0]?.route).toBe("/qualified-array"); + expect(arrayQualifiedController?.contextFiles).toContainEqual({ + path: "routes/web.php", + reason: "route definition", + }); + }); + + it("maps aliased Laravel controller route imports", async () => { + const root = await fixtureRoot("clawpatch-laravel-aliased-routes-"); + await writeFixture( + root, + "composer.json", + JSON.stringify( + { + name: "acme/aliased-routes", + require: { + php: "^8.3", + "laravel/framework": "^13.0", + }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "routes/web.php", + " feature.entrypoints[0]?.path === "app/Http/Controllers/Admin/UserController.php", + ); + const apiController = result.features.find( + (feature) => feature.entrypoints[0]?.path === "app/Http/Controllers/Api/UserController.php", + ); + + expect(adminController?.entrypoints[0]?.route).toBe("/admin/users"); + expect(apiController?.entrypoints[0]?.route).toBe("/api/users"); + }); + + it("ignores commented-out Laravel routes", async () => { + const root = await fixtureRoot("clawpatch-laravel-commented-routes-"); + await writeFixture( + root, + "composer.json", + JSON.stringify( + { + name: "acme/commented-routes", + require: { + php: "^8.3", + "laravel/framework": "^13.0", + }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "routes/web.php", + " feature.entrypoints[0]?.path === "app/Http/Controllers/ArchiveController.php", + ); + + expect(archiveController?.entrypoints[0]?.route).toBe("/current"); + expect(archiveController?.summary).toContain("GET /current"); + expect(archiveController?.summary).not.toContain("/old"); + expect(archiveController?.summary).not.toContain("/blocked"); + }); + + it("uses Composer validation scripts for PHP projects", async () => { + const root = await fixtureRoot("clawpatch-php-composer-commands-"); + await writeFixture( + root, + "composer.json", + JSON.stringify( + { + name: "acme/php-tool", + require: { + php: "^8.3", + }, + scripts: { + analyse: "vendor/bin/phpstan analyse", + lint: "vendor/bin/phpcs", + format: "vendor/bin/php-cs-fixer fix --dry-run", + test: "vendor/bin/phpunit", + }, + }, + null, + 2, + ), + ); + await writeFixture(root, "app/Service.php", " feature.title); + const phpTestSuite = result.features.find((feature) => + feature.title.startsWith("PHP test suite tests"), + ); + + expect(project.detected.commands).toEqual({ + typecheck: "composer analyse", + lint: "composer lint", + format: "composer format", + test: "composer test", + }); + expect(titles).toContain("Composer script test"); + expect(phpTestSuite?.tags).toEqual(["php", "test"]); + expect(titles).not.toContain("Laravel project php-tool"); + }); + + it("uses PHPUnit for Laravel package projects without artisan", async () => { + const root = await fixtureRoot("clawpatch-laravel-package-commands-"); + await writeFixture( + root, + "composer.json", + JSON.stringify( + { + name: "acme/laravel-package", + require: { + php: "^8.3", + "laravel/framework": "^13.0", + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + }, + }, + null, + 2, + ), + ); + await writeFixture(root, "phpunit.xml", "\n"); + await writeFixture(root, "src/PackageServiceProvider.php", " { + const root = await fixtureRoot("clawpatch-laravel-package-feature-tests-"); + await writeFixture( + root, + "composer.json", + JSON.stringify( + { + name: "acme/laravel-package-features", + require: { + php: "^8.3", + "laravel/framework": "^13.0", + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "app/Http/Controllers/PackageController.php", + " feature.entrypoints[0]?.path === "app/Http/Controllers/PackageController.php", + ); + + expect(project.detected.commands.test).toBe("vendor/bin/phpunit"); + expect(controller?.tests).toEqual([ + { path: "tests/Feature/PackageControllerTest.php", command: "vendor/bin/phpunit" }, + ]); + }); + it("resolves Python console scripts and tests from non-src package roots", async () => { const root = await fixtureRoot("clawpatch-python-roots-"); await writeFixture( diff --git a/src/mapper.ts b/src/mapper.ts index 897089f..7ede0fd 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -4,6 +4,7 @@ import { configSeeds } from "./mappers/config.js"; import { goSeeds } from "./mappers/go.js"; import { appleSeeds } from "./mappers/apple.js"; import { gradleSeeds } from "./mappers/gradle.js"; +import { laravelSeeds } from "./mappers/laravel.js"; import { nextSeeds } from "./mappers/next.js"; import { nodeSeeds } from "./mappers/node.js"; import { pythonSeeds } from "./mappers/python.js"; @@ -29,6 +30,7 @@ const featureMappers: FeatureMapper[] = [ { name: "swift", map: swiftSeeds }, { name: "apple", map: appleSeeds }, { name: "gradle", map: gradleSeeds }, + { name: "laravel", map: laravelSeeds }, { name: "config", map: configSeeds }, ]; diff --git a/src/mappers/config.ts b/src/mappers/config.ts index a043410..6912416 100644 --- a/src/mappers/config.ts +++ b/src/mappers/config.ts @@ -13,6 +13,9 @@ export async function configSeeds(root: string): Promise { "Cargo.lock", "rust-toolchain.toml", "Package.swift", + "composer.json", + "composer.lock", + "phpunit.xml", "Makefile", ]; const seeds: FeatureSeed[] = []; diff --git a/src/mappers/laravel.ts b/src/mappers/laravel.ts new file mode 100644 index 0000000..c083aa2 --- /dev/null +++ b/src/mappers/laravel.ts @@ -0,0 +1,732 @@ +import { readFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { + composerDependencyNames, + composerScripts, + readComposerJson, + type ComposerJson, +} from "../detect.js"; +import { pathExists } from "../fs.js"; +import { TrustBoundary } from "../types.js"; +import { isSafeDirectory, isSafeFile, pathMatchesPrefix, shouldSkip, walk } from "./shared.js"; +import { FeatureSeed, SeedFileRef, SeedTestRef } from "./types.js"; + +type SourceGroup = { + label: string; + files: string[]; +}; + +type RouteRef = { + file: string; + method: string; + uri: string; + controllerClass: string; + action: string | null; +}; + +const composerScriptNames = ["setup", "dev", "test", "lint", "format", "analyse", "analyze"]; +const groupedMaxOwnedFiles = 12; +const maxAssociatedTests = 8; + +export async function laravelSeeds(root: string): Promise { + const composer = await readComposerJson(root); + const isLaravel = await isLaravelProject(root, composer); + if (!isLaravel && composer === null) { + return []; + } + + const testCommand = await laravelTestCommand(root, composer); + const testFiles = await phpTestFiles(root); + const routes = await laravelRoutes(root); + const seeds: FeatureSeed[] = [ + ...(isLaravel ? await projectSeeds(root, composer) : []), + ...composerScriptSeeds(composer), + ...(isLaravel ? await controllerSeeds(root, routes, testFiles, testCommand) : []), + ...(isLaravel ? await requestSeeds(root, testFiles, testCommand) : []), + ...(isLaravel ? await commandSeeds(root, testFiles, testCommand) : []), + ...(isLaravel ? await jobSeeds(root, testFiles, testCommand) : []), + ...(isLaravel ? await serviceSeeds(root, testFiles, testCommand) : []), + ...(isLaravel ? await modelSeeds(root, testFiles, testCommand) : []), + ...(isLaravel + ? await groupedPhpSeeds(root, "database/migrations", "Laravel migrations", "migration") + : []), + ...(isLaravel + ? await groupedPhpSeeds(root, "database/seeders", "Laravel seeders", "seeder") + : []), + ...testSuiteSeeds(testFiles, testCommand, isLaravel ? "Laravel" : "PHP"), + ]; + + return seeds; +} + +async function isLaravelProject(root: string, composer: ComposerJson | null): Promise { + return ( + composerDependencyNames(composer).has("laravel/framework") || + (await pathExists(join(root, "artisan"))) + ); +} + +async function projectSeeds(root: string, composer: ComposerJson | null): Promise { + const ownedFiles: SeedFileRef[] = []; + for (const path of ["composer.json", "composer.lock", "artisan", "bootstrap/app.php"]) { + if (await pathExists(join(root, path))) { + ownedFiles.push({ path, reason: "Laravel project metadata" }); + } + } + if (ownedFiles.length === 0) { + return []; + } + const name = + typeof composer?.name === "string" + ? (composer.name.split("/").at(-1) ?? composer.name) + : basename(root); + return [ + { + title: `Laravel project ${name}`, + summary: `Laravel project metadata in ${ownedFiles.map((file) => file.path).join(", ")}.`, + kind: "service", + source: "laravel-project", + confidence: "high", + entryPath: ownedFiles[0]?.path ?? "composer.json", + symbol: name, + route: null, + command: null, + ownedFiles, + contextFiles: await existingRefs(root, [ + ["phpunit.xml", "Laravel test configuration"], + [".env.example", "environment contract"], + ["config/app.php", "application config"], + ["config/database.php", "database config"], + ["routes/web.php", "HTTP routes"], + ["routes/api.php", "API routes"], + ["routes/console.php", "scheduled commands"], + ]), + tags: ["php", "laravel", "project"], + trustBoundaries: ["filesystem", "database", "process-exec", "secrets"], + skipNearbyTests: true, + }, + ]; +} + +function composerScriptSeeds(composer: ComposerJson | null): FeatureSeed[] { + return Object.entries(composerScripts(composer)) + .filter(([script]) => composerScriptNames.includes(script) || script.startsWith("deploy")) + .map(([script, command]) => ({ + title: `Composer script ${script}`, + summary: `Composer script '${script}': ${command}`, + kind: script === "test" ? "test-suite" : "release", + source: "composer-script", + confidence: "medium", + entryPath: "composer.json", + symbol: script, + route: null, + command: script, + ownedFiles: [{ path: "composer.json", reason: "composer script" }], + contextFiles: [], + tests: script === "test" ? [{ path: "composer.json", command: "composer test" }] : [], + tags: ["php", "composer", "script"], + trustBoundaries: script === "test" ? [] : (["process-exec", "filesystem"] as TrustBoundary[]), + skipNearbyTests: true, + })); +} + +async function controllerSeeds( + root: string, + routes: RouteRef[], + testFiles: string[], + testCommand: string | null, +): Promise { + const controllerFiles = await phpFilesUnder(root, "app/Http/Controllers"); + const controllerByClass = new Map(controllerFiles.map((path) => [basename(path, ".php"), path])); + return Promise.all( + controllerFiles.map(async (path) => { + const className = basename(path, ".php"); + const declaredClassName = await phpDeclaredClassName(root, path); + const controllerRoutes = routes.filter((route) => + route.controllerClass.includes("\\") + ? route.controllerClass === declaredClassName + : route.controllerClass === className, + ); + const tests = associatedPhpTests([path], testFiles, testCommand); + return { + title: `Laravel controller ${className}`, + summary: + controllerRoutes.length > 0 + ? `Laravel HTTP controller for ${describeRoutes(controllerRoutes)}.` + : `Laravel HTTP controller ${className}.`, + kind: "route", + source: "laravel-controller", + confidence: "high", + entryPath: path, + symbol: className, + route: controllerRoutes[0]?.uri ?? null, + command: null, + ownedFiles: [{ path, reason: "controller" }], + contextFiles: uniqueRefs([ + ...controllerRoutes.map((route) => ({ path: route.file, reason: "route definition" })), + ...(await phpUseContextFiles(root, path, controllerByClass)), + ...tests.map((test) => ({ path: test.path, reason: "associated test" })), + ]), + tests, + tags: ["php", "laravel", "controller", "http"], + trustBoundaries: ["user-input", "auth", "database", "serialization"], + testCommand, + skipNearbyTests: true, + } satisfies FeatureSeed; + }), + ); +} + +async function requestSeeds( + root: string, + testFiles: string[], + testCommand: string | null, +): Promise { + return phpClassSeeds( + root, + "app/Http/Requests", + "Laravel request", + "laravel-request", + "route", + ["php", "laravel", "request", "validation"], + ["user-input", "auth"], + testFiles, + testCommand, + ); +} + +async function commandSeeds( + root: string, + testFiles: string[], + testCommand: string | null, +): Promise { + const files = await phpFilesUnder(root, "app/Console/Commands"); + return Promise.all( + files.map(async (path) => { + const className = basename(path, ".php"); + const signature = await artisanSignature(root, path); + const tests = associatedPhpTests([path], testFiles, testCommand); + return { + title: `Laravel command ${signature ?? className}`, + summary: + signature === null + ? `Laravel Artisan command ${className}.` + : `Laravel Artisan command '${signature}' in ${path}.`, + kind: "cli-command", + source: "laravel-artisan-command", + confidence: "high", + entryPath: path, + symbol: className, + route: null, + command: signature, + ownedFiles: [{ path, reason: "Artisan command" }], + contextFiles: uniqueRefs([ + ...(await phpUseContextFiles(root, path)), + ...tests.map((test) => ({ path: test.path, reason: "associated test" })), + ]), + tests, + tags: ["php", "laravel", "artisan", "cli"], + trustBoundaries: ["user-input", "filesystem", "process-exec", "database"], + testCommand, + skipNearbyTests: true, + } satisfies FeatureSeed; + }), + ); +} + +async function jobSeeds( + root: string, + testFiles: string[], + testCommand: string | null, +): Promise { + return phpClassSeeds( + root, + "app/Jobs", + "Laravel job", + "laravel-job", + "job", + ["php", "laravel", "job"], + ["database", "concurrency", "external-api"], + testFiles, + testCommand, + ); +} + +async function serviceSeeds( + root: string, + testFiles: string[], + testCommand: string | null, +): Promise { + const files = await phpFilesUnder(root, "app/Services"); + return Promise.all( + files.map(async (path) => { + const className = basename(path, ".php"); + const tests = associatedPhpTests([path], testFiles, testCommand); + return { + title: `Laravel service ${className}`, + summary: `Laravel application service ${className}.`, + kind: "service", + source: "laravel-service", + confidence: "medium", + entryPath: path, + symbol: className, + route: null, + command: null, + ownedFiles: [{ path, reason: "service" }], + contextFiles: uniqueRefs([ + ...(await phpUseContextFiles(root, path)), + ...tests.map((test) => ({ path: test.path, reason: "associated test" })), + ]), + tests, + tags: ["php", "laravel", "service"], + trustBoundaries: trustBoundariesForName(className), + testCommand, + skipNearbyTests: true, + } satisfies FeatureSeed; + }), + ); +} + +async function modelSeeds( + root: string, + testFiles: string[], + testCommand: string | null, +): Promise { + return phpClassSeeds( + root, + "app/Models", + "Laravel model", + "laravel-model", + "service", + ["php", "laravel", "model", "eloquent"], + ["database", "serialization"], + testFiles, + testCommand, + ); +} + +async function phpClassSeeds( + root: string, + prefix: string, + titlePrefix: string, + source: string, + kind: FeatureSeed["kind"], + tags: string[], + trustBoundaries: TrustBoundary[], + testFiles: string[], + testCommand: string | null, +): Promise { + const files = await phpFilesUnder(root, prefix); + return Promise.all( + files.map(async (path) => { + const className = basename(path, ".php"); + const tests = associatedPhpTests([path], testFiles, testCommand); + return { + title: `${titlePrefix} ${className}`, + summary: `${titlePrefix} ${className} in ${path}.`, + kind, + source, + confidence: "medium", + entryPath: path, + symbol: className, + route: null, + command: null, + ownedFiles: [{ path, reason: titlePrefix.toLowerCase() }], + contextFiles: uniqueRefs([ + ...(await phpUseContextFiles(root, path)), + ...tests.map((test) => ({ path: test.path, reason: "associated test" })), + ]), + tests, + tags, + trustBoundaries, + testCommand, + skipNearbyTests: true, + } satisfies FeatureSeed; + }), + ); +} + +async function groupedPhpSeeds( + root: string, + prefix: string, + titlePrefix: string, + tag: string, +): Promise { + const files = await phpFilesUnder(root, prefix); + const groups = partitionSourceFiles(prefix, files, groupedMaxOwnedFiles); + return groups.map((group) => ({ + title: `${titlePrefix} ${group.label}`, + summary: `${titlePrefix} in ${group.label}.`, + kind: "infra", + source: `laravel-${tag}`, + confidence: "medium", + entryPath: group.label, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ path, reason: tag })), + contextFiles: [], + tests: [], + tags: ["php", "laravel", tag], + trustBoundaries: ["database"], + skipNearbyTests: true, + })); +} + +async function laravelRoutes(root: string): Promise { + const routeFiles = await phpFilesUnder(root, "routes"); + const routes: RouteRef[] = []; + for (const file of routeFiles) { + const source = stripPhpComments(await readFile(join(root, file), "utf8")); + const imports = phpUseMap(source); + for (const match of source.matchAll( + /Route::((?:[A-Za-z_][A-Za-z0-9_]*\s*\([^;{}]*?\)\s*->\s*)*)(get|post|put|patch|delete|options|any|resource|apiResource)\s*\(\s*(['"])([^'"]*)\3\s*,\s*(?:\[\s*)?(\\?[A-Za-z_][A-Za-z0-9_\\]*)::class(?:\s*,\s*(['"])([^'"]+)\6)?/gmsu, + )) { + const chain = match[1] ?? ""; + const method = match[2]; + const uri = match[4]; + const controllerClass = resolveImportedClassName(imports, match[5] ?? ""); + if (method === undefined || uri === undefined || controllerClass === null) { + continue; + } + routes.push({ + file, + method, + uri: routeUriWithPrefixes(fluentRoutePrefixes(chain), uri), + controllerClass, + action: match[7] ?? null, + }); + } + } + return routes; +} + +function fluentRoutePrefixes(chain: string): string[] { + return [...chain.matchAll(/\bprefix\s*\(\s*(['"])([^'"]*)\1\s*\)/gmu)] + .map((match) => match[2]) + .filter((prefix) => prefix !== undefined); +} + +function stripPhpComments(source: string): string { + let output = ""; + let quote: "'" | '"' | null = null; + let escaped = false; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + const next = source[index + 1]; + if (char === undefined) { + continue; + } + if (quote !== null) { + output += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + output += char; + continue; + } + if ((char === "/" && next === "/") || char === "#") { + while (index < source.length && source[index] !== "\n") { + index += 1; + } + if (source[index] === "\n") { + output += "\n"; + } + continue; + } + if (char === "/" && next === "*") { + index += 2; + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + if (source[index] === "\n") { + output += "\n"; + } + index += 1; + } + index += 1; + continue; + } + output += char; + } + return output; +} + +function phpUseMap(source: string): Map { + const imports = new Map(); + for (const match of source.matchAll( + /^\s*use\s+([A-Za-z_\\][A-Za-z0-9_\\]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?\s*;/gimu, + )) { + const qualified = match[1]; + const short = match[2] ?? qualified?.split("\\").at(-1); + if (qualified !== undefined && short !== undefined) { + imports.set(short, qualified); + } + } + for (const match of source.matchAll( + /^\s*use\s+([A-Za-z_\\][A-Za-z0-9_\\]*)\\\s*\{\s*([^}]+)\s*\}\s*;/gimu, + )) { + const prefix = match[1]; + const members = match[2]; + if (prefix === undefined || members === undefined) { + continue; + } + for (const member of members.split(",")) { + const memberMatch = + /^\s*([A-Za-z_\\][A-Za-z0-9_\\]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?\s*$/iu.exec(member); + const memberName = memberMatch?.[1]; + if (memberName === undefined) { + continue; + } + const qualified = `${prefix}\\${memberName}`; + const short = memberMatch?.[2] ?? memberName.split("\\").at(-1); + if (short !== undefined) { + imports.set(short, qualified); + } + } + } + return imports; +} + +function resolveImportedClassName(imports: Map, className: string): string | null { + const normalized = className.replace(/^\\/u, ""); + if (normalized.length === 0) { + return null; + } + if (normalized.includes("\\")) { + return normalized; + } + return imports.get(normalized) ?? normalized; +} + +async function phpDeclaredClassName(root: string, path: string): Promise { + const source = await readFile(join(root, path), "utf8"); + const className = basename(path, ".php"); + const namespace = /^\s*namespace\s+([A-Za-z_\\][A-Za-z0-9_\\]*)\s*;/mu.exec(source)?.[1]; + return namespace === undefined ? className : `${namespace}\\${className}`; +} + +function routeUri(uri: string): string { + if (uri === "/" || uri.length === 0) { + return "/"; + } + return uri.startsWith("/") ? uri : `/${uri}`; +} + +function routeUriWithPrefixes(prefixes: string[], uri: string): string { + const combined = [...prefixes, uri] + .map((segment) => segment.replace(/^\/+|\/+$/gu, "")) + .filter((segment) => segment.length > 0) + .join("/"); + return routeUri(combined); +} + +function describeRoutes(routes: RouteRef[]): string { + return routes + .slice(0, 6) + .map( + (route) => + `${route.method.toUpperCase()} ${route.uri}${route.action ? `#${route.action}` : ""}`, + ) + .join(", "); +} + +async function artisanSignature(root: string, path: string): Promise { + const source = await readFile(join(root, path), "utf8"); + return ( + /\$signature\s*=\s*(['"])([^'"]+)\1/u.exec(source)?.[2]?.split(/\s+/u)[0] ?? + /Signature\s*\(\s*(['"])([^'"]+)\1/u.exec(source)?.[2]?.split(/\s+/u)[0] ?? + /AsCommand\s*\(\s*name:\s*(['"])([^'"]+)\1/u.exec(source)?.[2] ?? + null + ); +} + +async function phpUseContextFiles( + root: string, + path: string, + alreadyKnownClasses = new Map(), +): Promise { + const source = await readFile(join(root, path), "utf8"); + const refs: SeedFileRef[] = []; + for (const qualified of phpUseMap(source).values()) { + if (!qualified.startsWith("App\\")) { + continue; + } + const candidate = `${qualified.replace(/\\/gu, "/")}.php`.replace(/^App\//u, "app/"); + if (candidate !== path && (await isSafeFile(root, join(root, candidate)))) { + refs.push({ path: candidate, reason: "imported application class" }); + continue; + } + const short = qualified.split("\\").at(-1); + const known = short === undefined ? undefined : alreadyKnownClasses.get(short); + if (known !== undefined && known !== path) { + refs.push({ path: known, reason: "imported application class" }); + } + } + return refs.slice(0, 12); +} + +async function laravelTestCommand( + root: string, + composer: ComposerJson | null, +): Promise { + if (composerScripts(composer)["test"] !== undefined) { + return "composer test"; + } + if (await pathExists(join(root, "artisan"))) { + return "php artisan test"; + } + if ( + composerDependencyNames(composer).has("phpunit/phpunit") || + (await pathExists(join(root, "phpunit.xml"))) || + (await pathExists(join(root, "phpunit.xml.dist"))) + ) { + return "vendor/bin/phpunit"; + } + return null; +} + +async function phpTestFiles(root: string): Promise { + return (await walk(root, ["tests"])).filter((path) => path.endsWith("Test.php")).slice(0, 300); +} + +function testSuiteSeeds( + testFiles: string[], + command: string | null, + projectType: "Laravel" | "PHP", +): FeatureSeed[] { + return [...groupedTestFiles(testFiles).entries()].flatMap(([root, files]) => + partitionSourceFiles(root, files, groupedMaxOwnedFiles).map((group) => ({ + title: `${projectType} test suite ${group.label}`, + summary: `${projectType} tests in ${group.label}.`, + kind: "test-suite", + source: projectType === "Laravel" ? "laravel-test-suite" : "php-test-suite", + confidence: "medium", + entryPath: group.label, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ path, reason: "PHP test" })), + contextFiles: [], + tests: group.files.map((path) => ({ path, command })), + tags: projectType === "Laravel" ? ["php", "laravel", "test"] : ["php", "test"], + trustBoundaries: [], + testCommand: command, + skipNearbyTests: true, + })), + ); +} + +function groupedTestFiles(testFiles: string[]): Map { + const groups = new Map(); + for (const path of testFiles) { + const root = testSuiteRoot(path); + const files = groups.get(root) ?? []; + files.push(path); + groups.set(root, files); + } + return new Map([...groups.entries()].toSorted(([left], [right]) => left.localeCompare(right))); +} + +function testSuiteRoot(path: string): string { + const parts = path.split("/"); + if (parts.length >= 2 && parts[0] === "tests") { + return `${parts[0]}/${parts[1]}`; + } + return dirname(path); +} + +function associatedPhpTests( + files: string[], + tests: string[], + command: string | null, +): SeedTestRef[] { + const stems = new Set(files.map((file) => basename(file, ".php"))); + const directories = new Set(files.map((file) => dirname(file))); + return tests + .filter((test) => { + const testStem = basename(test, ".php").replace(/Test$/u, ""); + return ( + stems.has(testStem) || + [...stems].some((stem) => testStem.includes(stem)) || + [...directories].some((dir) => pathMatchesPrefix(test, dir)) + ); + }) + .slice(0, maxAssociatedTests) + .map((path) => ({ path, command })); +} + +async function phpFilesUnder(root: string, prefix: string): Promise { + if (!(await isSafeDirectory(root, join(root, prefix)))) { + return []; + } + return (await walk(root, [prefix])) + .filter((path) => path.endsWith(".php")) + .filter((path) => !laravelShouldSkip(path)); +} + +function laravelShouldSkip(path: string): boolean { + return shouldSkip(path) || /(^|\/)(vendor|storage|bootstrap\/cache)(\/|$)/u.test(path); +} + +function partitionSourceFiles( + sourceRoot: string, + files: string[], + maxFiles: number, +): SourceGroup[] { + const sorted = files.toSorted(); + const groups: SourceGroup[] = []; + for (let index = 0; index < sorted.length; index += maxFiles) { + const chunk = sorted.slice(index, index + maxFiles); + const part = Math.floor(index / maxFiles) + 1; + groups.push({ + label: sorted.length <= maxFiles ? sourceRoot : `${sourceRoot}#${part}`, + files: chunk, + }); + } + return groups; +} + +async function existingRefs(root: string, refs: Array<[string, string]>): Promise { + const output: SeedFileRef[] = []; + for (const [path, reason] of refs) { + if (await pathExists(join(root, path))) { + output.push({ path, reason }); + } + } + return output; +} + +function uniqueRefs(refs: SeedFileRef[]): SeedFileRef[] { + const seen = new Set(); + const output: SeedFileRef[] = []; + for (const ref of refs) { + if (seen.has(ref.path)) { + continue; + } + seen.add(ref.path); + output.push(ref); + } + return output; +} + +function trustBoundariesForName(name: string): TrustBoundary[] { + const boundaries = new Set(["database", "serialization"]); + if (/audio|http|api|telegram|vector|embedding|client|s3|storage/iu.test(name)) { + boundaries.add("network"); + boundaries.add("external-api"); + } + if (/upload|file|disk|asset|report|artifact|catalog/iu.test(name)) { + boundaries.add("filesystem"); + } + if (/queue|job|batch|async|process/iu.test(name)) { + boundaries.add("concurrency"); + } + return [...boundaries]; +}