journal/Journal.DevTool/Core/ConfigDoctorService.cs

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."));
}
}
}
}