- WPF/MVVM, ASP.NET API, React/Vue/Next, Node, Python API, Spring, Android, Go, Rust CLI, generic solution ??? ProjectScaffold? ???? ???? ????? ??? - Code ?? empty workspace ??? ??? ????? ??? ?? ??? file_write? ?? ???? ??? ????? file_manage/file_write? ?? ?? ???? ???? ??? - AgentLoop ?? ? ProjectLayoutGate? ??? ??? ?? ??? ?? ??? ??? ????? ?? ??? ? ????? ??? - code-scaffold.skill.md? when_to_use? file_manage/file_edit ?????? ??? proactive auto-skill ???? ??? - IntentGate, scaffold profile, code quality ?? ???? ??? ?? ?? - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_project_scaffold_layout\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout\\ ?? 0 / ?? 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "IntentGateServiceTests|ProjectScaffoldProfileCatalogTests|SkillServiceRuntimePolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_project_scaffold_layout_tests\\ -p:IntermediateOutputPath=obj\\verify_project_scaffold_layout_tests\\ ?? 183
584 lines
17 KiB
C#
584 lines
17 KiB
C#
using System.IO;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
internal sealed record ProjectScaffoldProfile(
|
|
string Key,
|
|
string Label,
|
|
IReadOnlyList<string> TriggerTokens,
|
|
IReadOnlyList<string> ExpectedDirectories,
|
|
IReadOnlyList<string> StarterPaths,
|
|
IReadOnlyList<string> AllowedRootFiles,
|
|
int MinimumDirectoryHits = 2);
|
|
|
|
internal sealed record ProjectScaffoldLayoutAssessment(
|
|
bool IsSatisfied,
|
|
int MatchedDirectoryCount,
|
|
IReadOnlyList<string> ExistingDirectories,
|
|
IReadOnlyList<string> MissingDirectories,
|
|
IReadOnlyList<string> SuspiciousRootFiles);
|
|
|
|
internal static class ProjectScaffoldProfileCatalog
|
|
{
|
|
private static readonly string[] s_creationVerbs =
|
|
[
|
|
"create",
|
|
"generate",
|
|
"build",
|
|
"write",
|
|
"scaffold",
|
|
"bootstrap",
|
|
"set up",
|
|
"setup",
|
|
"make",
|
|
"implement",
|
|
"start",
|
|
"만들",
|
|
"생성",
|
|
"작성",
|
|
"구현",
|
|
"구축",
|
|
"세팅",
|
|
"스캐폴드"
|
|
];
|
|
|
|
private static readonly string[] s_projectNouns =
|
|
[
|
|
"project",
|
|
"app",
|
|
"application",
|
|
"service",
|
|
"api",
|
|
"solution",
|
|
"starter",
|
|
"template",
|
|
"boilerplate",
|
|
"workspace",
|
|
"repo",
|
|
"repository",
|
|
"library",
|
|
"package",
|
|
"module",
|
|
"프로젝트",
|
|
"앱",
|
|
"애플리케이션",
|
|
"서비스",
|
|
"솔루션",
|
|
"템플릿",
|
|
"보일러플레이트",
|
|
"워크스페이스",
|
|
"저장소",
|
|
"라이브러리",
|
|
"패키지",
|
|
"모듈"
|
|
];
|
|
|
|
private static readonly string[] s_languageTokens =
|
|
[
|
|
"c#",
|
|
"dotnet",
|
|
".net",
|
|
"csproj",
|
|
"wpf",
|
|
"xaml",
|
|
"mvvm",
|
|
"viewmodel",
|
|
"resourcedictionary",
|
|
"resource dictionary",
|
|
"javascript",
|
|
"typescript",
|
|
"react",
|
|
"next",
|
|
"vue",
|
|
"nuxt",
|
|
"node",
|
|
"express",
|
|
"python",
|
|
"fastapi",
|
|
"flask",
|
|
"django",
|
|
"java",
|
|
"spring",
|
|
"spring boot",
|
|
"kotlin",
|
|
"android",
|
|
"go",
|
|
"golang",
|
|
"rust",
|
|
"cli",
|
|
"web",
|
|
"desktop",
|
|
"mobile",
|
|
"frontend",
|
|
"backend",
|
|
"api",
|
|
"웹",
|
|
"데스크톱",
|
|
"모바일",
|
|
"프론트엔드",
|
|
"백엔드",
|
|
"파이썬",
|
|
"자바",
|
|
"코틀린",
|
|
"안드로이드",
|
|
"리액트",
|
|
"뷰",
|
|
"고",
|
|
"러스트",
|
|
"스프링",
|
|
"장고",
|
|
"플라스크",
|
|
"패스트api"
|
|
];
|
|
|
|
private static readonly string[] s_solutionShapeTokens =
|
|
[
|
|
"frontend",
|
|
"backend",
|
|
"desktop",
|
|
"mobile",
|
|
"cli",
|
|
"tool",
|
|
"worker",
|
|
"daemon",
|
|
"game",
|
|
"프론트엔드",
|
|
"백엔드",
|
|
"데스크톱",
|
|
"모바일",
|
|
"도구",
|
|
"워커",
|
|
"게임"
|
|
];
|
|
|
|
private static readonly HashSet<string> s_commonAllowedRootFiles = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"README.md",
|
|
".gitignore",
|
|
".editorconfig",
|
|
".gitattributes",
|
|
".env",
|
|
".env.example",
|
|
"package.json",
|
|
"package-lock.json",
|
|
"pnpm-lock.yaml",
|
|
"yarn.lock",
|
|
"tsconfig.json",
|
|
"vite.config.ts",
|
|
"vite.config.js",
|
|
"next.config.js",
|
|
"next.config.mjs",
|
|
"nuxt.config.ts",
|
|
"requirements.txt",
|
|
"pyproject.toml",
|
|
"poetry.lock",
|
|
"Pipfile",
|
|
"Pipfile.lock",
|
|
"pom.xml",
|
|
"build.gradle",
|
|
"build.gradle.kts",
|
|
"settings.gradle",
|
|
"settings.gradle.kts",
|
|
"Cargo.toml",
|
|
"go.mod",
|
|
"go.sum",
|
|
"composer.json",
|
|
"Gemfile",
|
|
"index.html"
|
|
};
|
|
|
|
private static readonly string[] s_relevantRootExtensions =
|
|
[
|
|
".cs",
|
|
".xaml",
|
|
".razor",
|
|
".ts",
|
|
".tsx",
|
|
".js",
|
|
".jsx",
|
|
".css",
|
|
".scss",
|
|
".py",
|
|
".java",
|
|
".kt",
|
|
".go",
|
|
".rs",
|
|
".php",
|
|
".rb",
|
|
".json",
|
|
".xml",
|
|
".yaml",
|
|
".yml"
|
|
];
|
|
|
|
private static readonly IReadOnlyList<ProjectScaffoldProfile> s_profiles =
|
|
[
|
|
new(
|
|
Key: "wpf-mvvm",
|
|
Label: "WPF / MVVM desktop app",
|
|
TriggerTokens:
|
|
[
|
|
"wpf",
|
|
"xaml",
|
|
"mvvm",
|
|
"viewmodel",
|
|
"resourcedictionary",
|
|
"resource dictionary",
|
|
"csproj",
|
|
"desktop app",
|
|
"window",
|
|
"usercontrol",
|
|
"user control"
|
|
],
|
|
ExpectedDirectories: ["Views", "ViewModels", "Models", "Services", "Themes", "Assets"],
|
|
StarterPaths:
|
|
[
|
|
"App.xaml",
|
|
"App.xaml.cs",
|
|
"Views/MainWindow.xaml",
|
|
"Views/MainWindow.xaml.cs",
|
|
"ViewModels/MainWindowViewModel.cs",
|
|
"Themes/Colors.xaml"
|
|
],
|
|
AllowedRootFiles: ["App.xaml", "App.xaml.cs"],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "aspnet-api",
|
|
Label: "ASP.NET / Web API service",
|
|
TriggerTokens:
|
|
[
|
|
"asp.net",
|
|
"aspnet",
|
|
"web api",
|
|
"webapi",
|
|
"minimal api",
|
|
"mvc",
|
|
"blazor",
|
|
"razor",
|
|
"controller",
|
|
"swagger"
|
|
],
|
|
ExpectedDirectories: ["Controllers", "Services", "Models", "Contracts", "Data", "Properties"],
|
|
StarterPaths:
|
|
[
|
|
"Program.cs",
|
|
"Controllers/HealthController.cs",
|
|
"Services/HealthService.cs",
|
|
"Models/HealthStatus.cs",
|
|
"appsettings.json"
|
|
],
|
|
AllowedRootFiles: ["Program.cs", "appsettings.json", "appsettings.Development.json"],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "react-web",
|
|
Label: "React / Next / Vue frontend",
|
|
TriggerTokens:
|
|
[
|
|
"react",
|
|
"next",
|
|
"next.js",
|
|
"nextjs",
|
|
"vue",
|
|
"nuxt",
|
|
"svelte",
|
|
"vite",
|
|
"frontend",
|
|
"front-end",
|
|
"spa",
|
|
"component"
|
|
],
|
|
ExpectedDirectories: ["src/components", "src/pages", "src/services", "src/styles", "public"],
|
|
StarterPaths:
|
|
[
|
|
"src/App.tsx",
|
|
"src/main.tsx",
|
|
"src/components/AppShell.tsx",
|
|
"src/pages/HomePage.tsx",
|
|
"src/services/api.ts"
|
|
],
|
|
AllowedRootFiles: ["index.html"],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "node-service",
|
|
Label: "Node / Express / Nest backend",
|
|
TriggerTokens:
|
|
[
|
|
"node",
|
|
"express",
|
|
"nestjs",
|
|
"nest",
|
|
"koa",
|
|
"backend",
|
|
"back-end",
|
|
"rest api",
|
|
"server app",
|
|
"middleware"
|
|
],
|
|
ExpectedDirectories: ["src/controllers", "src/routes", "src/services", "src/models", "tests"],
|
|
StarterPaths:
|
|
[
|
|
"src/index.ts",
|
|
"src/controllers/HealthController.ts",
|
|
"src/routes/index.ts",
|
|
"src/services/HealthService.ts",
|
|
"tests/health.spec.ts"
|
|
],
|
|
AllowedRootFiles: [],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "python-service",
|
|
Label: "Python API service",
|
|
TriggerTokens:
|
|
[
|
|
"python",
|
|
"fastapi",
|
|
"flask",
|
|
"django",
|
|
"uvicorn",
|
|
"backend api",
|
|
"python api"
|
|
],
|
|
ExpectedDirectories: ["app/api", "app/models", "app/services", "app/core", "tests"],
|
|
StarterPaths:
|
|
[
|
|
"app/main.py",
|
|
"app/api/routes.py",
|
|
"app/services/health_service.py",
|
|
"app/models/health.py",
|
|
"tests/test_health.py"
|
|
],
|
|
AllowedRootFiles: [],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "spring-service",
|
|
Label: "Java / Spring Boot service",
|
|
TriggerTokens:
|
|
[
|
|
"spring",
|
|
"spring boot",
|
|
"java api",
|
|
"maven",
|
|
"gradle",
|
|
"jpa",
|
|
"controller",
|
|
"repository"
|
|
],
|
|
ExpectedDirectories: ["src/main/java", "src/main/resources", "src/test/java"],
|
|
StarterPaths:
|
|
[
|
|
"src/main/java/com/example/Application.java",
|
|
"src/main/java/com/example/controller/HealthController.java",
|
|
"src/main/java/com/example/service/HealthService.java",
|
|
"src/main/resources/application.yml",
|
|
"src/test/java/com/example/ApplicationTests.java"
|
|
],
|
|
AllowedRootFiles: [],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "android-kotlin",
|
|
Label: "Android / Kotlin app",
|
|
TriggerTokens:
|
|
[
|
|
"android",
|
|
"kotlin",
|
|
"jetpack compose",
|
|
"compose",
|
|
"xml layout",
|
|
"activity",
|
|
"fragment"
|
|
],
|
|
ExpectedDirectories: ["app/src/main/java", "app/src/main/res/layout", "app/src/main/res/values", "app/src/test"],
|
|
StarterPaths:
|
|
[
|
|
"app/src/main/java/com/example/MainActivity.kt",
|
|
"app/src/main/res/layout/activity_main.xml",
|
|
"app/src/main/res/values/colors.xml",
|
|
"app/src/test/java/com/example/MainActivityTest.kt"
|
|
],
|
|
AllowedRootFiles: [],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "go-service",
|
|
Label: "Go service",
|
|
TriggerTokens:
|
|
[
|
|
"golang",
|
|
"go api",
|
|
"go service",
|
|
"gin",
|
|
"echo",
|
|
"fiber",
|
|
"handler"
|
|
],
|
|
ExpectedDirectories: ["cmd", "internal", "pkg", "api", "tests"],
|
|
StarterPaths:
|
|
[
|
|
"cmd/server/main.go",
|
|
"internal/handler/health.go",
|
|
"internal/service/health.go",
|
|
"pkg/config/config.go",
|
|
"tests/health_test.go"
|
|
],
|
|
AllowedRootFiles: [],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "rust-cli",
|
|
Label: "Rust CLI / tool",
|
|
TriggerTokens:
|
|
[
|
|
"rust",
|
|
"cargo",
|
|
"cli",
|
|
"command line",
|
|
"terminal tool",
|
|
"binary",
|
|
"subcommand"
|
|
],
|
|
ExpectedDirectories: ["src/bin", "src/commands", "src/domain", "tests"],
|
|
StarterPaths:
|
|
[
|
|
"src/main.rs",
|
|
"src/commands/root.rs",
|
|
"src/domain/config.rs",
|
|
"tests/cli_tests.rs"
|
|
],
|
|
AllowedRootFiles: [],
|
|
MinimumDirectoryHits: 2),
|
|
new(
|
|
Key: "generic-solution",
|
|
Label: "Generic structured solution",
|
|
TriggerTokens:
|
|
[
|
|
"project",
|
|
"solution",
|
|
"starter",
|
|
"template",
|
|
"boilerplate",
|
|
"library",
|
|
"module",
|
|
"workspace"
|
|
],
|
|
ExpectedDirectories: ["src", "tests", "docs", "config"],
|
|
StarterPaths:
|
|
[
|
|
"src/main.ext",
|
|
"src/services/service.ext",
|
|
"tests/main.spec.ext",
|
|
"docs/README.md"
|
|
],
|
|
AllowedRootFiles: [],
|
|
MinimumDirectoryHits: 2)
|
|
];
|
|
|
|
internal static ProjectScaffoldProfile? Detect(string? userQuery, string? activeTab)
|
|
{
|
|
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
|
return null;
|
|
|
|
if (string.IsNullOrWhiteSpace(userQuery))
|
|
return null;
|
|
|
|
var query = userQuery.ToLowerInvariant();
|
|
if (!ContainsAny(query, s_creationVerbs))
|
|
return null;
|
|
|
|
ProjectScaffoldProfile? bestProfile = null;
|
|
var bestScore = 0;
|
|
foreach (var profile in s_profiles)
|
|
{
|
|
var score = profile.TriggerTokens.Count(token => query.Contains(token, StringComparison.OrdinalIgnoreCase));
|
|
if (score > bestScore)
|
|
{
|
|
bestScore = score;
|
|
bestProfile = profile;
|
|
}
|
|
}
|
|
|
|
if (bestProfile != null && bestScore > 0)
|
|
return bestProfile;
|
|
|
|
if (ContainsAny(query, s_projectNouns) && ContainsAny(query, s_languageTokens))
|
|
return GetProfile("generic-solution");
|
|
|
|
if (ContainsAny(query, s_projectNouns) && ContainsAny(query, s_solutionShapeTokens))
|
|
return GetProfile("generic-solution");
|
|
|
|
return null;
|
|
}
|
|
|
|
internal static bool IsStructuredProjectRequest(string? userQuery, string? activeTab)
|
|
=> Detect(userQuery, activeTab) != null;
|
|
|
|
internal static string BuildDirectoryPreview(ProjectScaffoldProfile profile, int maxCount = 5)
|
|
=> string.Join(", ", profile.ExpectedDirectories.Take(Math.Max(1, maxCount)));
|
|
|
|
internal static string BuildStarterPathPreview(ProjectScaffoldProfile profile, int maxCount = 5)
|
|
=> string.Join(", ", profile.StarterPaths.Take(Math.Max(1, maxCount)).Select(path => $"'{path}'"));
|
|
|
|
internal static ProjectScaffoldLayoutAssessment AssessLayout(ProjectScaffoldProfile profile, string? workFolder)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
|
|
{
|
|
return new ProjectScaffoldLayoutAssessment(
|
|
IsSatisfied: false,
|
|
MatchedDirectoryCount: 0,
|
|
ExistingDirectories: [],
|
|
MissingDirectories: profile.ExpectedDirectories.ToList(),
|
|
SuspiciousRootFiles: []);
|
|
}
|
|
|
|
var existingDirectories = profile.ExpectedDirectories
|
|
.Where(dir => Directory.Exists(Path.Combine(workFolder, NormalizeDirectory(dir))))
|
|
.ToList();
|
|
var missingDirectories = profile.ExpectedDirectories
|
|
.Where(dir => !existingDirectories.Contains(dir, StringComparer.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
var allowedRootFiles = new HashSet<string>(s_commonAllowedRootFiles, StringComparer.OrdinalIgnoreCase);
|
|
foreach (var file in profile.AllowedRootFiles)
|
|
allowedRootFiles.Add(file);
|
|
|
|
var suspiciousRootFiles = Directory.EnumerateFiles(workFolder)
|
|
.Select(Path.GetFileName)
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
.Select(name => name!)
|
|
.Where(name =>
|
|
{
|
|
if (allowedRootFiles.Contains(name))
|
|
return false;
|
|
|
|
var extension = Path.GetExtension(name);
|
|
return s_relevantRootExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
|
})
|
|
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var isSatisfied = existingDirectories.Count >= profile.MinimumDirectoryHits
|
|
&& suspiciousRootFiles.Count == 0;
|
|
|
|
return new ProjectScaffoldLayoutAssessment(
|
|
IsSatisfied: isSatisfied,
|
|
MatchedDirectoryCount: existingDirectories.Count,
|
|
ExistingDirectories: existingDirectories,
|
|
MissingDirectories: missingDirectories,
|
|
SuspiciousRootFiles: suspiciousRootFiles);
|
|
}
|
|
|
|
private static ProjectScaffoldProfile GetProfile(string key)
|
|
=> s_profiles.First(profile => string.Equals(profile.Key, key, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private static string NormalizeDirectory(string value)
|
|
=> value.Replace('/', Path.DirectorySeparatorChar);
|
|
|
|
private static bool ContainsAny(string text, IEnumerable<string> tokens)
|
|
{
|
|
foreach (var token in tokens)
|
|
{
|
|
if (text.Contains(token, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|