diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eda10a..3e9e44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 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. +- Added C#/.NET detection, conservative `dotnet build` / `dotnet test` defaults, ASP.NET Core route mapping, C#/F#/Visual Basic source groups, and .NET test-project mapping including TUnit, thanks @SimonGuldager with ideas from @danielmarbach. ## 0.2.0 - 2026-05-17 diff --git a/README.md b/README.md index 37a568f..4e4538f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ validation commands and records a patch attempt under `.clawpatch/`. imports, interfaces, inheritance, supertypes, and method signatures - Kotlin Android semantic roles for UI entrypoints, ViewModels, data boundaries, external clients, and dependency injection, including Metro +- C#/.NET projects from `.sln`, `.slnx`, `.csproj`, `.fsproj`, and `.vbproj` + files, with conservative `dotnet build` / `dotnet test` defaults +- ASP.NET Core controllers, minimal API endpoints, C#/F#/Visual Basic source + groups, and .NET test projects - 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 diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index ff5158c..836563c 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -50,6 +50,9 @@ Supported deterministic mappers today: Rails configs, routes, views, assets, and database files - Rust Cargo commands, libraries, workspace crates, and integration tests - C/C++ standalone `main()` files, CMake targets, and autotools targets +- C#/.NET projects from `.sln`, `.slnx`, `.csproj`, `.fsproj`, and `.vbproj`, + ASP.NET Core controllers, minimal API endpoints, C#/F#/Visual Basic source + groups, and .NET test projects - SwiftPM executable targets, library targets, and test suites - nested SwiftPM packages - Apple/Xcode projects from `project.yml`, `.xcodeproj`, or `.xcworkspace` @@ -128,6 +131,14 @@ Android UI entrypoints, ViewModels, data boundaries, or dependency injection. Kotlin dependency-injection evidence includes Hilt, Dagger, Koin, and Metro annotations and imports. +C#/.NET mapping reads solution/project files and C#/F#/Visual Basic source +without executing MSBuild. It emits project records, bounded source groups, +test-project records, ASP.NET Core controller routes, and minimal API routes. Default +validation commands are only generated when there is a single clear solution or +project target; ambiguous workspaces stay command-null rather than guessing. +Common generated outputs such as `bin/`, `obj/`, `TestResults/`, and `.g.cs` +files are skipped. + C/C++ mapping covers generic project shapes only: standalone source files with `main()`, CMake `add_executable` / `add_library`, and autotools `bin_PROGRAMS` / `lib_LTLIBRARIES`. It deliberately avoids project-specific C dialects such as @@ -156,5 +167,7 @@ Known gaps: - no 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 +- C#/.NET mapping does not evaluate MSBuild conditions, imported props/targets, + or runtime route conventions - no import graph expansion beyond nearby tests yet - agent mapping depends on provider quality and validates paths but not semantic intent diff --git a/docs/index.md b/docs/index.md index 60157ea..c7ec86e 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, React Router routes, Python packages and Flask/FastAPI routes, Ruby/Rails slices, Laravel/PHP slices, Java/Kotlin Gradle modules, Go packages, Rust crates, C/C++ build targets, SwiftPM targets, and common config files as reviewable units. +- **Semantic feature mapping.** Detects npm bins, Next.js routes, React Router routes, Python packages and Flask/FastAPI routes, Ruby/Rails slices, Laravel/PHP slices, Java/Kotlin Gradle modules, C#/.NET projects and ASP.NET endpoints, Go packages, Rust crates, C/C++ build targets, SwiftPM targets, 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, Python, Ruby, PHP/Laravel, Java/Kotlin, Go, Rust, C/C++, and Swift today; more mappers planned. +- **Multi-language.** JavaScript/TypeScript, Python, Ruby, PHP/Laravel, Java/Kotlin, C#/.NET, Go, Rust, C/C++, and Swift today; more mappers planned. ## Pick your path diff --git a/docs/quickstart.md b/docs/quickstart.md index 3bbdc29..82e36f8 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -48,6 +48,7 @@ This discovers reviewable features: - Next.js routes - Go packages and commands - Java/Kotlin Gradle modules +- C#/.NET projects, ASP.NET endpoints, source groups, and test projects - Python packages, console scripts, Flask/FastAPI routes, and pytest suites - JVM semantic role groups - Ruby packages, Rails apps, executables, and tests diff --git a/src/agent-mapper.ts b/src/agent-mapper.ts index f376f01..19ee2e1 100644 --- a/src/agent-mapper.ts +++ b/src/agent-mapper.ts @@ -55,6 +55,8 @@ const sourceExtensions = new Set([ ".cxx", ".ex", ".exs", + ".fs", + ".fsi", ".go", ".h", ".heex", @@ -73,15 +75,22 @@ const sourceExtensions = new Set([ ".swift", ".ts", ".tsx", + ".vb", ]); const manifestNames = new Set([ "Cargo.toml", "CMakeLists.txt", "Package.swift", + "Directory.Build.props", + "Directory.Build.targets", + "Directory.Packages.props", + "Directory.Packages.targets", + "NuGet.config", "build.gradle", "build.gradle.kts", "composer.json", + "global.json", "go.mod", "mix.exs", "package.json", @@ -389,7 +398,7 @@ async function repoInventory( weak: weak.weak, weakReason: weak.reason, allFiles: new Set(files), - manifests: files.filter((file) => manifestNames.has(file.split("/").at(-1) ?? "")), + manifests: files.filter(isManifestFile), topLevelDirs: uniqueStrings(files.map((file) => file.split("/")[0] ?? "").filter(Boolean)), fileSamples: files.slice(0, 400), sourceFileSamples: sourceFiles.slice(0, 500), @@ -470,6 +479,11 @@ function isSourceFile(path: string): boolean { return ext !== undefined && sourceExtensions.has(ext); } +function isManifestFile(path: string): boolean { + const name = path.split("/").at(-1) ?? ""; + return manifestNames.has(name) || /\.(?:sln|slnx|csproj|fsproj|vbproj)$/iu.test(name); +} + function isTestFile(path: string): boolean { return /(^|\/)(test|tests|__tests__)(\/|$)|(?:^|[._-])(?:test|spec)\.[^/]+$/iu.test(path); } diff --git a/src/detect.ts b/src/detect.ts index 7ecffca..1045144 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1,5 +1,5 @@ import { lstat, readFile, readdir } from "node:fs/promises"; -import { join } from "node:path"; +import { join, posix } from "node:path"; import { pathExists } from "./fs.js"; import { projectNameFromRoot, discoverGit } from "./git.js"; import { stableId } from "./id.js"; @@ -9,6 +9,7 @@ import { rubyGemspecPaths, stripRubyComments, } from "./ruby.js"; +import { shellQuotePath } from "./shell.js"; import { ProjectRecord, ProjectCommands } from "./types.js"; type PackageJson = { @@ -236,6 +237,12 @@ async function languageDefaultCommands( if (languages.includes("elixir")) { return elixirDefaultCommands(root); } + if (languages.some((language) => dotnetLanguages.has(language))) { + const dotnetCommands = await dotnetDefaultCommands(root); + if (hasValidationCommand(dotnetCommands)) { + return dotnetCommands; + } + } if (languages.includes("ruby")) { return rubyDefaultCommands(root); } @@ -248,6 +255,15 @@ async function languageDefaultCommands( }; } +function hasValidationCommand(commands: ProjectCommands): boolean { + return ( + commands.typecheck !== null || + commands.lint !== null || + commands.format !== null || + commands.test !== null + ); +} + function packageScriptManager(packageManagers: string[]): string { return packageManagers.find((name) => nodePackageManagers.has(name)) ?? "npm"; } @@ -327,6 +343,9 @@ async function detectPackageManagers(root: string): Promise { ) { found.push("autotools"); } + if (!found.includes("dotnet") && (await hasDotnetBuildManifest(root))) { + found.push("dotnet"); + } if (await pathExists(join(root, "composer.json"))) { found.push("composer"); } @@ -357,6 +376,7 @@ async function detectPackageManagers(root: string): Promise { const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]); const rubyPackageManagers = new Set(["bundler", "ruby"]); +const dotnetLanguages = new Set(["csharp", "fsharp", "visual-basic"]); async function elixirDefaultCommands(root: string): Promise { const info = await mixProjectInfo(root); @@ -387,6 +407,118 @@ async function gradleDefaultCommands(root: string): Promise { }; } +async function dotnetDefaultCommands(root: string): Promise { + const target = await dotnetValidationTarget(root); + const testTarget = await dotnetTestTarget(root, target); + return { + typecheck: target === null ? null : `dotnet build ${shellQuotePath(target)}`, + lint: null, + format: null, + test: testTarget === null ? null : `dotnet test ${shellQuotePath(testTarget)}`, + }; +} + +async function dotnetValidationTarget(root: string): Promise { + const solutions = (await collectDotnetFiles(root, isDotnetSolutionFileName, 4)).toSorted(); + const rootSolutions = solutions.filter((path) => !path.includes("/")); + const projects = (await collectDotnetFiles(root, isDotnetProjectFileName, 5)).toSorted(); + const solutionCoverageProjects = await dotnetSolutionCoverageProjects(root, projects); + if (rootSolutions.length === 1) { + const solution = rootSolutions[0] ?? null; + if ( + solution !== null && + (await dotnetSolutionIncludesProjects(root, solution, solutionCoverageProjects)) + ) { + return solution; + } + } + + const rootProjects = projects.filter((path) => !path.includes("/")); + if (rootProjects.length === 1) { + return rootProjects[0] ?? null; + } + if ( + rootSolutions.length === 0 && + solutions.length === 1 && + (await dotnetSolutionIncludesProjects(root, solutions[0] ?? "", solutionCoverageProjects)) + ) { + return solutions[0] ?? null; + } + return projects.length === 1 ? (projects[0] ?? null) : null; +} + +async function dotnetSolutionCoverageProjects(root: string, projects: string[]): Promise { + const buildProjects: string[] = []; + for (const project of projects) { + const source = await readFile(join(root, project), "utf8").catch(() => ""); + if (!isStrongDotnetTestProject(source)) { + buildProjects.push(project); + } + } + return buildProjects.length === 0 ? projects : buildProjects; +} + +async function dotnetTestTarget(root: string, buildTarget: string | null): Promise { + const testProjects: string[] = []; + for (const project of await collectDotnetFiles(root, isDotnetProjectFileName, 5)) { + const source = await readFile(join(root, project), "utf8").catch(() => ""); + if (isStrongDotnetTestProject(source)) { + testProjects.push(project); + } + } + if (testProjects.length === 0) { + return null; + } + if ( + buildTarget !== null && + (testProjects.includes(buildTarget) || + (isDotnetSolutionFileName(buildTarget) && + (await dotnetSolutionIncludesProjects(root, buildTarget, testProjects)))) + ) { + return buildTarget; + } + return testProjects.length === 1 ? (testProjects[0] ?? null) : null; +} + +async function dotnetSolutionIncludesProjects( + root: string, + solution: string, + projects: string[], +): Promise { + const source = await readFile(join(root, solution), "utf8").catch(() => ""); + const solutionProjects = new Set(dotnetSolutionProjectPaths(solution, source)); + return projects.every((project) => solutionProjects.has(project)); +} + +function dotnetSolutionProjectPaths(solution: string, source: string): string[] { + const solutionRoot = solution.includes("/") ? solution.slice(0, solution.lastIndexOf("/")) : "."; + const paths: string[] = []; + const pattern = isDotnetSlnxFileName(solution) + ? /\bPath\s*=\s*["']([^"']+\.(?:cs|fs|vb)proj)["']/gimu + : /^Project\([^)]+\)\s*=\s*"[^"]*"\s*,\s*"([^"]+\.(?:cs|fs|vb)proj)"/gimu; + for (const match of source.matchAll(pattern)) { + const path = dotnetSolutionProjectPath(solutionRoot, match[1] ?? ""); + if (path !== null) { + paths.push(path); + } + } + return [...new Set(paths)]; +} + +function dotnetSolutionProjectPath(solutionRoot: string, path: string): string | null { + const normalized = path.replace(/\\/gu, "/"); + if (normalized.length === 0 || /^(?:[A-Za-z]:)?\//u.test(normalized)) { + return null; + } + const resolved = posix.normalize( + [solutionRoot === "." ? "" : solutionRoot, normalized].filter(Boolean).join("/"), + ); + if (resolved === "." || resolved === ".." || resolved.startsWith("../")) { + return null; + } + return resolved; +} + async function phpDefaultCommands( root: string, composer: ComposerJson | null, @@ -956,6 +1088,26 @@ async function detectFrameworks( frameworks.push(name); } } + for (const name of await detectDotnetFrameworks(root)) { + if (!frameworks.includes(name)) { + frameworks.push(name); + } + } + return uniqueStrings(frameworks); +} + +async function detectDotnetFrameworks(root: string): Promise { + const frameworks: string[] = []; + for (const project of await collectDotnetFiles(root, isDotnetProjectFileName, 5)) { + const source = await readFile(join(root, project), "utf8").catch(() => ""); + const activeSource = stripXmlComments(source); + if (isDotnetWebProject(activeSource)) { + frameworks.push("aspnetcore"); + } + if (hasDotnetTestFrameworkEvidence(activeSource)) { + frameworks.push("dotnet-test"); + } + } return uniqueStrings(frameworks); } @@ -1059,9 +1211,53 @@ async function detectLanguages(root: string): Promise { if (!languages.includes("php") && (await containsReviewablePhpFile(root))) { languages.push("php"); } + const dotnetProjectFiles = await collectDotnetFiles(root, isDotnetProjectFileName, 5); + if ( + !languages.includes("csharp") && + (dotnetProjectFiles.some((path) => path.toLowerCase().endsWith(".csproj")) || + (await containsReviewableCsharpFile(root))) + ) { + languages.push("csharp"); + } + if ( + !languages.includes("fsharp") && + dotnetProjectFiles.some((path) => path.toLowerCase().endsWith(".fsproj")) + ) { + languages.push("fsharp"); + } + if ( + !languages.includes("visual-basic") && + dotnetProjectFiles.some((path) => path.toLowerCase().endsWith(".vbproj")) + ) { + languages.push("visual-basic"); + } return languages; } +async function hasDotnetBuildManifest(root: string): Promise { + return ( + (await containsFileMatching(root, 5, isDotnetProjectFileName, shouldSkipDotnetSearchEntry)) || + (await containsFileMatching(root, 4, isDotnetSolutionFileName, shouldSkipDotnetSearchEntry)) + ); +} + +async function containsReviewableCsharpFile(root: string): Promise { + for (const prefix of ["src", "app", "apps", "lib", "test", "tests"]) { + if ( + await containsFileWithExtension( + join(root, prefix), + ".cs", + 6, + shouldSkipDotnetSearchEntry, + prefix, + ) + ) { + return true; + } + } + return containsFileWithExtension(root, ".cs", 1, shouldSkipDotnetSearchEntry); +} + async function containsCFile(root: string): Promise { return containsFileWithExtension(root, ".c", 5, shouldSkipCOrCppSearchEntry); } @@ -1467,6 +1663,18 @@ function shouldSkipSearchEntry(entry: string, relativePath = entry): boolean { ].includes(entry); } +function shouldSkipDotnetSearchEntry(entry: string, relativePath = entry): boolean { + return ( + shouldSkipSearchEntry(entry, relativePath) || + ["bin", "obj", "TestResults", ".vs"].includes(entry) || + isDotnetPackageCachePath(relativePath) + ); +} + +function isDotnetPackageCachePath(path: string): boolean { + return /(^|\/)\.nuget\/(?:packages|fallbackpackages)(\/|$)/iu.test(path); +} + function shouldSkipCOrCppSearchEntry(entry: string): boolean { return ( shouldSkipSearchEntry(entry) || @@ -1476,6 +1684,89 @@ function shouldSkipCOrCppSearchEntry(entry: string): boolean { ); } +async function collectDotnetFiles( + root: string, + predicate: (entry: string) => boolean, + maxDepth: number, +): Promise { + const files: string[] = []; + await collectDotnetFilesAt(root, maxDepth, predicate, files); + return [...new Set(files)].toSorted(); +} + +async function collectDotnetFilesAt( + dir: string, + remainingDepth: number, + predicate: (entry: string) => boolean, + files: string[], + relativeDir = "", +): Promise { + if (remainingDepth < 0 || !(await pathExists(dir))) { + return; + } + const dirInfo = await lstat(dir); + if (!dirInfo.isDirectory() || dirInfo.isSymbolicLink()) { + return; + } + for (const entry of await readdir(dir)) { + const relativePath = relativeDir.length === 0 ? entry : `${relativeDir}/${entry}`; + if (shouldSkipDotnetSearchEntry(entry, relativePath)) { + continue; + } + const full = join(dir, entry); + const info = await lstat(full); + if (info.isSymbolicLink()) { + continue; + } + if (info.isFile() && predicate(entry)) { + files.push(relativePath); + } else if (info.isDirectory()) { + await collectDotnetFilesAt(full, remainingDepth - 1, predicate, files, relativePath); + } + } +} + +function isDotnetProjectFileName(entry: string): boolean { + return /\.(?:cs|fs|vb)proj$/iu.test(entry); +} + +function isDotnetSolutionFileName(entry: string): boolean { + return /\.(?:sln|slnx)$/iu.test(entry); +} + +function isDotnetSlnxFileName(entry: string): boolean { + return /\.slnx$/iu.test(entry); +} + +function isStrongDotnetTestProject(source: string): boolean { + const activeSource = stripXmlComments(source); + return hasDotnetTestFrameworkEvidence(activeSource); +} + +function hasDotnetTestFrameworkEvidence(source: string): boolean { + return ( + /\s*true\s*<\/IsTestProject>/iu.test(source) || + /]*\bSdk\s*=\s*["']MSTest\.Sdk(?:\/|["'])/iu.test(source) || + /]*\bName\s*=\s*["']MSTest\.Sdk["']/iu.test(source) || + /]*\bInclude\s*=\s*["'](?:Microsoft\.NET\.Test\.Sdk|xunit|xunit\.v3|NUnit|NUnit3TestAdapter|MSTest\.TestFramework|Microsoft\.Testing\.Platform\.MSBuild|TUnit)["']/iu.test( + source, + ) + ); +} + +function stripXmlComments(source: string): string { + return source.replace(//gu, ""); +} + +function isDotnetWebProject(source: string): boolean { + return ( + /]*\bSdk\s*=\s*["']Microsoft\.NET\.Sdk\.Web(?:\/|["'])/iu.test(source) || + /]*\bName\s*=\s*["']Microsoft\.NET\.Sdk\.Web["']/iu.test(source) || + /]*\bInclude\s*=\s*["']Microsoft\.AspNetCore\.App["']/iu.test(source) || + /]*\bInclude\s*=\s*["']Microsoft\.AspNetCore\./iu.test(source) + ); +} + function stripLineComments(source: string, marker: "#" | "//"): string { return source .split("\n") diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 994d5ad..7661c92 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -12971,7 +12971,6 @@ let package = Package( expect(core?.entrypoints[0]?.path).toBe("Sources/Core.swift"); }); - it("detects Mix and Phoenix projects with useful default commands", async () => { const root = await fixtureRoot("clawpatch-elixir-detect-"); await writeFixture( @@ -13194,4 +13193,654 @@ end expect(owned).toContain("lib/deps/client.ts"); }); + + it("maps C# .NET projects, ASP.NET endpoints, and associated test projects", async () => { + const root = await fixtureRoot("clawpatch-dotnet-map-"); + await writeFixture( + root, + "TodoApp.sln", + `Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{00000000-0000-0000-0000-000000000000}") = "Todo.Api", "src\\Todo.Api\\Todo.Api.csproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "Todo.Api.Tests", "tests\\Todo.Api.Tests\\Todo.Api.Tests.csproj", "{22222222-2222-2222-2222-222222222222}" +EndProject +`, + ); + await writeFixture(root, "global.json", '{ "sdk": { "version": "9.0.100" } }\n'); + await writeFixture(root, "Directory.Build.props", "\n"); + await writeFixture( + root, + "src/Todo.Api/Todo.Api.csproj", + ` + + net9.0 + + +`, + ); + await writeFixture( + root, + "src/Todo.Api/Program.cs", + `var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapGet("/health", () => Results.Ok()); +app.MapGet("/todos", () => Results.Ok()); +app.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo)); +app.MapFallbackToFile("index.html"); +app.MapFallbackToFile("/{*path:nonfile}", "index.html"); +app.Run(); +public sealed record Todo(string Id); +`, + ); + await writeFixture( + root, + "src/Todo.Api/Controllers/TodoController.cs", + `using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/[controller]")] +public sealed class TodoController : ControllerBase +{ + [HttpGet("{id}")] + public IActionResult Get(string id) => Ok(id); + + [HttpGet(Name = "ListTodos")] + public IActionResult List() => Ok(); + + [HttpPost] + public IActionResult Create() => Created(); +} +`, + ); + await writeFixture( + root, + "src/Todo.Api/Services/TodoService.cs", + "public sealed class TodoService {}\n", + ); + await writeFixture( + root, + "src/Todo.Api/Generated/TodoClient.g.cs", + "public sealed class GeneratedClient {}\n", + ); + await writeFixture( + root, + "src/Todo.Api/obj/Debug/net9.0/Todo.Api.AssemblyInfo.cs", + "public sealed class AssemblyInfo {}\n", + ); + await writeFixture( + root, + "tests/Todo.Api.Tests/Todo.Api.Tests.csproj", + ` + + + + + + +`, + ); + await writeFixture( + root, + "tests/Todo.Api.Tests/TodoControllerTests.cs", + "public sealed class TodoControllerTests {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const health = result.features.find( + (feature) => feature.title === "ASP.NET endpoint GET /health", + ); + const controller = result.features.find( + (feature) => feature.title === "ASP.NET controller TodoController", + ); + const ownedFiles = new Set( + result.features.flatMap((feature) => feature.ownedFiles.map((file) => file.path)), + ); + + expect(project.detected.languages).toContain("csharp"); + expect(project.detected.packageManagers).toContain("dotnet"); + expect(project.detected.frameworks).toEqual( + expect.arrayContaining(["aspnetcore", "dotnet-test"]), + ); + expect(project.detected.commands).toMatchObject({ + typecheck: "dotnet build TodoApp.sln", + test: "dotnet test TodoApp.sln", + }); + expect(titles).toContain(".NET project Todo.Api"); + expect(titles).toContain(".NET project Todo.Api.Tests"); + expect(titles).toContain("C# test suite Todo.Api.Tests"); + expect(titles).toContain("ASP.NET endpoint GET /health"); + expect(titles).toContain("ASP.NET endpoint GET /todos"); + expect(titles).toContain("ASP.NET endpoint POST /todos"); + expect(titles).toContain("ASP.NET endpoint FALLBACKTOFILE /{*path:nonfile}"); + expect(titles).not.toContain("ASP.NET endpoint FALLBACKTOFILE /index.html"); + expect(titles).toContain("ASP.NET controller TodoController"); + expect(titles).toContain("C# source src/Todo.Api"); + expect(titles).toContain("Project config global.json"); + expect(health?.tests).toEqual([ + { + path: "tests/Todo.Api.Tests/TodoControllerTests.cs", + command: "dotnet test tests/Todo.Api.Tests/Todo.Api.Tests.csproj", + }, + ]); + expect(health?.contextFiles).toContainEqual({ + path: "tests/Todo.Api.Tests/TodoControllerTests.cs", + reason: "associated test", + }); + expect(controller?.summary).not.toContain("ListTodos"); + expect(ownedFiles).not.toContain("src/Todo.Api/Generated/TodoClient.g.cs"); + expect(ownedFiles).not.toContain("src/Todo.Api/obj/Debug/net9.0/Todo.Api.AssemblyInfo.cs"); + }); + + it("preserves ASP.NET minimal API route group prefixes", async () => { + const root = await fixtureRoot("clawpatch-dotnet-map-group-"); + await writeFixture( + root, + "src/Grouped.Api/Grouped.Api.csproj", + ` + + net9.0 + + +`, + ); + await writeFixture( + root, + "src/Grouped.Api/Program.cs", + `var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapGroup("/v1").MapGet("/users", () => Results.Ok()); +app.MapGroup("/v2").MapGet("/users", () => Results.Ok()); +var admin = app.MapGroup("/admin"); +admin.MapGet("/users", () => Results.Ok()); +var reports = admin.MapGroup("/reports"); +reports.MapPost("/{id}", () => Results.Ok()); +RouteGroupBuilder ApiGroup(WebApplication app) => + app.MapGroup("/api") + .WithTags("api"); +var helperGroup = ApiGroup(app); +helperGroup.MapDelete("/teams/{id}", () => Results.Ok()); +app.Run(); +`, + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const endpointTitles = result.features + .map((feature) => feature.title) + .filter((title) => title.startsWith("ASP.NET endpoint ")); + + expect(endpointTitles).toEqual( + expect.arrayContaining([ + "ASP.NET endpoint GET /v1/users", + "ASP.NET endpoint GET /v2/users", + "ASP.NET endpoint GET /admin/users", + "ASP.NET endpoint POST /admin/reports/{id}", + "ASP.NET endpoint DELETE /api/teams/{id}", + ]), + ); + expect(endpointTitles).not.toContain("ASP.NET endpoint GET /users"); + }); + + it("keeps .NET validation commands conservative for ambiguous workspaces", async () => { + const root = await fixtureRoot("clawpatch-dotnet-ambiguous-"); + await writeFixture(root, "First.sln", ""); + await writeFixture(root, "Second.sln", ""); + await writeFixture(root, "src/App/App.csproj", '\n'); + await writeFixture(root, "src/Lib/Lib.csproj", '\n'); + await writeFixture( + root, + "tests/App.Tests/App.Tests.csproj", + ` + + + + +`, + ); + + const project = await detectProject(root); + + expect(project.detected.languages).toContain("csharp"); + expect(project.detected.packageManagers).toContain("dotnet"); + expect(project.detected.commands.typecheck).toBeNull(); + expect(project.detected.commands.test).toBe("dotnet test tests/App.Tests/App.Tests.csproj"); + }); + + it("targets a lone .NET test project when the build solution omits it", async () => { + const root = await fixtureRoot("clawpatch-dotnet-solution-missing-test-"); + await writeFixture( + root, + "App.sln", + `Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{00000000-0000-0000-0000-000000000000}") = "App", "src\\App\\App.csproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +`, + ); + await writeFixture(root, "src/App/App.csproj", '\n'); + await writeFixture( + root, + "tests/App.Tests/App.Tests.csproj", + ` + + + + +`, + ); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBe("dotnet build App.sln"); + expect(project.detected.commands.test).toBe("dotnet test tests/App.Tests/App.Tests.csproj"); + }); + + it("does not build a root .NET solution that omits non-test projects", async () => { + const root = await fixtureRoot("clawpatch-dotnet-solution-missing-project-"); + await writeFixture( + root, + "App.sln", + `Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{00000000-0000-0000-0000-000000000000}") = "App", "src\\App\\App.csproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +`, + ); + await writeFixture(root, "src/App/App.csproj", '\n'); + await writeFixture(root, "src/Lib/Lib.csproj", '\n'); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBeNull(); + }); + + it("prefers a root .NET project over an unrelated nested solution", async () => { + const root = await fixtureRoot("clawpatch-dotnet-root-project-nested-solution-"); + await writeFixture(root, "App.csproj", '\n'); + await writeFixture(root, "Program.cs", "public sealed class Program {}\n"); + await writeFixture( + root, + "tools/Tool.sln", + `Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{00000000-0000-0000-0000-000000000000}") = "Tool", "Tool.csproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +`, + ); + await writeFixture(root, "tools/Tool.csproj", '\n'); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBe("dotnet build App.csproj"); + }); + + it("ignores .NET test metadata inside XML comments", async () => { + const root = await fixtureRoot("clawpatch-dotnet-commented-test-metadata-"); + await writeFixture( + root, + "src/App/App.csproj", + ` + + +`, + ); + await writeFixture(root, "src/App/Program.cs", "public sealed class Program {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const appProject = result.features.find((feature) => feature.title === ".NET project App"); + const titles = result.features.map((feature) => feature.title); + + expect(project.detected.frameworks).not.toContain("dotnet-test"); + expect(project.detected.frameworks).not.toContain("aspnetcore"); + expect(project.detected.commands).toMatchObject({ + typecheck: "dotnet build src/App/App.csproj", + test: null, + }); + expect(titles).toContain(".NET project App"); + expect(titles).toContain("C# source src/App"); + expect(titles).not.toContain("C# test suite App"); + expect(appProject?.kind).toBe("library"); + expect(appProject?.tags).not.toContain("aspnetcore"); + expect(appProject?.tags).not.toContain("worker"); + }); + + it("does not treat Program.cs as ASP.NET evidence for ordinary C# projects", async () => { + const root = await fixtureRoot("clawpatch-dotnet-console-program-controller-"); + await writeFixture(root, "src/App/App.csproj", '\n'); + await writeFixture(root, "src/App/Program.cs", 'Console.WriteLine("hello");\n'); + await writeFixture( + root, + "src/App/Domain/MotorController.cs", + "public sealed class MotorController {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const appSource = result.features.find((feature) => feature.title === "C# source src/App"); + const titles = result.features.map((feature) => feature.title); + + expect(project.detected.frameworks).not.toContain("aspnetcore"); + expect(titles).not.toContain("ASP.NET controller MotorController"); + expect(appSource?.ownedFiles).toEqual( + expect.arrayContaining([ + { path: "src/App/Program.cs", reason: "C# source group src/App" }, + { path: "src/App/Domain/MotorController.cs", reason: "C# source group src/App" }, + ]), + ); + }); + + it("discovers first-party .NET projects under packages workspaces", async () => { + const root = await fixtureRoot("clawpatch-dotnet-packages-workspace-"); + await writeFixture(root, "packages/Foo/Foo.csproj", '\n'); + await writeFixture(root, "packages/Foo/Service.cs", "public sealed class Service {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + + expect(project.detected.languages).toContain("csharp"); + expect(project.detected.packageManagers).toContain("dotnet"); + expect(project.detected.commands).toMatchObject({ + typecheck: "dotnet build packages/Foo/Foo.csproj", + test: null, + }); + expect(titles).toContain(".NET project Foo"); + expect(titles).toContain("C# source packages/Foo"); + }); + + it("targets the discovered .NET test project when the build target is an app project", async () => { + const root = await fixtureRoot("clawpatch-dotnet-root-app-nested-test-"); + await writeFixture(root, "My App.csproj", '\n'); + await writeFixture( + root, + "tests/My App.Tests/My App.Tests.csproj", + ` + + + + +`, + ); + + const project = await detectProject(root); + + expect(project.detected.commands.typecheck).toBe('dotnet build "My App.csproj"'); + expect(project.detected.commands.test).toBe( + 'dotnet test "tests/My App.Tests/My App.Tests.csproj"', + ); + }); + + it("detects TUnit projects without Microsoft.NET.Test.Sdk as .NET test projects", async () => { + const root = await fixtureRoot("clawpatch-dotnet-tunit-test-"); + await writeFixture(root, "src/App/App.csproj", '\n'); + await writeFixture(root, "src/App/Program.cs", "public sealed class Program {}\n"); + await writeFixture( + root, + "tests/App.TUnit/App.TUnit.csproj", + ` + + + + + +`, + ); + await writeFixture( + root, + "tests/App.TUnit/ProgramTests.cs", + "public sealed class ProgramTests {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const testSuite = result.features.find( + (feature) => feature.title === "C# test suite App.TUnit", + ); + const appSource = result.features.find((feature) => feature.title === "C# source src/App"); + + expect(project.detected.frameworks).toContain("dotnet-test"); + expect(project.detected.commands.test).toBe("dotnet test tests/App.TUnit/App.TUnit.csproj"); + expect(testSuite?.tests).toEqual([ + { + path: "tests/App.TUnit/ProgramTests.cs", + command: "dotnet test tests/App.TUnit/App.TUnit.csproj", + }, + ]); + expect(appSource?.tests).toEqual([ + { + path: "tests/App.TUnit/ProgramTests.cs", + command: "dotnet test tests/App.TUnit/App.TUnit.csproj", + }, + ]); + }); + + it("keeps dotnet test commands for test projects without C# source files", async () => { + const root = await fixtureRoot("clawpatch-dotnet-empty-test-group-"); + await writeFixture(root, "src/App/App.csproj", '\n'); + await writeFixture(root, "src/App/Program.cs", "public sealed class Program {}\n"); + await writeFixture( + root, + "tests/App.Tests/App.Tests.fsproj", + ` + + + + + +`, + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const testSuite = result.features.find( + (feature) => feature.title === "F# test suite App.Tests", + ); + const appSource = result.features.find((feature) => feature.title === "C# source src/App"); + + expect(testSuite?.tests).toEqual([ + { + path: "tests/App.Tests/App.Tests.fsproj", + command: "dotnet test tests/App.Tests/App.Tests.fsproj", + }, + ]); + expect(appSource?.tests).toEqual([ + { + path: "tests/App.Tests/App.Tests.fsproj", + command: "dotnet test tests/App.Tests/App.Tests.fsproj", + }, + ]); + }); + + it("maps F# and Visual Basic source groups with solution context", async () => { + const root = await fixtureRoot("clawpatch-dotnet-fsharp-vb-source-"); + await writeFixture( + root, + "solutions/App.sln", + `Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{00000000-0000-0000-0000-000000000000}") = "FsLib", "..\\src\\FsLib\\FsLib.fsproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "FsLib.Tests", "..\\tests\\FsLib.Tests\\FsLib.Tests.fsproj", "{22222222-2222-2222-2222-222222222222}" +EndProject +`, + ); + await writeFixture( + root, + "solutions/App.slnx", + '\n', + ); + await writeFixture(root, "src/FsLib/FsLib.fsproj", '\n'); + await writeFixture(root, "src/FsLib/Library.fs", 'module Library\nlet hello = "world"\n'); + await writeFixture( + root, + "tests/FsLib.Tests/FsLib.Tests.fsproj", + ` + + + + + +`, + ); + await writeFixture(root, "tests/FsLib.Tests/Tests.fs", "module Tests\n"); + await writeFixture(root, "src/VbApp/VbApp.vbproj", '\n'); + await writeFixture(root, "src/VbApp/Program.vb", "Module Program\nEnd Module\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const fsProject = result.features.find((feature) => feature.title === ".NET project FsLib"); + const fsSource = result.features.find((feature) => feature.title === "F# source src/FsLib"); + const vbProject = result.features.find((feature) => feature.title === ".NET project VbApp"); + const vbSource = result.features.find( + (feature) => feature.title === "Visual Basic source src/VbApp", + ); + + expect(project.detected.languages).toEqual(expect.arrayContaining(["fsharp", "visual-basic"])); + expect(fsProject?.contextFiles).toContainEqual({ + path: "solutions/App.sln", + reason: "solution context", + }); + expect(vbProject?.contextFiles).toContainEqual({ + path: "solutions/App.slnx", + reason: "solution context", + }); + expect(fsSource?.ownedFiles).toEqual([ + { path: "src/FsLib/Library.fs", reason: "F# source group src/FsLib" }, + ]); + expect(fsSource?.tests).toEqual([ + { + path: "tests/FsLib.Tests/Tests.fs", + command: "dotnet test tests/FsLib.Tests/FsLib.Tests.fsproj", + }, + ]); + expect(vbSource?.ownedFiles).toEqual([ + { path: "src/VbApp/Program.vb", reason: "Visual Basic source group src/VbApp" }, + ]); + }); + + it("excludes nested .NET project roots from parent C# source groups", async () => { + const root = await fixtureRoot("clawpatch-dotnet-nested-project-root-"); + await writeFixture(root, "src/App/App.csproj", '\n'); + await writeFixture(root, "src/App/Program.cs", "public sealed class Program {}\n"); + await writeFixture( + root, + "src/App/tests/App.Tests/App.Tests.csproj", + ` + + + + + +`, + ); + await writeFixture( + root, + "src/App/tests/App.Tests/ProgramTests.cs", + "public sealed class ProgramTests {}\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const appSource = result.features.find((feature) => feature.title === "C# source src/App"); + const testSuite = result.features.find( + (feature) => feature.title === "C# test suite App.Tests", + ); + + expect(appSource?.ownedFiles).toEqual([ + { path: "src/App/Program.cs", reason: "C# source group src/App" }, + ]); + expect(testSuite?.ownedFiles).toEqual([ + { + path: "src/App/tests/App.Tests/ProgramTests.cs", + reason: "C# test group src/App/tests/App.Tests", + }, + ]); + }); + + it("does not report dotnet as a package manager for manifestless C# source", async () => { + const root = await fixtureRoot("clawpatch-csharp-source-only-"); + await writeFixture(root, "src/Helpers/Thing.cs", "public sealed class Thing {}\n"); + await writeFixture(root, "src/Helpers/Thing.g.cs", "public sealed class GeneratedThing {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find((feature) => feature.title === "C# source src"); + const titles = result.features.map((feature) => feature.title); + + expect(project.detected.languages).toContain("csharp"); + expect(project.detected.packageManagers).not.toContain("dotnet"); + expect(project.detected.commands).toEqual({ + typecheck: null, + lint: null, + format: null, + test: null, + }); + expect(titles).not.toContain(".NET project Thing"); + expect(source?.ownedFiles).toEqual([ + { path: "src/Helpers/Thing.cs", reason: "C# source group src" }, + ]); + }); + + it("keeps Ruby validation defaults when a Ruby project has source-only C#", async () => { + const root = await fixtureRoot("clawpatch-ruby-csharp-source-only-"); + await writeFixture( + root, + "Gemfile", + "source 'https://rubygems.org'\ngem 'rspec'\ngem 'rubocop'\n", + ); + await writeFixture(root, "lib/fixture.rb", "module Fixture\nend\n"); + await writeFixture(root, "src/Helpers/Thing.cs", "public sealed class Thing {}\n"); + + const project = await detectProject(root); + + expect(project.detected.languages).toEqual(expect.arrayContaining(["ruby", "csharp"])); + expect(project.detected.packageManagers).toContain("bundler"); + expect(project.detected.packageManagers).not.toContain("dotnet"); + expect(project.detected.commands).toMatchObject({ + lint: "bundle exec rubocop", + test: "bundle exec rspec", + }); + }); + + it("skips fixture and testdata .NET projects", async () => { + const root = await fixtureRoot("clawpatch-dotnet-samples-"); + await writeFixture(root, "src/App/App.csproj", '\n'); + await writeFixture(root, "src/App/Service.cs", "public sealed class Service {}\n"); + await writeFixture( + root, + "fixtures/Sample/Sample.csproj", + '\n', + ); + await writeFixture(root, "fixtures/Sample/Sample.cs", "public sealed class Sample {}\n"); + await writeFixture( + root, + "testdata/Example/Example.csproj", + '\n', + ); + await writeFixture(root, "testdata/Example/Example.cs", "public sealed class Example {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const ownedFiles = result.features.flatMap((feature) => + feature.ownedFiles.map((file) => file.path), + ); + + expect(titles).toContain(".NET project App"); + expect(titles).not.toContain(".NET project Sample"); + expect(titles).not.toContain(".NET project Example"); + expect(ownedFiles).not.toContain("fixtures/Sample/Sample.cs"); + expect(ownedFiles).not.toContain("testdata/Example/Example.cs"); + }); }); diff --git a/src/mapper.ts b/src/mapper.ts index 69cd7dc..efa6d0e 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -2,6 +2,7 @@ import { nowIso } from "./fs.js"; import { stableId } from "./id.js"; import { cCppSeeds } from "./mappers/c-cpp.js"; import { configSeeds } from "./mappers/config.js"; +import { dotnetSeeds } from "./mappers/dotnet.js"; import { elixirSeeds } from "./mappers/elixir.js"; import { goSeeds } from "./mappers/go.js"; import { appleSeeds } from "./mappers/apple.js"; @@ -49,6 +50,7 @@ const featureMappers: FeatureMapper[] = [ { name: "ruby", map: rubySeeds }, { name: "elixir", map: elixirSeeds }, { name: "rust", map: rustSeeds }, + { name: "dotnet", map: dotnetSeeds }, { name: "c-cpp", map: cCppSeeds }, { name: "swift", map: swiftSeeds }, { name: "apple", map: appleSeeds }, @@ -255,7 +257,7 @@ function dedupeSeeds(seeds: FeatureSeed[]): FeatureSeed[] { const seen = new Set(); const output: FeatureSeed[] = []; for (const seed of seeds) { - const key = `${seed.kind}:${seed.source}:${seed.entryPath}:${seed.command ?? seed.route ?? seed.symbol ?? ""}`; + const key = `${seed.kind}:${seed.source}:${seed.entryPath}:${seed.identityKey ?? seed.command ?? seed.route ?? seed.symbol ?? ""}`; if (seen.has(key)) { continue; } diff --git a/src/mappers/config.ts b/src/mappers/config.ts index 64b327b..beee87a 100644 --- a/src/mappers/config.ts +++ b/src/mappers/config.ts @@ -14,6 +14,12 @@ export async function configSeeds(root: string): Promise { "Cargo.lock", "rust-toolchain.toml", "Package.swift", + "global.json", + "Directory.Build.props", + "Directory.Build.targets", + "Directory.Packages.props", + "Directory.Packages.targets", + "NuGet.config", "composer.json", "composer.lock", "phpunit.xml", diff --git a/src/mappers/dotnet.ts b/src/mappers/dotnet.ts new file mode 100644 index 0000000..0308397 --- /dev/null +++ b/src/mappers/dotnet.ts @@ -0,0 +1,1110 @@ +import { readFile } from "node:fs/promises"; +import { basename, dirname, extname, join } from "node:path"; +import { shellQuotePath } from "../shell.js"; +import { TrustBoundary } from "../types.js"; +import { partitionFileGroups } from "./grouping.js"; +import { isSampleProjectPath, normalize, pathMatchesPrefix, shouldSkip, walk } from "./shared.js"; +import { FeatureSeed, SeedFileRef, SeedTestRef } from "./types.js"; + +const maxOwnedFiles = 12; +const maxTests = 8; + +type DotnetProject = { + path: string; + root: string; + name: string; + language: "csharp" | "fsharp" | "visual-basic"; + sdk: string | null; + source: string; + packageReferences: string[]; + frameworkReferences: string[]; + projectReferences: string[]; + isTest: boolean; + isStrongTest: boolean; + isWeb: boolean; + isWorker: boolean; + sourceFiles: string[]; +}; + +type DotnetSolution = { + path: string; + projectPaths: string[]; +}; + +export async function dotnetSeeds(root: string): Promise { + const files = await walk(root, [""], shouldSkipDotnetPath); + const fileSet = new Set(files); + const solutions = await dotnetSolutions(root, files.filter(isDotnetSolutionPath)); + const projectPaths = uniqueStrings([ + ...files.filter(isDotnetProjectPath), + ...solutions.flatMap((solution) => solution.projectPaths), + ]).filter((path) => fileSet.has(path)); + const sourceFiles = files + .filter(isDotnetSourcePath) + .filter((path) => !isGeneratedDotnetSourcePath(path)); + if (projectPaths.length === 0 && sourceFiles.length === 0) { + return []; + } + + const configs = dotnetConfigFiles(files); + if (projectPaths.length === 0) { + return sourceOnlyCsharpSeeds(sourceFiles, configs); + } + + const projects = await dotnetProjects(root, projectPaths, sourceFiles); + const solutionContextsByProject = solutionContextsByProjectPath(solutions); + const testProjects = projects.filter((project) => project.isTest); + const routeOwnedFiles = new Set(); + const seeds: FeatureSeed[] = []; + + for (const project of projects) { + seeds.push( + projectSeed(project, configs, solutionContextsByProject.get(project.path) ?? [], projects), + ); + } + + for (const project of testProjects) { + seeds.push(...testProjectSeeds(project, configs)); + } + + for (const project of projects.filter((candidate) => candidate.language === "csharp")) { + if (project.isTest) { + continue; + } + const tests = associatedTests(project, testProjects); + for (const seed of await aspNetRouteSeeds(root, project, tests)) { + for (const owned of seed.ownedFiles ?? []) { + routeOwnedFiles.add(owned.path); + } + seeds.push(seed); + } + } + + for (const project of projects) { + if (project.isTest) { + continue; + } + const groupSourceFiles = + project.language === "csharp" + ? project.sourceFiles.filter((path) => !routeOwnedFiles.has(path)) + : project.sourceFiles; + const tests = associatedTests(project, testProjects); + for (const group of dotnetSourceGroups(project, groupSourceFiles)) { + seeds.push(sourceGroupSeed(project, group, tests)); + } + } + + return seeds; +} + +async function dotnetProjects( + root: string, + projectPaths: string[], + sourceFiles: string[], +): Promise { + const projects = await Promise.all( + projectPaths.toSorted().map((path) => readDotnetProject(root, path)), + ); + const projectRoots = projects.map((project) => ({ path: project.path, root: project.root })); + const knownProjectPaths = new Set(projects.map((project) => project.path)); + return projects.map((project) => ({ + ...project, + projectReferences: project.projectReferences.filter((path) => knownProjectPaths.has(path)), + sourceFiles: projectSourceFiles(project, sourceFiles, projectRoots), + })); +} + +async function dotnetSolutions(root: string, paths: string[]): Promise { + const solutions: DotnetSolution[] = []; + for (const path of paths.toSorted()) { + const source = await readFile(join(root, path), "utf8").catch(() => ""); + const solutionRoot = projectRoot(path); + const projectPaths = isDotnetSlnxPath(path) + ? slnxProjectPaths(source, solutionRoot) + : slnProjectPaths(source, solutionRoot); + solutions.push({ path, projectPaths }); + } + return solutions; +} + +function solutionContextsByProjectPath(solutions: DotnetSolution[]): Map { + const byProject = new Map(); + for (const solution of solutions) { + for (const projectPath of solution.projectPaths) { + const refs = byProject.get(projectPath) ?? []; + refs.push({ path: solution.path, reason: "solution context" }); + byProject.set(projectPath, refs); + } + } + return byProject; +} + +function slnProjectPaths(source: string, solutionRoot: string): string[] { + const paths: string[] = []; + const pattern = /^Project\([^)]+\)\s*=\s*"[^"]*"\s*,\s*"([^"]+\.(?:cs|fs|vb)proj)"/gimu; + for (const match of source.matchAll(pattern)) { + const path = solutionProjectPath(solutionRoot, match[1] ?? ""); + if (path !== null) { + paths.push(path); + } + } + return uniqueStrings(paths).toSorted(); +} + +function slnxProjectPaths(source: string, solutionRoot: string): string[] { + const paths: string[] = []; + for (const match of source.matchAll(/\bPath\s*=\s*["']([^"']+\.(?:cs|fs|vb)proj)["']/gimu)) { + const path = solutionProjectPath(solutionRoot, match[1] ?? ""); + if (path !== null) { + paths.push(path); + } + } + return uniqueStrings(paths).toSorted(); +} + +function solutionProjectPath(solutionRoot: string, path: string): string | null { + const normalized = normalizeMsbuildPath(path); + if (normalized.length === 0 || /^(?:[A-Za-z]:)?\//u.test(normalized)) { + return null; + } + const resolved = normalize(join(solutionRoot === "." ? "" : solutionRoot, normalized)); + if (resolved === "." || resolved === ".." || resolved.startsWith("../")) { + return null; + } + return resolved; +} + +async function readDotnetProject(root: string, path: string): Promise { + const source = await readFile(join(root, path), "utf8").catch(() => ""); + const activeSource = stripXmlComments(source); + const name = + xmlElementValue(activeSource, "AssemblyName") ?? + xmlElementValue(activeSource, "RootNamespace") ?? + basename(path, extname(path)); + const packageReferences = xmlAttributeValues(activeSource, "PackageReference", "Include"); + const frameworkReferences = xmlAttributeValues(activeSource, "FrameworkReference", "Include"); + const sdk = dotnetProjectSdk(activeSource); + const projectReferences = xmlAttributeValues(activeSource, "ProjectReference", "Include").map( + (ref) => normalize(join(dirname(path), normalizeMsbuildPath(ref))), + ); + const strongTest = isStrongTestProject(activeSource); + return { + path, + root: projectRoot(path), + name, + language: projectLanguage(path), + sdk, + source: activeSource, + packageReferences, + frameworkReferences, + projectReferences, + isTest: strongTest || isLikelyTestProjectPath(path), + isStrongTest: strongTest, + isWeb: isWebProject(activeSource, packageReferences, frameworkReferences), + isWorker: isWorkerProject(activeSource, packageReferences), + sourceFiles: [], + }; +} + +function projectSeed( + project: DotnetProject, + configs: SeedFileRef[], + solutions: SeedFileRef[], + projects: DotnetProject[], +): FeatureSeed { + const referenceContext = project.projectReferences + .filter((path) => projects.some((candidate) => candidate.path === path)) + .map((path) => ({ path, reason: "project reference" })); + return { + title: `.NET project ${project.name}`, + summary: `${dotnetLanguageName(project.language)} project ${project.path}.`, + kind: projectKind(project), + source: "dotnet-project", + confidence: project.language === "csharp" ? "high" : "medium", + entryPath: project.path, + symbol: project.name, + route: null, + command: null, + ownedFiles: [{ path: project.path, reason: "project file" }], + contextFiles: uniqueFileRefs([...configs, ...solutions, ...referenceContext]), + tags: projectTags(project, "project"), + trustBoundaries: projectTrustBoundaries(project), + skipNearbyTests: true, + }; +} + +function testProjectSeeds(project: DotnetProject, configs: SeedFileRef[]): FeatureSeed[] { + const groups = + project.sourceFiles.length === 0 + ? [{ label: project.name, files: [] }] + : dotnetSourceGroups(project, project.sourceFiles); + const multipleGroups = groups.length > 1; + return groups.map((group) => { + const languageName = dotnetLanguageName(project.language); + const tests = testRefsForFiles(project, group.files); + const ownedFiles = + group.files.length === 0 + ? [{ path: project.path, reason: "test project file" }] + : group.files.map((path) => ({ + path, + reason: `${languageName} test group ${group.label}`, + })); + return { + title: multipleGroups + ? `${languageName} test suite ${group.label}` + : `${languageName} test suite ${project.name}`, + summary: + group.files.length === 0 + ? `${languageName} test project ${project.path}.` + : `${languageName} test group ${group.label} with ${group.files.length} source file(s).`, + kind: "test-suite", + source: "dotnet-test-project", + confidence: project.isStrongTest ? "high" : "medium", + entryPath: group.files[0] ?? project.path, + symbol: multipleGroups ? group.label : project.name, + route: null, + command: null, + ownedFiles, + contextFiles: uniqueFileRefs([ + { path: project.path, reason: "test project file" }, + ...configs, + ]), + tests, + tags: projectTags(project, "test"), + trustBoundaries: [], + testCommand: dotnetTestCommand(project), + skipNearbyTests: true, + }; + }); +} + +async function aspNetRouteSeeds( + root: string, + project: DotnetProject, + tests: SeedTestRef[], +): Promise { + if (!project.isWeb && !project.sourceFiles.some((path) => isAspNetRouteConventionPath(path))) { + return []; + } + const seeds: FeatureSeed[] = []; + for (const path of project.sourceFiles) { + const source = stripCsharpComments(await readFile(join(root, path), "utf8").catch(() => "")); + const controller = controllerInfo(path, source); + if (controller !== null) { + seeds.push(controllerSeed(project, path, controller, tests)); + continue; + } + for (const endpoint of minimalApiEndpoints(source)) { + seeds.push(minimalApiSeed(project, path, endpoint, tests)); + } + } + return seeds; +} + +function controllerSeed( + project: DotnetProject, + path: string, + controller: { name: string; routes: string[] }, + tests: SeedTestRef[], +): FeatureSeed { + const route = controller.routes[0] ?? null; + return { + title: `ASP.NET controller ${controller.name}`, + summary: + controller.routes.length === 0 + ? `ASP.NET controller ${controller.name} in ${path}.` + : `ASP.NET controller ${controller.name} in ${path}; routes ${controller.routes.join(", ")}.`, + kind: "route", + source: "dotnet-aspnet-controller", + confidence: controller.routes.length === 0 ? "medium" : "high", + entryPath: path, + identityKey: controller.name, + symbol: controller.name, + route, + command: null, + ownedFiles: [{ path, reason: "ASP.NET controller" }], + contextFiles: routeContextFiles(project, tests), + tests, + tags: projectTags(project, "aspnet", "controller"), + trustBoundaries: aspNetTrustBoundaries(route, path), + testCommand: tests[0]?.command ?? null, + skipNearbyTests: true, + }; +} + +function minimalApiSeed( + project: DotnetProject, + path: string, + endpoint: { method: string; route: string }, + tests: SeedTestRef[], +): FeatureSeed { + const method = endpoint.method === "METHODS" ? "HTTP" : endpoint.method; + return { + title: `ASP.NET endpoint ${method} ${endpoint.route}`, + summary: `ASP.NET minimal API endpoint ${method} ${endpoint.route} in ${path}.`, + kind: "route", + source: "dotnet-minimal-api-route", + confidence: "high", + entryPath: path, + identityKey: `${method}:${endpoint.route}`, + symbol: null, + route: endpoint.route, + command: null, + ownedFiles: [{ path, reason: "ASP.NET minimal API endpoint" }], + contextFiles: routeContextFiles(project, tests), + tests, + tags: projectTags(project, "aspnet", "minimal-api"), + trustBoundaries: aspNetTrustBoundaries(endpoint.route, path), + testCommand: tests[0]?.command ?? null, + skipNearbyTests: true, + }; +} + +function sourceGroupSeed( + project: DotnetProject, + group: { label: string; files: string[] }, + tests: SeedTestRef[], +): FeatureSeed { + const kind = sourceGroupKind(project, group.label); + const languageName = dotnetLanguageName(project.language); + return { + title: `${languageName} source ${group.label}`, + summary: + group.files.length === 1 + ? `${languageName} source file ${group.files[0]} in project ${project.name}.` + : `${languageName} source group ${group.label} with ${group.files.length} files in project ${project.name}.`, + kind, + source: "dotnet-source-group", + confidence: "medium", + entryPath: group.files[0] ?? project.path, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ + path, + reason: `${languageName} source group ${group.label}`, + })), + contextFiles: sourceContextFiles(project, tests), + tests, + tags: projectTags(project, "source-group"), + trustBoundaries: sourceGroupTrustBoundaries(project, group.label), + testCommand: tests[0]?.command ?? null, + skipNearbyTests: true, + }; +} + +function sourceOnlyCsharpSeeds(sourceFiles: string[], configs: SeedFileRef[]): FeatureSeed[] { + return sourceOnlyCsharpGroups( + sourceFiles.filter((path) => isProjectLanguageSourcePath(path, "csharp")), + ).map((group) => sourceOnlyCsharpSeed(group, configs)); +} + +function sourceOnlyCsharpSeed( + group: { label: string; files: string[] }, + configs: SeedFileRef[], +): FeatureSeed { + return { + title: `C# source ${group.label}`, + summary: + group.files.length === 1 + ? `C# source file ${group.files[0]} without a .NET project file.` + : `C# source group ${group.label} with ${group.files.length} files without a .NET project file.`, + kind: sourceOnlyCsharpKind(group.label), + source: "dotnet-csharp-source-only", + confidence: "medium", + entryPath: group.files[0] ?? "", + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ + path, + reason: `C# source group ${group.label}`, + })), + contextFiles: configs, + tests: [], + tags: ["dotnet", "csharp", "source-only", "source-group"], + trustBoundaries: sourceOnlyCsharpTrustBoundaries(group.label, group.files), + testCommand: null, + skipNearbyTests: true, + }; +} + +function dotnetSourceGroups( + project: DotnetProject, + files: string[], +): Array<{ label: string; files: string[] }> { + if (files.length === 0) { + return []; + } + const sorted = files.toSorted(); + if (project.root !== ".") { + return partitionFileGroups(project.root, sorted, maxOwnedFiles); + } + const topLevelSegments = new Set(sorted.map((path) => path.split("/")[0] ?? path)); + if (topLevelSegments.size === 1) { + const root = sorted[0]?.split("/")[0]; + if (root !== undefined && sorted.every((path) => path.startsWith(`${root}/`))) { + return partitionFileGroups(root, sorted, maxOwnedFiles); + } + } + const groups: Array<{ label: string; files: string[] }> = []; + for (let index = 0; index < sorted.length; index += maxOwnedFiles) { + groups.push({ + label: + index === 0 ? project.name : `${project.name}#${Math.floor(index / maxOwnedFiles) + 1}`, + files: sorted.slice(index, index + maxOwnedFiles), + }); + } + return groups; +} + +function sourceOnlyCsharpGroups(files: string[]): Array<{ label: string; files: string[] }> { + if (files.length === 0) { + return []; + } + const sorted = files.toSorted(); + const topLevelSegments = new Set(sorted.map((path) => path.split("/")[0] ?? path)); + if (topLevelSegments.size === 1) { + const root = sorted[0]?.split("/")[0]; + if (root !== undefined && sorted.every((path) => path.startsWith(`${root}/`))) { + return partitionFileGroups(root, sorted, maxOwnedFiles); + } + } + const groups: Array<{ label: string; files: string[] }> = []; + for (let index = 0; index < sorted.length; index += maxOwnedFiles) { + groups.push({ + label: index === 0 ? "repository" : `repository#${Math.floor(index / maxOwnedFiles) + 1}`, + files: sorted.slice(index, index + maxOwnedFiles), + }); + } + return groups; +} + +function projectSourceFiles( + project: DotnetProject, + sourceFiles: string[], + projectRoots: Array<{ path: string; root: string }>, +): string[] { + return sourceFiles + .filter((path) => isProjectLanguageSourcePath(path, project.language)) + .filter((path) => fileBelongsToProject(path, project, projectRoots)) + .filter((path) => !isGeneratedDotnetSourcePath(path)) + .toSorted(); +} + +function fileBelongsToProject( + path: string, + project: DotnetProject, + projectRoots: Array<{ path: string; root: string }>, +): boolean { + if (project.root !== "." && !pathMatchesPrefix(path, project.root)) { + return false; + } + for (const other of projectRoots) { + if (other.path === project.path || other.root === "." || other.root === project.root) { + continue; + } + const nestedInProject = project.root === "." || pathMatchesPrefix(other.root, project.root); + if (nestedInProject && pathMatchesPrefix(path, other.root)) { + return false; + } + } + return true; +} + +function associatedTests(project: DotnetProject, testProjects: DotnetProject[]): SeedTestRef[] { + const tests: SeedTestRef[] = []; + for (const testProject of testProjects) { + if (!testProjectTargetsProject(testProject, project)) { + continue; + } + tests.push(...testRefs(testProject)); + } + return tests.slice(0, maxTests); +} + +function testProjectTargetsProject(testProject: DotnetProject, project: DotnetProject): boolean { + if (testProject.projectReferences.includes(project.path)) { + return true; + } + const testName = normalizeName(testProject.name); + const projectName = normalizeName(project.name); + return ( + testName === `${projectName}tests` || + testName === `${projectName}test` || + testName.startsWith(`${projectName}tests`) || + testName.startsWith(`${projectName}test`) + ); +} + +function testRefs(project: DotnetProject): SeedTestRef[] { + return testRefsForFiles(project, project.sourceFiles); +} + +function testRefsForFiles(project: DotnetProject, files: string[]): SeedTestRef[] { + const command = dotnetTestCommand(project); + if (files.length === 0) { + return [{ path: project.path, command }]; + } + return files.map((path) => ({ path, command })); +} + +function dotnetTestCommand(project: DotnetProject): string { + return `dotnet test ${shellQuotePath(project.path)}`; +} + +function dotnetConfigFiles(files: string[]): SeedFileRef[] { + return files.filter(isDotnetConfigPath).map((path) => ({ path, reason: "dotnet config" })); +} + +function routeContextFiles(project: DotnetProject, tests: SeedTestRef[]): SeedFileRef[] { + return uniqueFileRefs([ + { path: project.path, reason: "project file" }, + ...tests.map((test) => ({ path: test.path, reason: "associated test" })), + ]); +} + +function sourceContextFiles(project: DotnetProject, tests: SeedTestRef[]): SeedFileRef[] { + return uniqueFileRefs([ + { path: project.path, reason: "project file" }, + ...project.projectReferences.map((path) => ({ path, reason: "project reference" })), + ...tests.map((test) => ({ path: test.path, reason: "associated test" })), + ]); +} + +function projectKind(project: DotnetProject): FeatureSeed["kind"] { + if (project.isTest) { + return "test-suite"; + } + if (project.isWeb || project.isWorker) { + return "service"; + } + return "library"; +} + +function sourceGroupKind(project: DotnetProject, label: string): FeatureSeed["kind"] { + if (/(^|\/)(jobs?|workers?|background|hostedservices?)(\/|$)/iu.test(label)) { + return "job"; + } + if ( + project.isWeb || + /(^|\/)(services?|data|repositories|persistence|clients?)(\/|$)/iu.test(label) + ) { + return "service"; + } + return "library"; +} + +function sourceOnlyCsharpKind(label: string): FeatureSeed["kind"] { + if (/(^|\/)(jobs?|workers?|background|hostedservices?)(\/|$)/iu.test(label)) { + return "job"; + } + if (/(^|\/)(services?|data|repositories|persistence|clients?)(\/|$)/iu.test(label)) { + return "service"; + } + return "library"; +} + +function projectTrustBoundaries(project: DotnetProject): TrustBoundary[] { + const boundaries: TrustBoundary[] = []; + if (project.isWeb) { + boundaries.push("network", "user-input", "serialization"); + } + if (project.isWorker) { + boundaries.push("filesystem", "process-exec"); + } + if (hasDatabaseEvidence(project.path, project.source)) { + boundaries.push("database", "serialization"); + } + return uniqueStrings(boundaries) as TrustBoundary[]; +} + +function sourceGroupTrustBoundaries(project: DotnetProject, label: string): TrustBoundary[] { + const boundaries = projectTrustBoundaries(project); + if (/(^|\/)(data|repositories|persistence|migrations)(\/|$)/iu.test(label)) { + boundaries.push("database", "serialization"); + } + if (/(^|\/)(clients?|http|external|integrations?)(\/|$)/iu.test(label)) { + boundaries.push("network", "external-api", "serialization"); + } + if (/(^|\/)(auth|identity|security|permissions?)(\/|$)/iu.test(label)) { + boundaries.push("auth", "permissions"); + } + return uniqueStrings(boundaries) as TrustBoundary[]; +} + +function sourceOnlyCsharpTrustBoundaries(label: string, files: string[]): TrustBoundary[] { + const boundaries: TrustBoundary[] = []; + const evidence = `${label}\n${files.join("\n")}`; + if (/(^|\/)(data|repositories|persistence|migrations)(\/|$)/iu.test(evidence)) { + boundaries.push("database", "serialization"); + } + if (/(^|\/)(clients?|http|external|integrations?)(\/|$)/iu.test(evidence)) { + boundaries.push("network", "external-api", "serialization"); + } + if (/(^|\/)(auth|identity|security|permissions?)(\/|$)/iu.test(evidence)) { + boundaries.push("auth", "permissions"); + } + return uniqueStrings(boundaries) as TrustBoundary[]; +} + +function aspNetTrustBoundaries(route: string | null, path: string): TrustBoundary[] { + const boundaries: TrustBoundary[] = ["network", "user-input", "serialization"]; + if (/auth|login|token|admin|permission/iu.test(`${route ?? ""} ${path}`)) { + boundaries.push("auth", "permissions"); + } + return uniqueStrings(boundaries) as TrustBoundary[]; +} + +function projectTags(project: DotnetProject, ...extra: string[]): string[] { + return uniqueStrings([ + "dotnet", + project.language, + `project:${project.name}`, + `project-root:${project.root}`, + ...(project.sdk === null ? [] : [`sdk:${project.sdk}`]), + ...(project.isWeb ? ["aspnetcore"] : []), + ...(project.isWorker ? ["worker"] : []), + ...extra, + ]); +} + +function controllerInfo(path: string, source: string): { name: string; routes: string[] } | null { + const classMatch = /\bclass\s+([A-Za-z_][A-Za-z0-9_]*Controller)\b[^{}\n]*(?:[^{]+)?\{/u.exec( + source, + ); + if (classMatch?.[1] === undefined && !isAspNetConventionPath(path)) { + return null; + } + const name = classMatch?.[1] ?? basename(path, ".cs"); + if ( + !/\b(ApiController|ControllerBase|Controller)\b/u.test(source) && + !name.endsWith("Controller") + ) { + return null; + } + return { name, routes: routeAttributes(source) }; +} + +function routeAttributes(source: string): string[] { + const routes: string[] = []; + const pattern = + /\[\s*(?:Route|HttpGet|HttpPost|HttpPut|HttpPatch|HttpDelete|HttpHead|HttpOptions)\b/gu; + for (const match of source.matchAll(pattern)) { + const route = routeTemplateFromAttributeTail( + readAttributeTail(source, match.index + match[0].length), + ); + if (route !== null) { + routes.push(route); + } + } + return uniqueStrings(routes); +} + +function readAttributeTail(source: string, start: number): string { + let output = ""; + let quote: "normal" | "verbatim" | null = null; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + const next = source[index + 1]; + if (quote !== null) { + output += char; + if (quote === "verbatim") { + if (char === '"' && next === '"') { + output += next; + index += 1; + } else if (char === '"') { + quote = null; + } + } else if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + quote = null; + } + continue; + } + if (char === "@" && next === '"') { + output += char; + output += next; + index += 1; + quote = "verbatim"; + } else if (char === '"') { + output += char; + quote = "normal"; + } else if (char === "]") { + return output; + } else { + output += char; + } + } + return output; +} + +function routeTemplateFromAttributeTail(tail: string): string | null { + const open = tail.indexOf("("); + const close = tail.lastIndexOf(")"); + if (open === -1 || close <= open) { + return null; + } + const args = tail.slice(open + 1, close); + const positional = /^\s*@?"((?:[^"]|"")*)"/u.exec(args)?.[1]; + const namedTemplate = /^\s*(?:template|path)\s*:\s*@?"((?:[^"]|"")*)"/iu.exec(args)?.[1]; + const route = positional ?? namedTemplate ?? null; + return route === null ? null : normalizeAspNetRoute(route); +} + +function minimalApiEndpoints(source: string): Array<{ method: string; route: string }> { + const endpoints: Array<{ method: string; route: string }> = []; + const routeGroups = routeGroupPrefixes(source); + const pattern = + /\.\s*Map(Get|Post|Put|Patch|Delete|Methods|Fallback|FallbackToFile)\s*\(\s*@?"((?:[^"]|"")*)"/gu; + for (const match of source.matchAll(pattern)) { + const method = match[1]?.toUpperCase() ?? "HTTP"; + if (method === "FALLBACKTOFILE" && !hasFollowingStringArgument(source, match)) { + continue; + } + const route = normalizeAspNetRoute(match[2] ?? ""); + if (route === null) { + continue; + } + endpoints.push({ + method, + route: combineAspNetRoutes([ + ...routeGroupPrefixesForEndpoint(source, match, routeGroups), + route, + ]), + }); + } + return endpoints; +} + +function routeGroupPrefixes(source: string): Map { + const groups = new Map(); + const factories = routeGroupFactoryPrefixes(source); + const assignmentPattern = /\b(?:var\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^;]+);/gu; + for (const match of source.matchAll(assignmentPattern)) { + const name = match[1]; + const expression = match[2]; + if (name === undefined || expression === undefined) { + continue; + } + const prefixes = routeGroupPrefixesFromExpression(expression, groups, factories); + if (prefixes.length > 0) { + groups.set(name, prefixes); + } + } + return groups; +} + +function routeGroupFactoryPrefixes(source: string): Map { + const factories = new Map(); + const expressionBodyPattern = + /\b([A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*\)\s*=>\s*([^;]*\.\s*MapGroup\s*\([^;]*);/gu; + for (const match of source.matchAll(expressionBodyPattern)) { + const name = match[1]; + const expression = match[2]; + if (name === undefined || expression === undefined) { + continue; + } + const prefixes = routeGroupPrefixesFromExpression(expression, new Map(), factories); + if (prefixes.length > 0) { + factories.set(name, prefixes); + } + } + return factories; +} + +function routeGroupPrefixesForEndpoint( + source: string, + match: RegExpMatchArray, + groups: Map, +): string[] { + const matchIndex = match.index ?? 0; + const statementStart = source.lastIndexOf(";", matchIndex) + 1; + return routeGroupPrefixesFromExpression(source.slice(statementStart, matchIndex), groups); +} + +function routeGroupPrefixesFromExpression( + expression: string, + groups: Map, + factories: Map = new Map(), +): string[] { + const leadingName = /^\s*([A-Za-z_][A-Za-z0-9_]*)\b/u.exec(expression)?.[1] ?? ""; + const prefixes = [...(groups.get(leadingName) ?? [])]; + if (/^\s*[A-Za-z_][A-Za-z0-9_]*\s*\(/u.test(expression)) { + prefixes.push(...(factories.get(leadingName) ?? [])); + } + const groupPattern = /\.\s*MapGroup\s*\(\s*@?"((?:[^"]|"")*)"/gu; + for (const match of expression.matchAll(groupPattern)) { + const route = normalizeAspNetRoute(match[1] ?? ""); + if (route !== null) { + prefixes.push(route); + } + } + return prefixes; +} + +function hasFollowingStringArgument(source: string, match: RegExpMatchArray): boolean { + return /^,\s*(?:filePath\s*:\s*)?@?"/u.test(source.slice((match.index ?? 0) + match[0].length)); +} + +function combineAspNetRoutes(parts: string[]): string { + const segments = parts.map((part) => part.replace(/^\/+|\/+$/gu, "")).filter(Boolean); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +function normalizeAspNetRoute(route: string): string | null { + const cleaned = route.replace(/""/gu, '"').replace(/^~?\//u, "/"); + if (cleaned.length === 0) { + return "/"; + } + if (cleaned.includes("[") || cleaned.includes("]")) { + return cleaned.startsWith("/") ? cleaned : `/${cleaned}`; + } + return cleaned.startsWith("/") ? cleaned : `/${cleaned}`; +} + +function stripCsharpComments(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 (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; + } else if (char === "/" && next === "/") { + while (index < source.length && source[index] !== "\n") { + output += " "; + index += 1; + } + output += "\n"; + } else if (char === "/" && next === "*") { + output += " "; + index += 2; + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + output += source[index] === "\n" ? "\n" : " "; + index += 1; + } + output += " "; + index += 1; + } else { + output += char; + } + } + return output; +} + +function dotnetProjectSdk(source: string): string | null { + return ( + /]*\bSdk\s*=\s*["']([^"']+)["']/iu.exec(source)?.[1] ?? + /]*\bName\s*=\s*["']([^"']+)["']/iu.exec(source)?.[1] ?? + null + ); +} + +function xmlElementValue(source: string, name: string): string | null { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return ( + new RegExp(`<${escapedName}>\\s*([^<]+?)\\s*`, "iu").exec(source)?.[1] ?? null + ); +} + +function xmlAttributeValues(source: string, element: string, attribute: string): string[] { + const values: string[] = []; + const elementName = element.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const attributeName = attribute.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const pattern = new RegExp( + `<${elementName}\\b[^>]*\\b${attributeName}\\s*=\\s*["']([^"']+)["']`, + "giu", + ); + for (const match of source.matchAll(pattern)) { + if (match[1] !== undefined) { + values.push(normalizeMsbuildPath(match[1])); + } + } + return uniqueStrings(values); +} + +function normalizeMsbuildPath(path: string): string { + return normalize(path.replace(/\\/gu, "/")); +} + +function stripXmlComments(source: string): string { + return source.replace(//gu, ""); +} + +function isStrongTestProject(source: string): boolean { + return ( + /\s*true\s*<\/IsTestProject>/iu.test(source) || + /]*\bSdk\s*=\s*["']MSTest\.Sdk(?:\/|["'])/iu.test(source) || + /]*\bName\s*=\s*["']MSTest\.Sdk["']/iu.test(source) || + dotnetTestPackageReferencePattern.test(source) + ); +} + +const dotnetTestPackageReferencePattern = + /]*\bInclude\s*=\s*["'](?:Microsoft\.NET\.Test\.Sdk|xunit|xunit\.v3|NUnit|NUnit3TestAdapter|MSTest\.TestFramework|Microsoft\.Testing\.Platform\.MSBuild|TUnit)["']/iu; + +function isWebProject( + source: string, + packageReferences: string[], + frameworkReferences: string[], +): boolean { + return ( + /Microsoft\.NET\.Sdk\.Web/iu.test(source) || + frameworkReferences.includes("Microsoft.AspNetCore.App") || + packageReferences.some((name) => name.startsWith("Microsoft.AspNetCore.")) || + /\bMap(?:Get|Post|Put|Patch|Delete|Controllers|RazorPages|GrpcService)\s*\(/u.test(source) + ); +} + +function isWorkerProject(source: string, packageReferences: string[]): boolean { + return ( + /Microsoft\.NET\.Sdk\.Worker/iu.test(source) || + packageReferences.some((name) => name.startsWith("Microsoft.Extensions.Hosting")) || + /BackgroundService|IHostedService/u.test(source) + ); +} + +function hasDatabaseEvidence(path: string, source: string): boolean { + return /EntityFramework|Dapper|SqlClient|Npgsql|MySql|MongoDB|Cosmos|database|dbcontext/iu.test( + `${path}\n${source}`, + ); +} + +function isDotnetProjectPath(path: string): boolean { + return /\.(?:cs|fs|vb)proj$/iu.test(path); +} + +function isDotnetSolutionPath(path: string): boolean { + return /\.(?:sln|slnx)$/iu.test(path); +} + +function isDotnetSlnxPath(path: string): boolean { + return /\.slnx$/iu.test(path); +} + +function isDotnetConfigPath(path: string): boolean { + return /(^|\/)(global\.json|Directory\.(?:Build|Packages)\.(?:props|targets)|NuGet\.config|\.editorconfig)$/u.test( + path, + ); +} + +function isDotnetSourcePath(path: string): boolean { + return /\.(?:cs|fs|fsi|vb)$/iu.test(path); +} + +function isProjectLanguageSourcePath(path: string, language: DotnetProject["language"]): boolean { + const lower = path.toLowerCase(); + if (language === "fsharp") { + return lower.endsWith(".fs") || lower.endsWith(".fsi"); + } + if (language === "visual-basic") { + return lower.endsWith(".vb"); + } + return lower.endsWith(".cs"); +} + +function isGeneratedDotnetSourcePath(path: string): boolean { + const base = basename(path); + return ( + /(^|\/)(bin|obj|TestResults|Generated|generated)(\/|$)/u.test(path) || + /\.g(?:\.i)?\.cs$/u.test(base) || + base.endsWith(".AssemblyInfo.cs") || + base === "GlobalUsings.g.cs" || + base.startsWith("TemporaryGeneratedFile_") + ); +} + +function isAspNetConventionPath(path: string): boolean { + return isAspNetRouteConventionPath(path) || /(^|\/)Program\.cs$/iu.test(path); +} + +function isAspNetRouteConventionPath(path: string): boolean { + return /(^|\/)(Controllers?|Endpoints?|Pages)(\/|$)/iu.test(path); +} + +function isLikelyTestProjectPath(path: string): boolean { + const name = basename(path, extname(path)); + return ( + /(^|[._-])(?:tests?|specs?)(?:$|[._-])/iu.test(name) || + /(^|\/)(tests?|specs?)(\/|$)/iu.test(path) + ); +} + +function projectRoot(path: string): string { + const dir = dirname(path); + return dir === "." ? "." : normalize(dir); +} + +function projectLanguage(path: string): DotnetProject["language"] { + const lower = path.toLowerCase(); + if (lower.endsWith(".fsproj")) { + return "fsharp"; + } + if (lower.endsWith(".vbproj")) { + return "visual-basic"; + } + return "csharp"; +} + +function dotnetLanguageName(language: DotnetProject["language"]): string { + if (language === "fsharp") { + return "F#"; + } + if (language === "visual-basic") { + return "Visual Basic"; + } + return "C#"; +} + +function shouldSkipDotnetPath(path: string): boolean { + if (shouldSkip(path) || isSampleProjectPath(path)) { + return true; + } + return isDotnetGeneratedOrCachePath(path); +} + +function isDotnetGeneratedOrCachePath(path: string): boolean { + return ( + /(^|\/)(bin|obj|TestResults|\.vs)(\/|$)/u.test(path) || + /(^|\/)\.nuget\/(?:packages|fallbackpackages)(\/|$)/iu.test(path) + ); +} + +function normalizeName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/gu, ""); +} + +function uniqueFileRefs(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 uniqueStrings(values: T[]): T[] { + return [...new Set(values.filter((value) => value.length > 0))]; +} diff --git a/src/shell.test.ts b/src/shell.test.ts new file mode 100644 index 0000000..05567b2 --- /dev/null +++ b/src/shell.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { shellQuotePath } from "./shell.js"; + +describe("shellQuotePath", () => { + it("leaves simple paths unquoted", () => { + expect(shellQuotePath("src/App/App.csproj", "linux")).toBe("src/App/App.csproj"); + }); + + it("uses double quotes for Windows cmd paths with spaces", () => { + expect(shellQuotePath("src/My App/App.csproj", "win32")).toBe('"src/My App/App.csproj"'); + }); + + it("escapes percent signs for Windows cmd paths", () => { + expect(shellQuotePath("src/%USERNAME%/App.csproj", "win32")).toBe( + '"src/^%USERNAME^%/App.csproj"', + ); + }); + + it("escapes POSIX double-quoted metacharacters", () => { + expect(shellQuotePath('src/$App/"Project".csproj', "linux")).toBe( + '"src/\\$App/\\"Project\\".csproj"', + ); + }); +}); diff --git a/src/shell.ts b/src/shell.ts new file mode 100644 index 0000000..0e7b1e0 --- /dev/null +++ b/src/shell.ts @@ -0,0 +1,9 @@ +export function shellQuotePath(path: string, platform: NodeJS.Platform = process.platform): string { + if (/^[A-Za-z0-9_./:@+-]+$/u.test(path)) { + return path; + } + if (platform === "win32") { + return `"${path.replace(/%/gu, "^%").replace(/"/gu, '""')}"`; + } + return `"${path.replace(/(["\\$`])/gu, "\\$1")}"`; +} diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 475fc12..0489ed3 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -1221,6 +1221,72 @@ describe("workflow", () => { ); }); + it("includes F# and Visual Basic sources in agent mapper inventory", async () => { + const root = await fixtureRoot("clawpatch-agent-map-dotnet-inventory-"); + await writeFixture(root, "src/FsLib/FsLib.fsproj", '\n'); + await writeFixture(root, "src/FsLib/Library.fs", 'module Library\nlet hello = "world"\n'); + await writeFixture(root, "src/FsLib/Signature.fsi", "module Library\nval hello: string\n"); + await writeFixture(root, "src/VbApp/VbApp.vbproj", '\n'); + await writeFixture(root, "src/VbApp/Program.vb", "Module Program\nEnd Module\n"); + const context = await makeContext(testOptions(root)); + let prompt = ""; + const provider: Provider = { + name: "capture-agent-map", + async check() { + return "capture-agent-map"; + }, + async map(_root, nextPrompt) { + prompt = nextPrompt; + return { + features: [ + { + title: "F# library", + summary: "Provider grouped F# source.", + kind: "library", + confidence: "medium", + entrypoints: [ + { path: "src/FsLib/Library.fs", symbol: null, route: null, command: null }, + ], + ownedFiles: [{ path: "src/FsLib/Library.fs", reason: "F# source" }], + contextFiles: [], + tests: [], + tags: ["dotnet"], + trustBoundaries: [], + reason: "inventory fixture", + }, + ], + notes: [], + }; + }, + async review() { + throw new Error("unused"); + }, + async fix() { + throw new Error("unused"); + }, + async revalidate() { + throw new Error("unused"); + }, + }; + + await initCommand(context, {}); + const paths = statePaths(join(root, ".clawpatch")); + const project = await readProject(paths); + if (project === null) { + throw new Error("missing project"); + } + const heuristic = await mapFeatures(root, project, []); + await mapWithSource(root, project, [], heuristic, { + source: "agent", + provider, + providerOptions: { model: null, reasoningEffort: null, skipGitRepoCheck: false }, + }); + + expect(prompt).toContain('"src/FsLib/Library.fs"'); + expect(prompt).toContain('"src/FsLib/Signature.fsi"'); + expect(prompt).toContain('"src/VbApp/Program.vb"'); + }); + it("fails forced agent mapping when the provider returns no valid features", async () => { const root = await fixtureRoot("clawpatch-empty-agent-map-"); await writeFixture(