Skip to content
Closed
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 docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Default shape:
"review": {
"maxContextFiles": 24,
"maxOwnedFiles": 12,
"maxPromptBytes": 180000,
"maxFindingsPerFeature": 10,
"minConfidenceToFix": "medium"
},
Expand Down
18 changes: 12 additions & 6 deletions src/agent-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { pathExists } from "./fs.js";
import { runCommandArgs } from "./exec.js";
import { mapFeatureSeeds, MapResult } from "./mapper.js";
import { dedupeFeatureSeeds, stableFeatureJson } from "./mapper-reconciliation.js";
import { FeatureSeed, SeedFileRef, SeedTestRef } from "./mappers/types.js";
import { FeatureSeed, SeedEntrypoint, SeedFileRef, SeedTestRef } from "./mappers/types.js";
import {
applyPathFilters,
isSafeFile,
Expand Down Expand Up @@ -240,14 +240,19 @@ async function toSeed(
}
const contextFiles = await validFileRefs(root, feature.contextFiles, allowedFiles, 80);
const tests = await validTests(root, feature.tests, allowedFiles, 20);
const entrypoint =
(await validEntrypoint(root, feature.entrypoints[0]?.path, allowedFiles)) ??
ownedFiles[0]?.path ??
null;
const entrypoints = (
await Promise.all(
feature.entrypoints.map(async (candidate) => {
const path = await validEntrypoint(root, candidate.path, allowedFiles);
return path === null ? null : { ...candidate, path };
}),
)
).filter((candidate): candidate is SeedEntrypoint => candidate !== null);
const firstEntry = entrypoints[0] ?? null;
const entrypoint = firstEntry?.path ?? ownedFiles[0]?.path ?? null;
if (entrypoint === null) {
return null;
}
const firstEntry = feature.entrypoints[0] ?? null;
const reason = feature.reason.trim();
return {
title: feature.title,
Expand All @@ -261,6 +266,7 @@ async function toSeed(
symbol: firstEntry?.symbol ?? null,
route: firstEntry?.route ?? null,
command: firstEntry?.command ?? null,
...(entrypoints.length === 0 ? {} : { entrypoints }),
ownedFiles,
contextFiles,
tests,
Expand Down
11 changes: 8 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export async function mapCommand(
): Promise<unknown> {
const started = Date.now();
const loaded = await loadProjectState(context);
const detectedProject = await detectProject(loaded.root);
const project = { ...detectedProject, createdAt: loaded.project.createdAt };
const source = parseMapSource(flags);
const config = applyProviderFlags(loaded.config, flags);
const provider = source === "heuristic" ? null : providerByName(config.provider.name);
Expand All @@ -84,7 +86,7 @@ export async function mapCommand(
existing: existing.length,
dryRun: flags["dryRun"] === true,
});
const heuristic = await mapFeatures(loaded.root, loaded.project, existing, {
const heuristic = await mapFeatures(loaded.root, project, existing, {
filters,
onProgress: (event) => {
emitProgress(context, "map", event.event, {
Expand All @@ -102,7 +104,7 @@ export async function mapCommand(
changed: heuristic.changed,
stale: heuristic.stale,
});
const result = await mapWithSource(loaded.root, loaded.project, existing, heuristic, {
const result = await mapWithSource(loaded.root, project, existing, heuristic, {
source,
provider,
providerOptions: providerOptions(config),
Expand Down Expand Up @@ -132,6 +134,7 @@ export async function mapCommand(
emitProgress(context, "map", "write-start", {
features: result.features.length,
});
await writeProject(loaded.paths, project);
for (const feature of result.features) {
await writeFeature(loaded.paths, feature);
}
Expand Down Expand Up @@ -177,11 +180,13 @@ export async function statusCommand(context: AppContext): Promise<unknown> {
for (const id of lockFileIds) {
activeLockIds.add(id);
}
const activeFeatures = features.filter((feature) => feature.status !== "skipped");
return {
project: loaded.project.name,
branch: git.currentBranch,
dirty: git.dirty,
features: features.length,
features: activeFeatures.length,
staleFeatures: features.length - activeFeatures.length,
findings: findings.length,
openFindings: findings.filter((finding) => finding.status === "open").length,
activeLocks: activeLockIds.size,
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function defaultConfig(): ClawpatchConfig {
review: {
maxContextFiles: 24,
maxOwnedFiles: 12,
maxPromptBytes: 180_000,
maxFindingsPerFeature: 10,
minConfidenceToFix: "medium",
},
Expand Down
61 changes: 58 additions & 3 deletions src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ProjectRecord, ProjectCommands } from "./types.js";

type PackageJson = {
name?: unknown;
packageManager?: unknown;
scripts?: unknown;
dependencies?: unknown;
devDependencies?: unknown;
Expand Down Expand Up @@ -162,7 +163,7 @@ async function detectCommands(
const scripts = packageScripts(pkg);
const composerScriptMap = composerScripts(composer);
const defaults = await languageDefaultCommands(root, languages, composer);
const packageManager = packageScriptManager(packageManagers);
const packageManager = declaredNodePackageManager(pkg) ?? packageScriptManager(packageManagers);
const composerTestCommand = composerValidationCommand(composerScriptMap, ["test"]);
return {
typecheck:
Expand Down Expand Up @@ -288,6 +289,10 @@ function packageRunCommand(packageManager: string, script: string): string {

async function detectPackageManagers(root: string): Promise<string[]> {
const found: string[] = [];
const declared = declaredNodePackageManager(await readPackageJson(root));
if (declared !== null) {
found.push(declared);
}
const nodeChecks: Array<[string, string]> = [
["pnpm", "pnpm-lock.yaml"],
["npm", "package-lock.json"],
Expand Down Expand Up @@ -381,6 +386,14 @@ async function detectPackageManagers(root: string): Promise<string[]> {
return found;
}

export function declaredNodePackageManager(pkg: PackageJson | null): string | null {
if (typeof pkg?.packageManager !== "string") {
return null;
}
const name = pkg.packageManager.trim().split("@")[0]?.toLowerCase() ?? "";
return ["npm", "pnpm", "yarn", "bun"].includes(name) ? name : null;
}

const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]);
const rubyPackageManagers = new Set(["bundler", "ruby"]);
const dotnetLanguages = new Set(["csharp", "fsharp", "visual-basic"]);
Expand Down Expand Up @@ -1094,10 +1107,10 @@ async function detectFrameworks(
pkg: PackageJson | null,
composer: ComposerJson | null,
): Promise<string[]> {
const deps = dependencyNames(pkg);
const deps = new Set([...dependencyNames(pkg), ...(await nestedNodeDependencyNames(root, 6))]);
const composerDeps = composerDependencyNames(composer);
const frameworks: string[] = [];
for (const name of ["next", "express", "fastify", "hono", "vitest"]) {
for (const name of ["react", "next", "express", "fastify", "hono", "vitest"]) {
if (deps.has(name)) {
frameworks.push(name);
}
Expand Down Expand Up @@ -1147,6 +1160,48 @@ async function detectFrameworks(
return uniqueStrings(frameworks);
}

async function nestedNodeDependencyNames(root: string, maxDepth: number): Promise<Set<string>> {
const dependencies = new Set<string>();
await collectNestedNodeDependencies(root, root, maxDepth, dependencies);
return dependencies;
}

async function collectNestedNodeDependencies(
root: string,
directory: string,
remainingDepth: number,
dependencies: Set<string>,
): Promise<void> {
if (remainingDepth < 0) {
return;
}
for (const entry of await readdir(directory).catch(() => [])) {
const full = join(directory, entry);
const relativePath = posix.normalize(full.slice(root.length + 1).replace(/\\/gu, "/"));
if (shouldSkipSearchEntry(entry, relativePath)) {
continue;
}
const info = await lstat(full).catch(() => null);
if (info === null || info.isSymbolicLink()) {
continue;
}
if (info.isFile() && entry === "package.json" && directory !== root) {
const parsed = await readFile(full, "utf8")
.then((source) => JSON.parse(source) as unknown)
.catch(() => null);
if (typeof parsed === "object" && parsed !== null) {
for (const dependency of dependencyNames(parsed as PackageJson)) {
dependencies.add(dependency);
}
}
continue;
}
if (info.isDirectory()) {
await collectNestedNodeDependencies(root, full, remainingDepth - 1, dependencies);
}
}
}

async function detectMavenFrameworks(root: string): Promise<string[]> {
const frameworks: string[] = [];
for (const pom of await collectMavenPomFiles(root, 5)) {
Expand Down
Loading