271 lines
9.6 KiB
C#
271 lines
9.6 KiB
C#
using Sdt.Config;
|
|
|
|
namespace Sdt.Core;
|
|
|
|
public enum DoctorStatus
|
|
{
|
|
Pass,
|
|
Warn,
|
|
Fail,
|
|
}
|
|
|
|
public sealed record DoctorCheck(
|
|
string Name,
|
|
DoctorStatus Status,
|
|
string Detail,
|
|
string? Fix = null);
|
|
|
|
public sealed record DoctorReport(
|
|
IReadOnlyList<DoctorCheck> Checks)
|
|
{
|
|
public bool HasFailures => Checks.Any(c => c.Status == DoctorStatus.Fail);
|
|
public bool HasWarnings => Checks.Any(c => c.Status == DoctorStatus.Warn);
|
|
}
|
|
|
|
public sealed class ConfigDoctorService(
|
|
IToolProbe? toolProbe = null,
|
|
IRequirementResolver? requirementResolver = null)
|
|
{
|
|
private readonly IToolProbe _toolProbe = toolProbe ?? new ToolProbeService();
|
|
private readonly IRequirementResolver _requirementResolver = requirementResolver ?? new RequirementResolver();
|
|
|
|
public async Task<DoctorReport> RunAsync(
|
|
DevToolConfig config,
|
|
string projectRoot,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var checks = new List<DoctorCheck>();
|
|
var workflowMap = config.Workflows.ToDictionary(w => w.Id, StringComparer.OrdinalIgnoreCase);
|
|
|
|
AddSchemaChecks(config, checks);
|
|
AddWorkflowChecks(config.Workflows, workflowMap, projectRoot, checks);
|
|
AddPathChecks(config, projectRoot, checks);
|
|
await AddToolProbeChecksAsync(config, projectRoot, checks, cancellationToken).ConfigureAwait(false);
|
|
|
|
return new DoctorReport(checks);
|
|
}
|
|
|
|
private static void AddSchemaChecks(DevToolConfig config, List<DoctorCheck> checks)
|
|
{
|
|
if (config.Workflows.Count == 0 && config.Targets.Count == 0)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"Config schema",
|
|
DoctorStatus.Fail,
|
|
"No workflows or legacy targets found.",
|
|
"Add workflows or run SDT init/bootstrap."));
|
|
return;
|
|
}
|
|
|
|
if (config.Workflows.Count == 0 && config.Targets.Count > 0)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"Legacy schema",
|
|
DoctorStatus.Fail,
|
|
"Targets-only config detected (strict mode will block execution).",
|
|
"Use SYSTEM -> Migrate legacy targets -> workflows."));
|
|
return;
|
|
}
|
|
|
|
if (config.Targets.Count > 0)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"Legacy schema",
|
|
DoctorStatus.Warn,
|
|
"Both workflows and legacy targets are present.",
|
|
"Prefer workflows-only config and remove legacy targets once migrated."));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new DoctorCheck("Config schema", DoctorStatus.Pass, "Workflow-first config detected."));
|
|
}
|
|
}
|
|
|
|
private static void AddWorkflowChecks(
|
|
IReadOnlyList<WorkflowDefinition> workflows,
|
|
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap,
|
|
string projectRoot,
|
|
List<DoctorCheck> checks)
|
|
{
|
|
var duplicateIds = workflows
|
|
.GroupBy(w => w.Id, StringComparer.OrdinalIgnoreCase)
|
|
.Where(g => g.Count() > 1)
|
|
.Select(g => g.Key)
|
|
.ToList();
|
|
|
|
if (duplicateIds.Count > 0)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"Workflow IDs",
|
|
DoctorStatus.Fail,
|
|
$"Duplicate workflow IDs: {string.Join(", ", duplicateIds)}",
|
|
"Ensure each workflow has a unique id."));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new DoctorCheck("Workflow IDs", DoctorStatus.Pass, "No duplicate workflow IDs."));
|
|
}
|
|
|
|
var brokenDeps = new List<string>();
|
|
foreach (var workflow in workflows)
|
|
{
|
|
foreach (var dep in workflow.DependsOn)
|
|
{
|
|
if (!workflowMap.ContainsKey(dep))
|
|
brokenDeps.Add($"{workflow.Id} -> {dep}");
|
|
}
|
|
}
|
|
|
|
if (brokenDeps.Count > 0)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"Workflow dependencies",
|
|
DoctorStatus.Fail,
|
|
$"Missing dependencies: {string.Join("; ", brokenDeps)}",
|
|
"Fix dependsOn IDs to reference existing workflows."));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new DoctorCheck("Workflow dependencies", DoctorStatus.Pass, "All workflow dependencies are valid."));
|
|
}
|
|
|
|
var invalidSteps = new List<string>();
|
|
var missingWorkingDirs = new List<string>();
|
|
foreach (var workflow in workflows)
|
|
{
|
|
foreach (var step in workflow.Steps)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(step.Command) && string.IsNullOrWhiteSpace(step.Action))
|
|
invalidSteps.Add($"{workflow.Id}/{step.Id}");
|
|
|
|
var stepDir = Path.GetFullPath(Path.Combine(projectRoot, step.WorkingDir));
|
|
if (!Directory.Exists(stepDir))
|
|
missingWorkingDirs.Add($"{workflow.Id}/{step.Id} -> {step.WorkingDir}");
|
|
}
|
|
}
|
|
|
|
if (invalidSteps.Count > 0)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"Step definitions",
|
|
DoctorStatus.Fail,
|
|
$"Steps missing command/action: {string.Join(", ", invalidSteps)}",
|
|
"Each step must define either action or command."));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new DoctorCheck("Step definitions", DoctorStatus.Pass, "All steps define an action or command."));
|
|
}
|
|
|
|
if (missingWorkingDirs.Count > 0)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"Working directories",
|
|
DoctorStatus.Warn,
|
|
$"Missing directories: {string.Join("; ", missingWorkingDirs)}",
|
|
"Create missing directories or fix step workingDir values."));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new DoctorCheck("Working directories", DoctorStatus.Pass, "All referenced working directories exist."));
|
|
}
|
|
}
|
|
|
|
private static void AddPathChecks(DevToolConfig config, string projectRoot, List<DoctorCheck> checks)
|
|
{
|
|
var configPath = Path.Combine(projectRoot, "devtool.json");
|
|
checks.Add(File.Exists(configPath)
|
|
? new DoctorCheck("Project root", DoctorStatus.Pass, $"Config found at {configPath}")
|
|
: new DoctorCheck("Project root", DoctorStatus.Fail, $"devtool.json not found at {configPath}", "Run SDT init/bootstrap."));
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
|
var unresolvedSegments = pathValue
|
|
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
|
|
.Where(s => s.Contains('%') && Environment.ExpandEnvironmentVariables(s) == s)
|
|
.Take(4)
|
|
.ToList();
|
|
|
|
if (unresolvedSegments.Count > 0)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"PATH expansion",
|
|
DoctorStatus.Warn,
|
|
$"Unresolved PATH tokens: {string.Join(" | ", unresolvedSegments)}",
|
|
"Set referenced env vars or remove invalid PATH segments."));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new DoctorCheck("PATH expansion", DoctorStatus.Pass, "No unresolved PATH token segments detected."));
|
|
}
|
|
}
|
|
|
|
if (config.Project?.RootHints.Count > 0)
|
|
{
|
|
checks.Add(new DoctorCheck("Root hints", DoctorStatus.Pass, $"Configured root hints: {string.Join(", ", config.Project.RootHints)}"));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
"Root hints",
|
|
DoctorStatus.Warn,
|
|
"No project.rootHints configured.",
|
|
"Add rootHints markers (for example .git, *.sln, package.json)."));
|
|
}
|
|
}
|
|
|
|
private async Task AddToolProbeChecksAsync(
|
|
DevToolConfig config,
|
|
string projectRoot,
|
|
List<DoctorCheck> checks,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var requiredTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var workflow in config.Workflows)
|
|
{
|
|
foreach (var step in workflow.Steps)
|
|
{
|
|
foreach (var req in _requirementResolver.Resolve(step))
|
|
requiredTools.Add(req.Tool);
|
|
}
|
|
}
|
|
|
|
foreach (var profile in config.Debug?.Profiles ?? [])
|
|
{
|
|
foreach (var req in profile.Requires)
|
|
requiredTools.Add(req.Tool);
|
|
}
|
|
|
|
foreach (var toolDef in config.Tooling?.Tools ?? [])
|
|
requiredTools.Add(toolDef.Tool);
|
|
|
|
if (requiredTools.Count == 0)
|
|
{
|
|
checks.Add(new DoctorCheck("Tool probes", DoctorStatus.Warn, "No tools discovered from workflows/debug/tooling."));
|
|
return;
|
|
}
|
|
|
|
foreach (var tool in requiredTools.OrderBy(t => t, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var probe = await _toolProbe.ProbeAsync(tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
|
|
if (probe.IsAvailable)
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
$"Tool: {tool}",
|
|
DoctorStatus.Pass,
|
|
string.IsNullOrWhiteSpace(probe.Version) ? "available" : probe.Version!,
|
|
probe.Details));
|
|
}
|
|
else
|
|
{
|
|
checks.Add(new DoctorCheck(
|
|
$"Tool: {tool}",
|
|
DoctorStatus.Fail,
|
|
string.IsNullOrWhiteSpace(probe.Details) ? "not available" : probe.Details!,
|
|
$"Install/configure {tool} or set tooling.tools[].executables for non-standard paths."));
|
|
}
|
|
}
|
|
}
|
|
}
|