feat: dependency resolver (Phase 1) + formatter + doctor + assertion evaluation

Resolver (Phase 1):
- resolve::resolve() takes SystemConfig + Registry → BuildPlan
- Three-layer feature/config merge (uses config::resolve_package)
- Feature-gated dependencies: deps with feature=X skipped if feature disabled
- Topological sort via Kahn's algorithm (BFS on in-degree)
- Cycle detection, missing package warnings
- CLI: kappa resolve <config> [<package>]

Formatter:
- format_package() + format_config() → canonical output
- Consistent 4-space indent, canonical declaration order

Doctor:
- check_package() + check_config() → warnings for common issues
- Missing fields, empty configs, root shell, feature warnings

Assertion evaluation:
- evaluate_assertions() resolves dotted field paths
- Supports == and != operators for config validation
- kappa validate now runs assertion checks

Review fixes:
- plan.steps.empty() exit code corrected to 0
- Removed unused <unordered_set> include
- Eliminated duplicate merge logic (uses config::resolve_package)
This commit is contained in:
2026-07-30 00:36:38 -04:00
parent 0c1e82d66b
commit ee9f280346
10 changed files with 746 additions and 23 deletions
+134 -3
View File
@@ -1,6 +1,10 @@
#include "kappa/cli/diagnostic.hpp"
#include "kappa/config/eval.hpp"
#include "kappa/dsl/parser.hpp"
#include "kappa/dsl/system.hpp"
#include "kappa/resolve/plan.hpp"
#include "kappa/tools/doctor.hpp"
#include "kappa/tools/format.hpp"
#include <cstdlib>
#include <fstream>
@@ -23,6 +27,9 @@ Subcommands:
parse-package <file> Parse and validate a package definition (.kap)
parse-config <file> Parse and validate a system configuration
validate <file> Validate any kappa file (package or config)
format <file> Format a .kap file to canonical style (printed to stdout)
doctor <file> Check a .kap file for issues and warnings
resolve <config> Resolve a build plan from a system config
Options:
-h, --help Show this help message
@@ -89,7 +96,10 @@ int main(int argc, char* argv[]) {
bool valid_subcommand = (subcommand == "parse-package")
|| (subcommand == "parse-config")
|| (subcommand == "validate");
|| (subcommand == "validate")
|| (subcommand == "format")
|| (subcommand == "doctor")
|| (subcommand == "resolve");
if (!valid_subcommand) {
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
@@ -146,8 +156,27 @@ int main(int argc, char* argv[]) {
return 0;
} catch (const std::runtime_error&) {
try {
dsl::parse_system_config(source);
std::cout << file_arg << ": valid system configuration\n";
auto cfg = dsl::parse_system_config(source);
auto failures = config::evaluate_assertions(cfg);
if (failures.empty()) {
std::cout << file_arg << ": valid system configuration ("
<< cfg.packages.size() << " packages, "
<< cfg.services.size() << " services, "
<< cfg.users.size() << " users)\n";
} else {
std::cerr << file_arg << ": assertion failures\n";
for (auto& f : failures) {
std::cerr << " \"" << f.message << "\"\n"
<< " " << f.field << " = \""
<< f.actual << "\"";
if (f.expected.empty()) {
std::cerr << " (must not be empty)\n";
} else {
std::cerr << " (expected \"" << f.expected << "\")\n";
}
}
return 1;
}
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
@@ -156,5 +185,107 @@ int main(int argc, char* argv[]) {
}
}
if (subcommand == "format") {
try {
auto pkg = dsl::parse(source);
tools::format_package(std::cout, pkg);
return 0;
} catch (const std::runtime_error&) {
try {
auto cfg = dsl::parse_system_config(source);
tools::format_config(std::cout, cfg);
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
return 1;
}
}
}
if (subcommand == "doctor") {
try {
auto pkg = dsl::parse(source);
auto diags = tools::check_package(pkg);
if (diags.empty()) {
std::cout << file_arg << ": no issues found\n";
} else {
for (auto& d : diags) {
std::cerr << (d.severity == tools::DiagSeverity::Error
? "error" : "warning")
<< ": " << d.message << "\n";
}
}
return diags.empty() ? 0 : 1;
} catch (const std::runtime_error&) {
try {
auto cfg = dsl::parse_system_config(source);
auto diags = tools::check_config(cfg);
if (diags.empty()) {
std::cout << file_arg << ": no issues found\n";
} else {
for (auto& d : diags) {
std::cerr << (d.severity == tools::DiagSeverity::Error
? "error" : "warning")
<< ": " << d.message << "\n";
}
}
return diags.empty() ? 0 : 1;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
return 1;
}
}
}
if (subcommand == "resolve") {
try {
auto cfg = dsl::parse_system_config(source);
resolve::Registry registry;
auto* pkg_arg = (argc > 3) ? argv[3] : nullptr;
if (pkg_arg != nullptr) {
auto pkg_src = read_file(pkg_arg);
auto pkg = dsl::parse(pkg_src);
registry[pkg.name] = std::move(pkg);
}
auto plan = resolve::resolve(cfg, registry);
if (!plan.missing.empty()) {
for (auto& m : plan.missing) {
std::cerr << "warning: package '" << m
<< "' not found in registry\n";
}
}
if (!plan.cycles.empty()) {
std::cerr << "error: dependency cycle detected:\n";
for (auto& c : plan.cycles) {
std::cerr << " " << c << "\n";
}
return 1;
}
std::cout << plan.steps.size() << " packages in build order:\n";
for (auto& step : plan.steps) {
std::cout << " " << step.name << " (" << step.dependencies.size()
<< " deps";
if (!step.features.empty()) {
std::cout << ", features:";
for (auto& [k, f] : step.features) {
if (f.enabled && !f.flag.empty()) {
std::cout << " " << k;
}
}
}
std::cout << ")\n";
}
return plan.steps.empty() && plan.missing.empty() ? 0 : 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
return 1;
}
}
return 1; // unreachable
}