wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Doctor.Engine;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -19,6 +23,12 @@ internal static class KnowledgeSearchCommandGroup
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private const string DefaultDocsAllowListPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-allowlist.json";
|
||||
private const string DefaultDocsManifestPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-manifest.json";
|
||||
private const string DefaultDoctorSeedPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-seed.json";
|
||||
private const string DefaultDoctorControlsPath = "src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-controls.json";
|
||||
private const string DefaultOpenApiAggregatePath = "devops/compose/openapi_current.json";
|
||||
|
||||
private static readonly HashSet<string> AllowedTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"docs",
|
||||
@@ -124,22 +134,97 @@ internal static class KnowledgeSearchCommandGroup
|
||||
var advisoryAi = new Command("advisoryai", "AdvisoryAI maintenance commands.");
|
||||
var index = new Command("index", "Knowledge index operations.");
|
||||
var rebuild = new Command("rebuild", "Rebuild AdvisoryAI deterministic knowledge index.");
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
var rebuildJsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
rebuild.Add(jsonOption);
|
||||
rebuild.Add(rebuildJsonOption);
|
||||
rebuild.Add(verboseOption);
|
||||
rebuild.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var emitJson = parseResult.GetValue(rebuildJsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
await ExecuteRebuildAsync(services, emitJson, verbose, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
var sources = new Command("sources", "Prepare deterministic knowledge source artifacts.");
|
||||
var prepare = new Command("prepare", "Aggregate docs allowlist, OpenAPI snapshot, and doctor controls seed data.");
|
||||
var repoRootOption = new Option<string>("--repo-root")
|
||||
{
|
||||
Description = "Repository root used to resolve relative source paths."
|
||||
};
|
||||
repoRootOption.SetDefaultValue(".");
|
||||
|
||||
var docsAllowListOption = new Option<string>("--docs-allowlist")
|
||||
{
|
||||
Description = "Path to docs allowlist JSON (include/includes/paths)."
|
||||
};
|
||||
docsAllowListOption.SetDefaultValue(DefaultDocsAllowListPath);
|
||||
|
||||
var docsManifestOutputOption = new Option<string>("--docs-manifest-output")
|
||||
{
|
||||
Description = "Output path for resolved markdown manifest JSON."
|
||||
};
|
||||
docsManifestOutputOption.SetDefaultValue(DefaultDocsManifestPath);
|
||||
|
||||
var openApiOutputOption = new Option<string>("--openapi-output")
|
||||
{
|
||||
Description = "Output path for aggregated OpenAPI snapshot."
|
||||
};
|
||||
openApiOutputOption.SetDefaultValue(DefaultOpenApiAggregatePath);
|
||||
|
||||
var doctorSeedOption = new Option<string>("--doctor-seed")
|
||||
{
|
||||
Description = "Input doctor seed JSON used to derive controls."
|
||||
};
|
||||
doctorSeedOption.SetDefaultValue(DefaultDoctorSeedPath);
|
||||
|
||||
var doctorControlsOutputOption = new Option<string>("--doctor-controls-output")
|
||||
{
|
||||
Description = "Output path for doctor controls JSON."
|
||||
};
|
||||
doctorControlsOutputOption.SetDefaultValue(DefaultDoctorControlsPath);
|
||||
|
||||
var overwriteOption = new Option<bool>("--overwrite")
|
||||
{
|
||||
Description = "Overwrite generated artifacts and OpenAPI snapshot."
|
||||
};
|
||||
|
||||
var prepareJsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
prepare.Add(repoRootOption);
|
||||
prepare.Add(docsAllowListOption);
|
||||
prepare.Add(docsManifestOutputOption);
|
||||
prepare.Add(openApiOutputOption);
|
||||
prepare.Add(doctorSeedOption);
|
||||
prepare.Add(doctorControlsOutputOption);
|
||||
prepare.Add(overwriteOption);
|
||||
prepare.Add(prepareJsonOption);
|
||||
prepare.Add(verboseOption);
|
||||
prepare.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
await ExecutePrepareSourcesAsync(
|
||||
services,
|
||||
repoRoot: parseResult.GetValue(repoRootOption) ?? ".",
|
||||
docsAllowListPath: parseResult.GetValue(docsAllowListOption) ?? DefaultDocsAllowListPath,
|
||||
docsManifestOutputPath: parseResult.GetValue(docsManifestOutputOption) ?? DefaultDocsManifestPath,
|
||||
openApiOutputPath: parseResult.GetValue(openApiOutputOption) ?? DefaultOpenApiAggregatePath,
|
||||
doctorSeedPath: parseResult.GetValue(doctorSeedOption) ?? DefaultDoctorSeedPath,
|
||||
doctorControlsOutputPath: parseResult.GetValue(doctorControlsOutputOption) ?? DefaultDoctorControlsPath,
|
||||
overwrite: parseResult.GetValue(overwriteOption),
|
||||
emitJson: parseResult.GetValue(prepareJsonOption),
|
||||
verbose: parseResult.GetValue(verboseOption),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
sources.Add(prepare);
|
||||
index.Add(rebuild);
|
||||
advisoryAi.Add(index);
|
||||
advisoryAi.Add(sources);
|
||||
return advisoryAi;
|
||||
}
|
||||
|
||||
@@ -318,6 +403,585 @@ internal static class KnowledgeSearchCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExecutePrepareSourcesAsync(
|
||||
IServiceProvider services,
|
||||
string repoRoot,
|
||||
string docsAllowListPath,
|
||||
string docsManifestOutputPath,
|
||||
string openApiOutputPath,
|
||||
string doctorSeedPath,
|
||||
string doctorControlsOutputPath,
|
||||
bool overwrite,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var repositoryRoot = ResolvePath(Directory.GetCurrentDirectory(), repoRoot);
|
||||
var docsAllowListAbsolute = ResolvePath(repositoryRoot, docsAllowListPath);
|
||||
var docsManifestAbsolute = ResolvePath(repositoryRoot, docsManifestOutputPath);
|
||||
var openApiAbsolute = ResolvePath(repositoryRoot, openApiOutputPath);
|
||||
var doctorSeedAbsolute = ResolvePath(repositoryRoot, doctorSeedPath);
|
||||
var doctorControlsAbsolute = ResolvePath(repositoryRoot, doctorControlsOutputPath);
|
||||
|
||||
var includeEntries = LoadAllowListIncludes(docsAllowListAbsolute);
|
||||
var markdownFiles = ResolveMarkdownFiles(repositoryRoot, includeEntries);
|
||||
var markdownDocuments = markdownFiles
|
||||
.Select(path => new DocsManifestDocument(
|
||||
path,
|
||||
ComputeSha256Hex(ResolvePath(repositoryRoot, path))))
|
||||
.ToArray();
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Docs allowlist: {docsAllowListAbsolute}");
|
||||
Console.WriteLine($"Resolved markdown files: {markdownFiles.Count}");
|
||||
}
|
||||
|
||||
WriteDocsManifest(
|
||||
docsManifestAbsolute,
|
||||
includeEntries,
|
||||
markdownDocuments,
|
||||
overwrite);
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var apiResult = await backend.DownloadApiSpecAsync(
|
||||
new ApiSpecDownloadRequest
|
||||
{
|
||||
OutputPath = openApiAbsolute,
|
||||
Service = null,
|
||||
Format = "openapi-json",
|
||||
Overwrite = overwrite,
|
||||
ChecksumAlgorithm = "sha256"
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!apiResult.Success)
|
||||
{
|
||||
Console.Error.WriteLine($"OpenAPI aggregation failed: {apiResult.Error ?? "unknown error"}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
return;
|
||||
}
|
||||
|
||||
var configuredDoctorSeedEntries = LoadDoctorSeedEntries(doctorSeedAbsolute);
|
||||
var discoveredDoctorEntries = LoadDoctorSeedEntriesFromEngine(services, verbose);
|
||||
var doctorSeedEntries = MergeDoctorSeedEntries(configuredDoctorSeedEntries, discoveredDoctorEntries);
|
||||
var doctorControlEntries = BuildDoctorControls(doctorSeedEntries);
|
||||
WriteDoctorControls(
|
||||
doctorControlsAbsolute,
|
||||
doctorControlEntries,
|
||||
overwrite);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
repositoryRoot,
|
||||
docs = new
|
||||
{
|
||||
allowListPath = ToRelativePath(repositoryRoot, docsAllowListAbsolute),
|
||||
manifestPath = ToRelativePath(repositoryRoot, docsManifestAbsolute),
|
||||
includeCount = includeEntries.Count,
|
||||
documentCount = markdownDocuments.Length
|
||||
},
|
||||
openApi = new
|
||||
{
|
||||
path = ToRelativePath(repositoryRoot, openApiAbsolute),
|
||||
fromCache = apiResult.FromCache,
|
||||
checksum = apiResult.Checksum
|
||||
},
|
||||
doctor = new
|
||||
{
|
||||
seedPath = ToRelativePath(repositoryRoot, doctorSeedAbsolute),
|
||||
configuredSeedCount = configuredDoctorSeedEntries.Count,
|
||||
discoveredSeedCount = discoveredDoctorEntries.Count,
|
||||
mergedSeedCount = doctorSeedEntries.Count,
|
||||
controlsPath = ToRelativePath(repositoryRoot, doctorControlsAbsolute),
|
||||
controlCount = doctorControlEntries.Length
|
||||
}
|
||||
});
|
||||
Environment.ExitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("AdvisoryAI source artifacts prepared.");
|
||||
Console.WriteLine($" Docs allowlist: {ToRelativePath(repositoryRoot, docsAllowListAbsolute)}");
|
||||
Console.WriteLine($" Docs manifest: {ToRelativePath(repositoryRoot, docsManifestAbsolute)} ({markdownDocuments.Length} markdown files)");
|
||||
Console.WriteLine($" OpenAPI aggregate: {ToRelativePath(repositoryRoot, openApiAbsolute)}");
|
||||
Console.WriteLine($" Doctor seed (configured/discovered/merged): {configuredDoctorSeedEntries.Count}/{discoveredDoctorEntries.Count}/{doctorSeedEntries.Count}");
|
||||
Console.WriteLine($" Doctor controls: {ToRelativePath(repositoryRoot, doctorControlsAbsolute)} ({doctorControlEntries.Length} checks)");
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"AdvisoryAI source preparation failed: {ex.Message}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePath(string repositoryRoot, string configuredPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return repositoryRoot;
|
||||
}
|
||||
|
||||
return Path.IsPathRooted(configuredPath)
|
||||
? Path.GetFullPath(configuredPath)
|
||||
: Path.GetFullPath(Path.Combine(repositoryRoot, configuredPath));
|
||||
}
|
||||
|
||||
private static string ToRelativePath(string repositoryRoot, string absolutePath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(repositoryRoot, absolutePath).Replace('\\', '/');
|
||||
return string.IsNullOrWhiteSpace(relative) ? "." : relative;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> LoadAllowListIncludes(string allowListPath)
|
||||
{
|
||||
if (!File.Exists(allowListPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Docs allowlist file was not found: {allowListPath}", allowListPath);
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(allowListPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return ReadStringList(root);
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (TryGetStringListProperty(root, "include", out var include))
|
||||
{
|
||||
return include;
|
||||
}
|
||||
|
||||
if (TryGetStringListProperty(root, "includes", out include))
|
||||
{
|
||||
return include;
|
||||
}
|
||||
|
||||
if (TryGetStringListProperty(root, "paths", out include))
|
||||
{
|
||||
return include;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveMarkdownFiles(string repositoryRoot, IReadOnlyList<string> includeEntries)
|
||||
{
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var include in includeEntries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(include))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var absolutePath = ResolvePath(repositoryRoot, include);
|
||||
if (File.Exists(absolutePath))
|
||||
{
|
||||
if (Path.GetExtension(absolutePath).Equals(".md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
files.Add(ToRelativePath(repositoryRoot, Path.GetFullPath(absolutePath)));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(absolutePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(absolutePath, "*.md", SearchOption.AllDirectories))
|
||||
{
|
||||
files.Add(ToRelativePath(repositoryRoot, Path.GetFullPath(file)));
|
||||
}
|
||||
}
|
||||
|
||||
return files.OrderBy(static file => file, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static void WriteDocsManifest(
|
||||
string outputPath,
|
||||
IReadOnlyList<string> includeEntries,
|
||||
IReadOnlyList<DocsManifestDocument> documents,
|
||||
bool overwrite)
|
||||
{
|
||||
if (File.Exists(outputPath) && !overwrite)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
schema = "stellaops.advisoryai.docs-manifest.v1",
|
||||
include = includeEntries.OrderBy(static entry => entry, StringComparer.Ordinal).ToArray(),
|
||||
documents = documents.OrderBy(static entry => entry.Path, StringComparer.Ordinal).ToArray()
|
||||
};
|
||||
|
||||
File.WriteAllText(outputPath, JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DoctorSeedEntry> LoadDoctorSeedEntries(string doctorSeedPath)
|
||||
{
|
||||
if (!File.Exists(doctorSeedPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(doctorSeedPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var entries = new List<DoctorSeedEntry>();
|
||||
foreach (var item in document.RootElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var checkCode = ReadString(item, "checkCode");
|
||||
if (string.IsNullOrWhiteSpace(checkCode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new DoctorSeedEntry(
|
||||
checkCode,
|
||||
ReadString(item, "title") ?? checkCode,
|
||||
ReadString(item, "severity") ?? "warn",
|
||||
ReadString(item, "description") ?? string.Empty,
|
||||
ReadString(item, "remediation") ?? string.Empty,
|
||||
ReadString(item, "runCommand") ?? $"stella doctor run --check {checkCode}",
|
||||
TryGetStringListProperty(item, "symptoms", out var symptoms) ? symptoms : [],
|
||||
TryGetStringListProperty(item, "tags", out var tags) ? tags : [],
|
||||
TryGetStringListProperty(item, "references", out var references) ? references : []));
|
||||
}
|
||||
|
||||
return entries
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DoctorSeedEntry> LoadDoctorSeedEntriesFromEngine(IServiceProvider services, bool verbose)
|
||||
{
|
||||
var engine = services.GetService<DoctorEngine>();
|
||||
if (engine is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var checks = engine.ListChecks();
|
||||
return checks
|
||||
.OrderBy(static check => check.CheckId, StringComparer.Ordinal)
|
||||
.Select(static check =>
|
||||
{
|
||||
var runCommand = $"stella doctor run --check {check.CheckId}";
|
||||
var references = MergeUniqueStrings(
|
||||
string.IsNullOrWhiteSpace(check.Category) ? [] : [$"category:{check.Category}"],
|
||||
string.IsNullOrWhiteSpace(check.PluginId) ? [] : [$"plugin:{check.PluginId}"]);
|
||||
var tags = MergeUniqueStrings(
|
||||
check.Tags,
|
||||
string.IsNullOrWhiteSpace(check.Category) ? [] : [check.Category],
|
||||
["doctor", "diagnostics"]);
|
||||
|
||||
return new DoctorSeedEntry(
|
||||
check.CheckId,
|
||||
string.IsNullOrWhiteSpace(check.Name) ? check.CheckId : check.Name.Trim(),
|
||||
check.DefaultSeverity.ToString().ToLowerInvariant(),
|
||||
(check.Description ?? string.Empty).Trim(),
|
||||
$"Run `{runCommand}` and follow the diagnosis output.",
|
||||
runCommand,
|
||||
BuildSymptomsFromCheckText(check.Name, check.Description, check.Tags),
|
||||
tags,
|
||||
references);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Doctor check discovery from engine failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DoctorSeedEntry> MergeDoctorSeedEntries(
|
||||
IReadOnlyList<DoctorSeedEntry> configuredEntries,
|
||||
IReadOnlyList<DoctorSeedEntry> discoveredEntries)
|
||||
{
|
||||
var merged = discoveredEntries
|
||||
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var configured in configuredEntries)
|
||||
{
|
||||
if (!merged.TryGetValue(configured.CheckCode, out var discovered))
|
||||
{
|
||||
merged[configured.CheckCode] = configured;
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[configured.CheckCode] = new DoctorSeedEntry(
|
||||
configured.CheckCode,
|
||||
PreferConfigured(configured.Title, discovered.Title, configured.CheckCode),
|
||||
PreferConfigured(configured.Severity, discovered.Severity, "warn"),
|
||||
PreferConfigured(configured.Description, discovered.Description, string.Empty),
|
||||
PreferConfigured(configured.Remediation, discovered.Remediation, string.Empty),
|
||||
PreferConfigured(configured.RunCommand, discovered.RunCommand, $"stella doctor run --check {configured.CheckCode}"),
|
||||
MergeUniqueStrings(configured.Symptoms, discovered.Symptoms),
|
||||
MergeUniqueStrings(configured.Tags, discovered.Tags),
|
||||
MergeUniqueStrings(configured.References, discovered.References));
|
||||
}
|
||||
|
||||
return merged.Values
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static DoctorControlEntry[] BuildDoctorControls(IReadOnlyList<DoctorSeedEntry> seedEntries)
|
||||
{
|
||||
return seedEntries
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.Select(static entry =>
|
||||
{
|
||||
var mode = InferControlMode(entry.Severity);
|
||||
var requiresConfirmation = !mode.Equals("safe", StringComparison.Ordinal);
|
||||
return new DoctorControlEntry(
|
||||
entry.CheckCode,
|
||||
mode,
|
||||
requiresConfirmation,
|
||||
IsDestructive: false,
|
||||
RequiresBackup: false,
|
||||
InspectCommand: $"stella doctor run --check {entry.CheckCode} --mode quick",
|
||||
VerificationCommand: string.IsNullOrWhiteSpace(entry.RunCommand)
|
||||
? $"stella doctor run --check {entry.CheckCode}"
|
||||
: entry.RunCommand.Trim(),
|
||||
Keywords: BuildKeywordList(entry),
|
||||
Title: entry.Title,
|
||||
Severity: entry.Severity,
|
||||
Description: entry.Description,
|
||||
Remediation: entry.Remediation,
|
||||
RunCommand: string.IsNullOrWhiteSpace(entry.RunCommand)
|
||||
? $"stella doctor run --check {entry.CheckCode}"
|
||||
: entry.RunCommand.Trim(),
|
||||
Symptoms: entry.Symptoms,
|
||||
Tags: entry.Tags,
|
||||
References: entry.References);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string InferControlMode(string severity)
|
||||
{
|
||||
var normalized = (severity ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"critical" => "manual",
|
||||
"error" => "manual",
|
||||
"fail" => "manual",
|
||||
"failure" => "manual",
|
||||
"high" => "manual",
|
||||
_ => "safe"
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildKeywordList(DoctorSeedEntry entry)
|
||||
{
|
||||
var terms = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var symptom in entry.Symptoms)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(symptom))
|
||||
{
|
||||
terms.Add(symptom.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var token in (entry.Title + " " + entry.Description)
|
||||
.Split(new[] { ' ', '\t', '\r', '\n', ',', ';', ':', '.', '(', ')', '[', ']', '{', '}', '"', '\'' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token.Length >= 4)
|
||||
{
|
||||
terms.Add(token.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return terms.Take(12).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildSymptomsFromCheckText(
|
||||
string? title,
|
||||
string? description,
|
||||
IReadOnlyList<string>? tags)
|
||||
{
|
||||
var terms = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var token in (title + " " + description)
|
||||
.Split(new[] { ' ', '\t', '\r', '\n', ',', ';', ':', '.', '(', ')', '[', ']', '{', '}', '"', '\'' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token.Length >= 5)
|
||||
{
|
||||
terms.Add(token.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
if (tags is not null)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
terms.Add(tag.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return terms.Take(12).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> MergeUniqueStrings(params IReadOnlyList<string>?[] values)
|
||||
{
|
||||
var merged = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var source in values)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var value in source)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
merged.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged.ToArray();
|
||||
}
|
||||
|
||||
private static string PreferConfigured(string? configured, string? discovered, string fallback)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return configured.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(discovered))
|
||||
{
|
||||
return discovered.Trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static void WriteDoctorControls(string outputPath, IReadOnlyList<DoctorControlEntry> controls, bool overwrite)
|
||||
{
|
||||
if (File.Exists(outputPath) && !overwrite)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var payload = controls
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.Select(static entry => new
|
||||
{
|
||||
checkCode = entry.CheckCode,
|
||||
control = entry.Control,
|
||||
requiresConfirmation = entry.RequiresConfirmation,
|
||||
isDestructive = entry.IsDestructive,
|
||||
requiresBackup = entry.RequiresBackup,
|
||||
inspectCommand = entry.InspectCommand,
|
||||
verificationCommand = entry.VerificationCommand,
|
||||
keywords = entry.Keywords,
|
||||
title = entry.Title,
|
||||
severity = entry.Severity,
|
||||
description = entry.Description,
|
||||
remediation = entry.Remediation,
|
||||
runCommand = entry.RunCommand,
|
||||
symptoms = entry.Symptoms,
|
||||
tags = entry.Tags,
|
||||
references = entry.References
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
File.WriteAllText(outputPath, JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string absolutePath)
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool TryGetStringListProperty(JsonElement element, string propertyName, out IReadOnlyList<string> values)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
values = ReadStringList(property);
|
||||
return true;
|
||||
}
|
||||
|
||||
values = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStringList(JsonElement array)
|
||||
{
|
||||
return array.EnumerateArray()
|
||||
.Where(static item => item.ValueKind == JsonValueKind.String)
|
||||
.Select(static item => item.GetString())
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static item => item, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchFilterModel? BuildFilter(
|
||||
IReadOnlyList<string> types,
|
||||
IReadOnlyList<string> tags,
|
||||
@@ -518,7 +1182,7 @@ internal static class KnowledgeSearchCommandGroup
|
||||
if (result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase) && result.Open.Doctor is not null)
|
||||
{
|
||||
var doctor = result.Open.Doctor;
|
||||
return $"doctor: {doctor.CheckCode} severity={doctor.Severity} run=\"{doctor.RunCommand}\"";
|
||||
return $"doctor: {doctor.CheckCode} severity={doctor.Severity} control={doctor.Control} confirm={doctor.RequiresConfirmation.ToString().ToLowerInvariant()} run=\"{doctor.RunCommand}\"";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
@@ -580,6 +1244,39 @@ internal static class KnowledgeSearchCommandGroup
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record DocsManifestDocument(
|
||||
string Path,
|
||||
string Sha256);
|
||||
|
||||
private sealed record DoctorSeedEntry(
|
||||
string CheckCode,
|
||||
string Title,
|
||||
string Severity,
|
||||
string Description,
|
||||
string Remediation,
|
||||
string RunCommand,
|
||||
IReadOnlyList<string> Symptoms,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<string> References);
|
||||
|
||||
private sealed record DoctorControlEntry(
|
||||
string CheckCode,
|
||||
string Control,
|
||||
bool RequiresConfirmation,
|
||||
bool IsDestructive,
|
||||
bool RequiresBackup,
|
||||
string InspectCommand,
|
||||
string VerificationCommand,
|
||||
IReadOnlyList<string> Keywords,
|
||||
string Title,
|
||||
string Severity,
|
||||
string Description,
|
||||
string Remediation,
|
||||
string RunCommand,
|
||||
IReadOnlyList<string> Symptoms,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<string> References);
|
||||
|
||||
private static void WriteJson(object payload)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
|
||||
Reference in New Issue
Block a user