Skip to content

Commit 3144da7

Browse files
digitaraldHarald Kirschner
andauthored
feat: enhance .NET detection with F#, framework parsing, and expanded signals (#60)
- 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. Co-authored-by: Harald Kirschner <digitarald@gmail.com>
1 parent 472b815 commit 3144da7

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

packages/core/src/services/analyzer.ts

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ const PACKAGE_MANAGERS: Array<{ file: string; name: string }> = [
5757
{ file: "pnpm-lock.yaml", name: "pnpm" },
5858
{ file: "yarn.lock", name: "yarn" },
5959
{ file: "package-lock.json", name: "npm" },
60-
{ file: "bun.lockb", name: "bun" }
60+
{ file: "bun.lockb", name: "bun" },
61+
{ file: "packages.lock.json", name: "nuget" }
6162
];
6263

6364
export async function analyzeRepo(repoPath: string): Promise<RepoAnalysis> {
@@ -82,9 +83,17 @@ export async function analyzeRepo(repoPath: string): Promise<RepoAnalysis> {
8283
const hasRequirements = files.includes("requirements.txt");
8384
const hasGoMod = files.includes("go.mod");
8485
const hasCargo = files.includes("Cargo.toml");
85-
const hasCsproj = files.some(
86-
(f) => f.endsWith(".csproj") || f.endsWith(".sln") || f.endsWith(".slnx")
86+
const hasDotnet = files.some(
87+
(f) =>
88+
f.endsWith(".csproj") ||
89+
f.endsWith(".fsproj") ||
90+
f.endsWith(".sln") ||
91+
f.endsWith(".slnx") ||
92+
f === "global.json" ||
93+
f === "Directory.Build.props"
8794
);
95+
const hasCsproj = hasDotnet && files.some((f) => f.endsWith(".csproj"));
96+
const hasFsproj = files.some((f) => f.endsWith(".fsproj"));
8897
const hasPomXml = files.includes("pom.xml");
8998
const hasBuildGradle = files.includes("build.gradle") || files.includes("build.gradle.kts");
9099
const hasGemfile = files.includes("Gemfile");
@@ -100,7 +109,8 @@ export async function analyzeRepo(repoPath: string): Promise<RepoAnalysis> {
100109
if (hasPyProject || hasRequirements) analysis.languages.push("Python");
101110
if (hasGoMod) analysis.languages.push("Go");
102111
if (hasCargo) analysis.languages.push("Rust");
103-
if (hasCsproj) analysis.languages.push("C#");
112+
if (hasCsproj || (hasDotnet && !hasFsproj)) analysis.languages.push("C#");
113+
if (hasFsproj) analysis.languages.push("F#");
104114
if (hasPomXml || hasBuildGradle) analysis.languages.push("Java");
105115
if (hasGemfile) analysis.languages.push("Ruby");
106116
if (hasComposerJson) analysis.languages.push("PHP");
@@ -120,6 +130,11 @@ export async function analyzeRepo(repoPath: string): Promise<RepoAnalysis> {
120130
analysis.frameworks.push(...detectFrameworks(deps, files));
121131
}
122132

133+
if (hasDotnet) {
134+
const dotnetFrameworks = await detectDotnetFrameworks(repoPath);
135+
analysis.frameworks.push(...dotnetFrameworks);
136+
}
137+
123138
const workspace = await detectWorkspace(repoPath, files, rootPackageJson);
124139
if (workspace) {
125140
analysis.workspaceType = workspace.type;
@@ -203,6 +218,63 @@ function detectFrameworks(deps: string[], files: string[]): string[] {
203218
return frameworks;
204219
}
205220

221+
async function detectDotnetFrameworks(repoPath: string): Promise<string[]> {
222+
const projectFiles = await fg("**/*.{csproj,fsproj}", {
223+
cwd: repoPath,
224+
onlyFiles: true,
225+
ignore: ["**/node_modules/**", "**/bin/**", "**/obj/**"]
226+
});
227+
228+
const frameworks: string[] = [];
229+
for (const projFile of projectFiles) {
230+
try {
231+
const content = await fs.readFile(path.join(repoPath, projFile), "utf8");
232+
frameworks.push(...parseDotnetProject(content));
233+
} catch {
234+
// ignore read errors
235+
}
236+
}
237+
return frameworks;
238+
}
239+
240+
function parseDotnetProject(content: string): string[] {
241+
const frameworks: string[] = [];
242+
const hasPackage = (pkg: string): boolean => content.includes(`Include="${pkg}"`);
243+
244+
// SDK-based detection
245+
if (content.includes('Sdk="Microsoft.NET.Sdk.Web"')) frameworks.push("ASP.NET Core");
246+
if (content.includes('Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"'))
247+
frameworks.push("Blazor WebAssembly");
248+
249+
// Package reference detection
250+
if (hasPackage("Microsoft.AspNetCore") || hasPackage("Microsoft.AspNetCore.App"))
251+
frameworks.push("ASP.NET Core");
252+
if (hasPackage("Microsoft.AspNetCore.Components")) frameworks.push("Blazor");
253+
if (hasPackage("Microsoft.EntityFrameworkCore")) frameworks.push("Entity Framework");
254+
if (hasPackage("Microsoft.Maui.Controls")) frameworks.push(".NET MAUI");
255+
if (hasPackage("Xamarin.Forms") || hasPackage("Xamarin.Essentials")) frameworks.push("Xamarin");
256+
257+
// Project property detection
258+
if (content.includes("<UseWPF>true</UseWPF>")) frameworks.push("WPF");
259+
if (content.includes("<UseWindowsForms>true</UseWindowsForms>")) frameworks.push("Windows Forms");
260+
261+
// Test framework detection
262+
if (hasPackage("xunit") || hasPackage("xunit.core")) frameworks.push("xUnit");
263+
if (hasPackage("NUnit") || hasPackage("nunit.framework")) frameworks.push("NUnit");
264+
if (hasPackage("MSTest.TestFramework")) frameworks.push("MSTest");
265+
266+
// Console app fallback
267+
if (
268+
frameworks.length === 0 &&
269+
content.includes("<OutputType>Exe</OutputType>") &&
270+
content.includes('Sdk="Microsoft.NET.Sdk"')
271+
) {
272+
frameworks.push("Console");
273+
}
274+
275+
return frameworks;
276+
}
277+
206278
async function safeReadFile(filePath: string): Promise<string | undefined> {
207279
try {
208280
return await fs.readFile(filePath, "utf8");

packages/core/src/services/instructions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ export async function generateCopilotInstructions(
363363
364364
Fan out multiple Explore subagents to map out the codebase in parallel:
365365
1. Check for existing instruction files: glob for **/{.github/copilot-instructions.md,AGENT.md,CLAUDE.md,.cursorrules,README.md}
366-
2. Identify the tech stack: look at package.json, tsconfig.json, pyproject.toml, Cargo.toml, go.mod, *.csproj, *.sln, build.gradle, pom.xml, etc.
366+
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.
367367
3. Understand the structure: list key directories
368368
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)
369369

src/services/__tests__/analyzer.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,84 @@ describe("analyzeRepo", () => {
256256
expect(result.packageManager).toBe("nuget");
257257
});
258258

259+
it("detects F# language via .fsproj", async () => {
260+
const repoPath = await makeTmpDir();
261+
await fs.writeFile(
262+
path.join(repoPath, "MyProject.fsproj"),
263+
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType></PropertyGroup></Project>'
264+
);
265+
266+
const result = await analyzeRepo(repoPath);
267+
expect(result.languages).toContain("F#");
268+
expect(result.frameworks).toContain("Console");
269+
});
270+
271+
it("detects .NET via global.json", async () => {
272+
const repoPath = await makeTmpDir();
273+
await fs.writeFile(
274+
path.join(repoPath, "global.json"),
275+
JSON.stringify({ sdk: { version: "8.0.100" } })
276+
);
277+
278+
const result = await analyzeRepo(repoPath);
279+
expect(result.languages).toContain("C#");
280+
});
281+
282+
it("detects ASP.NET Core framework from csproj", async () => {
283+
const repoPath = await makeTmpDir();
284+
await fs.writeFile(
285+
path.join(repoPath, "WebApp.csproj"),
286+
'<Project Sdk="Microsoft.NET.Sdk.Web"><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>'
287+
);
288+
289+
const result = await analyzeRepo(repoPath);
290+
expect(result.languages).toContain("C#");
291+
expect(result.frameworks).toContain("ASP.NET Core");
292+
});
293+
294+
it("detects xUnit test framework from csproj", async () => {
295+
const repoPath = await makeTmpDir();
296+
await fs.writeFile(
297+
path.join(repoPath, "Tests.csproj"),
298+
[
299+
'<Project Sdk="Microsoft.NET.Sdk">',
300+
" <ItemGroup>",
301+
' <PackageReference Include="xunit" Version="2.5.0" />',
302+
' <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />',
303+
" </ItemGroup>",
304+
"</Project>"
305+
].join("\n")
306+
);
307+
308+
const result = await analyzeRepo(repoPath);
309+
expect(result.frameworks).toContain("xUnit");
310+
expect(result.frameworks).not.toContain("MSTest");
311+
});
312+
313+
it("detects both C# and F# in mixed repo", async () => {
314+
const repoPath = await makeTmpDir();
315+
await fs.writeFile(
316+
path.join(repoPath, "App.csproj"),
317+
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType></PropertyGroup></Project>'
318+
);
319+
await fs.writeFile(
320+
path.join(repoPath, "Lib.fsproj"),
321+
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>'
322+
);
323+
324+
const result = await analyzeRepo(repoPath);
325+
expect(result.languages).toContain("C#");
326+
expect(result.languages).toContain("F#");
327+
});
328+
329+
it("detects packages.lock.json as nuget", async () => {
330+
const repoPath = await makeTmpDir();
331+
await fs.writeFile(path.join(repoPath, "packages.lock.json"), "{}");
332+
333+
const result = await analyzeRepo(repoPath);
334+
expect(result.packageManager).toBe("nuget");
335+
});
336+
259337
it("detects Gradle multi-project", async () => {
260338
const repoPath = await makeTmpDir();
261339
await fs.writeFile(

0 commit comments

Comments
 (0)