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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Added explicit Codex reasoning effort selection via `--reasoning-effort`, `CLAWPATCH_REASONING_EFFORT`, and provider config, with `doctor` reporting the active setting.
- Added deterministic Express, Fastify, and Hono route mapping for Node projects, thanks @rohitjavvadi.
- Fixed provider commands with relative `--root` paths by canonicalizing explicit roots before invoking Codex or other providers.
- Added first-pass Elixir Mix/Phoenix mapping for project metadata, contexts, Phoenix web slices, runtime config, Ecto migrations, project scripts, ExUnit tests, and Mix validation defaults, thanks @tears-mysthrala.
- Improved `clawpatch fix` handoff context and patch-attempt changed-file auditing for dirty-worktree fixes.
- Improved Node workspace mapping with richer package overview features, generic extension package context, semantic large-source splits, and stricter generated/build ownership hygiene.
- Improved Kotlin JVM and Android semantic role mapping for Gradle projects, including Android plugin aliases, local type handling, comment/string parsing, and role fallback edges, thanks @mrmans0n.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ validation commands and records a patch attempt under `.clawpatch/`.
- Kotlin Android semantic roles for UI entrypoints, ViewModels, data
boundaries, external clients, and dependency injection, including Metro
- Ruby project metadata, executables, source groups, RSpec/Minitest suites
- Elixir Mix/Phoenix projects, contexts, Phoenix web slices, runtime config,
Ecto migrations, project scripts, and ExUnit suites
- Rust `src/main.rs`, `src/bin/*.rs`, `src/lib.rs`, `crates/*`, and
`tests/*.rs`
- C/C++ standalone `main()` files, CMake `add_executable` / `add_library`
Expand Down
55 changes: 51 additions & 4 deletions src/agent-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { buildAgentMapPrompt } from "./prompt.js";
import { ClawpatchError } from "./errors.js";
import { Provider, ProviderOptions } from "./provider.js";
import { AgentMapOutput, FeatureRecord, ProjectRecord } from "./types.js";
import { pathExists } from "./fs.js";
import { mapFeatureSeeds, MapResult } from "./mapper.js";
import { FeatureSeed, SeedFileRef, SeedTestRef } from "./mappers/types.js";
import { isSafeFile, normalize, walk } from "./mappers/shared.js";
import { isSafeFile, normalize, shouldSkip, walk } from "./mappers/shared.js";

type AgentMapMode = "heuristic" | "auto" | "agent";

Expand Down Expand Up @@ -52,8 +53,11 @@ const sourceExtensions = new Set([
".cpp",
".cs",
".cxx",
".ex",
".exs",
".go",
".h",
".heex",
".hpp",
".java",
".js",
Expand All @@ -79,6 +83,7 @@ const manifestNames = new Set([
"build.gradle.kts",
"composer.json",
"go.mod",
"mix.exs",
"package.json",
"pnpm-workspace.yaml",
"pyproject.toml",
Expand All @@ -96,7 +101,7 @@ export async function mapWithSource(
): Promise<AgentMapResult> {
const inventoryStarted = Date.now();
options.onProgress?.("inventory-start", {});
const inventory = await repoInventory(root, heuristic.features);
const inventory = await repoInventory(root, project, heuristic.features);
options.onProgress?.("inventory-done", {
files: inventory.files,
sourceFiles: inventory.sourceFiles,
Expand Down Expand Up @@ -360,8 +365,12 @@ async function validRelativeFile(root: string, path: string): Promise<boolean> {
return isSafeFile(root, join(root, path));
}

async function repoInventory(root: string, features: FeatureRecord[]): Promise<RepoInventory> {
const files = await walk(root, [""]);
async function repoInventory(
root: string,
project: ProjectRecord,
features: FeatureRecord[],
): Promise<RepoInventory> {
const files = await walk(root, [""], await inventorySkipPath(root, project, features));
const sourceFiles = files.filter(isSourceFile).filter((path) => !isTestFile(path));
const testFiles = files.filter(isTestFile);
const ownedSource = new Set(
Expand All @@ -388,6 +397,44 @@ async function repoInventory(root: string, features: FeatureRecord[]): Promise<R
};
}

async function inventorySkipPath(
root: string,
project: ProjectRecord,
features: FeatureRecord[],
): Promise<(path: string) => boolean> {
if (
(await pathExists(join(root, "mix.exs"))) ||
hasDependencySkippingProject(project) ||
hasDependencySkippingFeatures(features)
) {
return shouldSkipDependencyPath;
}
return shouldSkip;
}

function hasDependencySkippingProject(project: ProjectRecord): boolean {
return (
project.detected.languages.some((language) => language === "c" || language === "cpp") ||
project.detected.packageManagers.some(
(manager) => manager === "cmake" || manager === "autotools",
)
);
}

function hasDependencySkippingFeatures(features: FeatureRecord[]): boolean {
return features.some(
(feature) =>
feature.tags.some((tag) => tag === "c" || tag === "cpp") ||
["autotools-bin", "autotools-lib", "cmake-bin", "cmake-lib", "cmake-test", "c-main"].includes(
feature.source,
),
);
}

function shouldSkipDependencyPath(path: string): boolean {
return shouldSkip(path) || /(^|\/)deps(\/|$)/u.test(path);
}

function weakMap(
features: FeatureRecord[],
sourceFileCount: number,
Expand Down
64 changes: 62 additions & 2 deletions src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ type PythonProjectInfo = {
hasPytestConfig: boolean;
};

type MixProjectInfo = {
dependencies: Set<string>;
};

export async function detectProject(root: string): Promise<ProjectRecord> {
const git = await discoverGit(root);
const pkg = await readPackageJson(root);
Expand Down Expand Up @@ -229,6 +233,9 @@ async function languageDefaultCommands(
) {
return gradleDefaultCommands(root);
}
if (languages.includes("elixir")) {
return elixirDefaultCommands(root);
}
if (languages.includes("ruby")) {
return rubyDefaultCommands(root);
}
Expand Down Expand Up @@ -292,6 +299,9 @@ async function detectPackageManagers(root: string): Promise<string[]> {
found.push(name);
}
}
if (await pathExists(join(root, "mix.exs"))) {
found.push("mix");
}
if (!found.includes("swiftpm") && (await containsFileNamed(root, "Package.swift", 5))) {
found.push("swiftpm");
}
Expand Down Expand Up @@ -348,6 +358,16 @@ async function detectPackageManagers(root: string): Promise<string[]> {
const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]);
const rubyPackageManagers = new Set(["bundler", "ruby"]);

async function elixirDefaultCommands(root: string): Promise<ProjectCommands> {
const info = await mixProjectInfo(root);
return {
typecheck: "mix compile --warnings-as-errors",
lint: info.dependencies.has("credo") ? "mix credo --strict" : null,
format: "mix format --check-formatted",
test: "mix test",
};
}

async function isRootGradleProject(root: string): Promise<boolean> {
return (
(await pathExists(join(root, "settings.gradle"))) ||
Expand Down Expand Up @@ -493,6 +513,25 @@ async function rubyDefaultCommands(root: string): Promise<ProjectCommands> {
};
}

async function mixProjectInfo(root: string): Promise<MixProjectInfo> {
if (!(await pathExists(join(root, "mix.exs")))) {
return { dependencies: new Set() };
}
const source = stripLineComments(await readFile(join(root, "mix.exs"), "utf8"), "#");
return { dependencies: mixDependencyNames(source) };
}

function mixDependencyNames(source: string): Set<string> {
const names = new Set<string>();
for (const match of source.matchAll(/\{:\s*([a-zA-Z][a-zA-Z0-9_]*)\s*,/gu)) {
const name = match[1];
if (name !== undefined) {
names.add(name);
}
}
return names;
}

function hasRubocopDependency(dependencies: Set<string>): boolean {
return [...dependencies].some(
(dependency) => dependency === "rubocop" || dependency.startsWith("rubocop-"),
Expand Down Expand Up @@ -912,9 +951,28 @@ async function detectFrameworks(
frameworks.push(name);
}
}
for (const name of await detectElixirFrameworks(root)) {
if (!frameworks.includes(name)) {
frameworks.push(name);
}
}
return uniqueStrings(frameworks);
}

async function detectElixirFrameworks(root: string): Promise<string[]> {
if (!(await pathExists(join(root, "mix.exs")))) {
return [];
}
const info = await mixProjectInfo(root);
const frameworks = ["mix"];
for (const name of ["phoenix", "phoenix_live_view", "ecto", "ecto_sql", "ash"]) {
if (info.dependencies.has(name)) {
frameworks.push(name);
}
}
return frameworks;
}

async function detectRubyFrameworks(root: string): Promise<string[]> {
const source = await rubyDependencySource(root);
const frameworks: string[] = [];
Expand Down Expand Up @@ -956,6 +1014,7 @@ async function detectLanguages(root: string): Promise<string[]> {
["python", "requirements.txt"],
["php", "composer.json"],
["php", "artisan"],
["elixir", "mix.exs"],
["ruby", "Gemfile"],
["ruby", "gems.rb"],
["ruby", "Rakefile"],
Expand Down Expand Up @@ -1382,6 +1441,7 @@ function shouldSkipSearchEntry(entry: string, relativePath = entry): boolean {
}
return [
"node_modules",
"deps",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid skipping nested deps folders in language detection

Adding "deps" to shouldSkipSearchEntry causes recursive detection scans to skip every nested deps directory, not just top-level dependency trees. As a result, projects with reviewable files under paths like src/deps/*.py/*.java/*.php can fail language detection (and therefore miss default commands/framework inference) when no root manifest is present, even though those files are first-party source.

Useful? React with 👍 / 👎.

"dist",
"build",
"target",
Expand Down Expand Up @@ -1416,7 +1476,7 @@ function shouldSkipCOrCppSearchEntry(entry: string): boolean {
);
}

function stripLineComments(source: string, marker: "//"): string {
function stripLineComments(source: string, marker: "#" | "//"): string {
return source
.split("\n")
.map((line) => stripLineComment(line, marker))
Expand Down Expand Up @@ -1476,7 +1536,7 @@ function stripBlockComments(source: string): string {
return output;
}

function stripLineComment(line: string, marker: "//"): string {
function stripLineComment(line: string, marker: "#" | "//"): string {
let inString = false;
let escaped = false;
for (let index = 0; index < line.length; index += 1) {
Expand Down
Loading