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 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 RunAsync( DevToolConfig config, string projectRoot, CancellationToken cancellationToken = default) { var checks = new List(); 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 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 workflows, IReadOnlyDictionary workflowMap, string projectRoot, List 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(); 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(); var missingWorkingDirs = new List(); 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 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 checks, CancellationToken cancellationToken) { var requiredTools = new HashSet(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.")); } } } }