Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/feature-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
130 changes: 121 additions & 9 deletions src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
tools: Set<string>;
Expand All @@ -22,11 +30,17 @@ type PythonProjectInfo = {
export async function detectProject(root: string): Promise<ProjectRecord> {
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,
Expand Down Expand Up @@ -90,32 +104,88 @@ export function packageBins(pkg: PackageJson | null): Record<string, string> {
return bins;
}

export async function readComposerJson(root: string): Promise<ComposerJson | null> {
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<string, string> {
if (typeof composer?.scripts !== "object" || composer.scripts === null) {
return {};
}
const scripts: Record<string, string> = {};
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<string> {
const names = new Set<string>();
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<ProjectCommands> {
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<string, string>,
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<ProjectCommands> {
if (languages.includes("go")) {
return {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -216,6 +289,9 @@ async function detectPackageManagers(root: string): Promise<string[]> {
) {
found.push("gradle");
}
if (await pathExists(join(root, "composer.json"))) {
found.push("composer");
}
const pythonManagers: Array<[string, string]> = [
["uv", "uv.lock"],
["poetry", "poetry.lock"],
Expand All @@ -240,6 +316,25 @@ async function detectPackageManagers(root: string): Promise<string[]> {

const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]);

async function phpDefaultCommands(
root: string,
composer: ComposerJson | null,
): Promise<ProjectCommands> {
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<ProjectCommands> {
const info = await pythonProjectInfo(root);
const runner = await pythonRunner(root);
Expand Down Expand Up @@ -667,14 +762,18 @@ async function containsSwiftFile(dir: string): Promise<boolean> {
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;
}

Expand Down Expand Up @@ -702,6 +801,7 @@ async function detectLanguages(root: string): Promise<string[]> {
["python", "setup.py"],
["python", "setup.cfg"],
["python", "requirements.txt"],
["php", "composer.json"],
];
const languages: string[] = [];
for (const [language, file] of checks) {
Expand All @@ -726,6 +826,9 @@ async function detectLanguages(root: string): Promise<string[]> {
) {
languages.push("kotlin");
}
if (!languages.includes("php") && (await containsReviewablePhpFile(root))) {
languages.push("php");
}
return languages;
}

Expand All @@ -748,6 +851,15 @@ async function containsReviewablePythonFile(root: string): Promise<boolean> {
return containsFileNamed(root, "__init__.py", 3);
}

async function containsReviewablePhpFile(root: string): Promise<boolean> {
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<boolean> {
return containsFileMatching(root, maxDepth, (entry) => entry === name);
}
Expand Down
Loading