From 08d63266d0791c19f2163081e0d3ffb82c321e7e Mon Sep 17 00:00:00 2001 From: Harald Kirschner Date: Sun, 15 Mar 2026 16:35:32 -0700 Subject: [PATCH] feat: enhance .NET detection with F#, framework parsing, and expanded signals - Add F# language detection via .fsproj files - Add .NET framework detection by parsing project file contents (ASP.NET Core, Blazor, Entity Framework, MAUI, Xamarin, WPF, WinForms, xUnit, NUnit, MSTest, Console) - Expand .NET signal detection to include global.json and Directory.Build.props - Add packages.lock.json to PACKAGE_MANAGERS for NuGet lock file detection - Update instructions prompt to include .fsproj and global.json - Add 7 new tests covering all new detection paths Cherry-picked and improved from #2. --- packages/core/src/services/analyzer.ts | 80 ++++++++++++++++++++-- packages/core/src/services/instructions.ts | 2 +- src/services/__tests__/analyzer.test.ts | 78 +++++++++++++++++++++ 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/packages/core/src/services/analyzer.ts b/packages/core/src/services/analyzer.ts index 9f155c8..2b2118e 100644 --- a/packages/core/src/services/analyzer.ts +++ b/packages/core/src/services/analyzer.ts @@ -57,7 +57,8 @@ const PACKAGE_MANAGERS: Array<{ file: string; name: string }> = [ { file: "pnpm-lock.yaml", name: "pnpm" }, { file: "yarn.lock", name: "yarn" }, { file: "package-lock.json", name: "npm" }, - { file: "bun.lockb", name: "bun" } + { file: "bun.lockb", name: "bun" }, + { file: "packages.lock.json", name: "nuget" } ]; export async function analyzeRepo(repoPath: string): Promise { @@ -82,9 +83,17 @@ export async function analyzeRepo(repoPath: string): Promise { const hasRequirements = files.includes("requirements.txt"); const hasGoMod = files.includes("go.mod"); const hasCargo = files.includes("Cargo.toml"); - const hasCsproj = files.some( - (f) => f.endsWith(".csproj") || f.endsWith(".sln") || f.endsWith(".slnx") + const hasDotnet = files.some( + (f) => + f.endsWith(".csproj") || + f.endsWith(".fsproj") || + f.endsWith(".sln") || + f.endsWith(".slnx") || + f === "global.json" || + f === "Directory.Build.props" ); + const hasCsproj = hasDotnet && files.some((f) => f.endsWith(".csproj")); + const hasFsproj = files.some((f) => f.endsWith(".fsproj")); const hasPomXml = files.includes("pom.xml"); const hasBuildGradle = files.includes("build.gradle") || files.includes("build.gradle.kts"); const hasGemfile = files.includes("Gemfile"); @@ -100,7 +109,8 @@ export async function analyzeRepo(repoPath: string): Promise { if (hasPyProject || hasRequirements) analysis.languages.push("Python"); if (hasGoMod) analysis.languages.push("Go"); if (hasCargo) analysis.languages.push("Rust"); - if (hasCsproj) analysis.languages.push("C#"); + if (hasCsproj || (hasDotnet && !hasFsproj)) analysis.languages.push("C#"); + if (hasFsproj) analysis.languages.push("F#"); if (hasPomXml || hasBuildGradle) analysis.languages.push("Java"); if (hasGemfile) analysis.languages.push("Ruby"); if (hasComposerJson) analysis.languages.push("PHP"); @@ -120,6 +130,11 @@ export async function analyzeRepo(repoPath: string): Promise { analysis.frameworks.push(...detectFrameworks(deps, files)); } + if (hasDotnet) { + const dotnetFrameworks = await detectDotnetFrameworks(repoPath); + analysis.frameworks.push(...dotnetFrameworks); + } + const workspace = await detectWorkspace(repoPath, files, rootPackageJson); if (workspace) { analysis.workspaceType = workspace.type; @@ -203,6 +218,63 @@ function detectFrameworks(deps: string[], files: string[]): string[] { return frameworks; } +async function detectDotnetFrameworks(repoPath: string): Promise { + const projectFiles = await fg("**/*.{csproj,fsproj}", { + cwd: repoPath, + onlyFiles: true, + ignore: ["**/node_modules/**", "**/bin/**", "**/obj/**"] + }); + + const frameworks: string[] = []; + for (const projFile of projectFiles) { + try { + const content = await fs.readFile(path.join(repoPath, projFile), "utf8"); + frameworks.push(...parseDotnetProject(content)); + } catch { + // ignore read errors + } + } + return frameworks; +} + +function parseDotnetProject(content: string): string[] { + const frameworks: string[] = []; + const hasPackage = (pkg: string): boolean => content.includes(`Include="${pkg}"`); + + // SDK-based detection + if (content.includes('Sdk="Microsoft.NET.Sdk.Web"')) frameworks.push("ASP.NET Core"); + if (content.includes('Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"')) + frameworks.push("Blazor WebAssembly"); + + // Package reference detection + if (hasPackage("Microsoft.AspNetCore") || hasPackage("Microsoft.AspNetCore.App")) + frameworks.push("ASP.NET Core"); + if (hasPackage("Microsoft.AspNetCore.Components")) frameworks.push("Blazor"); + if (hasPackage("Microsoft.EntityFrameworkCore")) frameworks.push("Entity Framework"); + if (hasPackage("Microsoft.Maui.Controls")) frameworks.push(".NET MAUI"); + if (hasPackage("Xamarin.Forms") || hasPackage("Xamarin.Essentials")) frameworks.push("Xamarin"); + + // Project property detection + if (content.includes("true")) frameworks.push("WPF"); + if (content.includes("true")) frameworks.push("Windows Forms"); + + // Test framework detection + if (hasPackage("xunit") || hasPackage("xunit.core")) frameworks.push("xUnit"); + if (hasPackage("NUnit") || hasPackage("nunit.framework")) frameworks.push("NUnit"); + if (hasPackage("MSTest.TestFramework")) frameworks.push("MSTest"); + + // Console app fallback + if ( + frameworks.length === 0 && + content.includes("Exe") && + content.includes('Sdk="Microsoft.NET.Sdk"') + ) { + frameworks.push("Console"); + } + + return frameworks; +} + async function safeReadFile(filePath: string): Promise { try { return await fs.readFile(filePath, "utf8"); diff --git a/packages/core/src/services/instructions.ts b/packages/core/src/services/instructions.ts index d2ded05..eb714c9 100644 --- a/packages/core/src/services/instructions.ts +++ b/packages/core/src/services/instructions.ts @@ -363,7 +363,7 @@ export async function generateCopilotInstructions( Fan out multiple Explore subagents to map out the codebase in parallel: 1. Check for existing instruction files: glob for **/{.github/copilot-instructions.md,AGENT.md,CLAUDE.md,.cursorrules,README.md} -2. Identify the tech stack: look at package.json, tsconfig.json, pyproject.toml, Cargo.toml, go.mod, *.csproj, *.sln, build.gradle, pom.xml, etc. +2. Identify the tech stack: look at package.json, tsconfig.json, pyproject.toml, Cargo.toml, go.mod, *.csproj, *.fsproj, *.sln, global.json, build.gradle, pom.xml, etc. 3. Understand the structure: list key directories 4. Detect monorepo structures: check for workspace configs (npm/pnpm/yarn workspaces, Cargo.toml [workspace], go.work, .sln solution files, settings.gradle include directives, pom.xml modules) diff --git a/src/services/__tests__/analyzer.test.ts b/src/services/__tests__/analyzer.test.ts index 0a5f5a1..acef5fe 100644 --- a/src/services/__tests__/analyzer.test.ts +++ b/src/services/__tests__/analyzer.test.ts @@ -256,6 +256,84 @@ describe("analyzeRepo", () => { expect(result.packageManager).toBe("nuget"); }); + it("detects F# language via .fsproj", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "MyProject.fsproj"), + 'Exe' + ); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("F#"); + expect(result.frameworks).toContain("Console"); + }); + + it("detects .NET via global.json", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "global.json"), + JSON.stringify({ sdk: { version: "8.0.100" } }) + ); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("C#"); + }); + + it("detects ASP.NET Core framework from csproj", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "WebApp.csproj"), + 'net8.0' + ); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("C#"); + expect(result.frameworks).toContain("ASP.NET Core"); + }); + + it("detects xUnit test framework from csproj", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "Tests.csproj"), + [ + '', + " ", + ' ', + ' ', + " ", + "" + ].join("\n") + ); + + const result = await analyzeRepo(repoPath); + expect(result.frameworks).toContain("xUnit"); + expect(result.frameworks).not.toContain("MSTest"); + }); + + it("detects both C# and F# in mixed repo", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "App.csproj"), + 'Exe' + ); + await fs.writeFile( + path.join(repoPath, "Lib.fsproj"), + 'net8.0' + ); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("C#"); + expect(result.languages).toContain("F#"); + }); + + it("detects packages.lock.json as nuget", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "packages.lock.json"), "{}"); + + const result = await analyzeRepo(repoPath); + expect(result.packageManager).toBe("nuget"); + }); + it("detects Gradle multi-project", async () => { const repoPath = await makeTmpDir(); await fs.writeFile(