lint!
This commit is contained in:
394
source/src/commands/validate.cpp
Normal file
394
source/src/commands/validate.cpp
Normal file
@@ -0,0 +1,394 @@
|
||||
#include "command_registry.hpp"
|
||||
#include "config.hpp"
|
||||
#include "templates.hpp"
|
||||
#include "utils/utils.hpp"
|
||||
#include "utils/directories.hpp"
|
||||
#include "utils/execute.hpp"
|
||||
#include "shared_commands.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <regex>
|
||||
#include <set>
|
||||
|
||||
namespace dropshell {
|
||||
|
||||
void validate_autocomplete(const CommandContext& ctx);
|
||||
int validate_handler(const CommandContext& ctx);
|
||||
|
||||
static std::vector<std::string> validate_name_list = {"validate", "lint", "check-template"};
|
||||
|
||||
// Static registration
|
||||
struct ValidateCommandRegister {
|
||||
ValidateCommandRegister() {
|
||||
CommandRegistry::instance().register_command({
|
||||
validate_name_list,
|
||||
validate_handler,
|
||||
validate_autocomplete,
|
||||
false, // hidden
|
||||
true, // requires_config
|
||||
true, // requires_install
|
||||
1, // min_args (template name required)
|
||||
1, // max_args
|
||||
"validate TEMPLATE",
|
||||
"Validate a template's structure, syntax, and common issues.",
|
||||
R"(
|
||||
Validates a dropshell template by checking:
|
||||
1. Required files exist (install.sh, uninstall.sh, config files)
|
||||
2. Shell script syntax (bash -n)
|
||||
3. Common issues:
|
||||
- Scripts source common.sh
|
||||
- Error handling patterns (_die usage)
|
||||
- Proper quoting of variables
|
||||
|
||||
Example:
|
||||
ds validate my-template
|
||||
)"
|
||||
});
|
||||
}
|
||||
} validate_command_register;
|
||||
|
||||
|
||||
void validate_autocomplete(const CommandContext& ctx) {
|
||||
if (ctx.args.size() == 0) {
|
||||
// list all templates
|
||||
std::set<std::string> templates = gTemplateManager().get_template_list();
|
||||
for (const auto& t : templates) {
|
||||
rawout << t << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Validation helper functions
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
struct ValidationResult {
|
||||
bool passed;
|
||||
std::string message;
|
||||
std::string details;
|
||||
};
|
||||
|
||||
// Check shell script syntax using bash -n
|
||||
ValidationResult check_bash_syntax(const std::filesystem::path& script_path) {
|
||||
ValidationResult result;
|
||||
result.passed = true;
|
||||
|
||||
std::string command = "bash -n " + script_path.string() + " 2>&1";
|
||||
std::string output;
|
||||
|
||||
ordered_env_vars empty_env;
|
||||
bool success = execute_local_command(".", command, empty_env, &output, cMode::Silent);
|
||||
|
||||
if (!success || !output.empty()) {
|
||||
result.passed = false;
|
||||
result.message = "Syntax error in " + script_path.filename().string();
|
||||
result.details = output;
|
||||
} else {
|
||||
result.message = "Syntax OK: " + script_path.filename().string();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if script sources common.sh
|
||||
ValidationResult check_common_sh_sourced(const std::filesystem::path& script_path) {
|
||||
ValidationResult result;
|
||||
result.passed = false;
|
||||
|
||||
std::ifstream file(script_path);
|
||||
if (!file.is_open()) {
|
||||
result.message = "Could not open " + script_path.filename().string();
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string line;
|
||||
std::regex source_pattern(R"(source\s+[\"']?\$\{?AGENT_PATH\}?[/\\]+_?common\.sh)");
|
||||
std::regex dot_pattern(R"(\.\s+[\"']?\$\{?AGENT_PATH\}?[/\\]+_?common\.sh)");
|
||||
|
||||
while (std::getline(file, line)) {
|
||||
if (std::regex_search(line, source_pattern) || std::regex_search(line, dot_pattern)) {
|
||||
result.passed = true;
|
||||
result.message = "Sources common.sh: " + script_path.filename().string();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
result.message = "Missing 'source \"${AGENT_PATH}/common.sh\"' in " + script_path.filename().string();
|
||||
result.details = "Scripts should source common.sh to use _die, _check_docker_installed, etc.";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for risky commands without error handling
|
||||
ValidationResult check_error_handling(const std::filesystem::path& script_path) {
|
||||
ValidationResult result;
|
||||
result.passed = true;
|
||||
|
||||
std::ifstream file(script_path);
|
||||
if (!file.is_open()) {
|
||||
result.message = "Could not open " + script_path.filename().string();
|
||||
result.passed = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Commands that should have error handling
|
||||
std::vector<std::string> risky_commands = {
|
||||
"docker compose",
|
||||
"docker run",
|
||||
"mkdir ",
|
||||
"cp ",
|
||||
"mv ",
|
||||
"rm ",
|
||||
"curl ",
|
||||
"wget ",
|
||||
"pip install",
|
||||
"pip3 install",
|
||||
"apt-get",
|
||||
"apt ",
|
||||
"git clone",
|
||||
"git pull"
|
||||
};
|
||||
|
||||
std::vector<std::string> issues;
|
||||
std::string line;
|
||||
int line_num = 0;
|
||||
|
||||
while (std::getline(file, line)) {
|
||||
line_num++;
|
||||
|
||||
// Skip comments and empty lines
|
||||
std::string trimmed = trim(line);
|
||||
if (trimmed.empty() || trimmed[0] == '#') continue;
|
||||
|
||||
// Skip lines that already have error handling
|
||||
if (line.find("|| _die") != std::string::npos ||
|
||||
line.find("|| exit") != std::string::npos ||
|
||||
line.find("|| return") != std::string::npos ||
|
||||
line.find("|| true") != std::string::npos ||
|
||||
line.find("|| echo") != std::string::npos ||
|
||||
line.find("if ") == 0 || line.find("if !") != std::string::npos ||
|
||||
line.find("&&") != std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const auto& cmd : risky_commands) {
|
||||
if (line.find(cmd) != std::string::npos) {
|
||||
// Check if it's inside a conditional or has error handling
|
||||
issues.push_back("Line " + std::to_string(line_num) + ": '" + cmd + "' without error handling");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!issues.empty()) {
|
||||
result.passed = false;
|
||||
result.message = "Missing error handling in " + script_path.filename().string();
|
||||
for (const auto& issue : issues) {
|
||||
result.details += " " + issue + "\n";
|
||||
}
|
||||
result.details += "Consider adding '|| _die \"error message\"' after commands that can fail";
|
||||
} else {
|
||||
result.message = "Error handling OK: " + script_path.filename().string();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for unquoted variables in critical places
|
||||
ValidationResult check_variable_quoting(const std::filesystem::path& script_path) {
|
||||
ValidationResult result;
|
||||
result.passed = true;
|
||||
|
||||
std::ifstream file(script_path);
|
||||
if (!file.is_open()) {
|
||||
result.message = "Could not open " + script_path.filename().string();
|
||||
result.passed = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> issues;
|
||||
std::string line;
|
||||
int line_num = 0;
|
||||
|
||||
// Pattern for unquoted variables in risky contexts (paths, arguments)
|
||||
std::regex unquoted_path_var(R"((cd|mkdir|rm|cp|mv|cat|ls)\s+\$[A-Za-z_][A-Za-z0-9_]*[^\"'\s])");
|
||||
std::regex unquoted_in_test(R"(\[\s+(-[a-z]\s+)?\$[A-Za-z_][A-Za-z0-9_]*\s+)");
|
||||
|
||||
while (std::getline(file, line)) {
|
||||
line_num++;
|
||||
|
||||
// Skip comments
|
||||
std::string trimmed = trim(line);
|
||||
if (trimmed.empty() || trimmed[0] == '#') continue;
|
||||
|
||||
// Check for unquoted variables in file operations
|
||||
if (std::regex_search(line, unquoted_path_var)) {
|
||||
issues.push_back("Line " + std::to_string(line_num) + ": Potentially unquoted variable in path operation");
|
||||
}
|
||||
|
||||
// Check for unquoted variables in test expressions
|
||||
if (std::regex_search(line, unquoted_in_test)) {
|
||||
issues.push_back("Line " + std::to_string(line_num) + ": Potentially unquoted variable in test expression");
|
||||
}
|
||||
}
|
||||
|
||||
if (!issues.empty()) {
|
||||
result.passed = false;
|
||||
result.message = "Potential quoting issues in " + script_path.filename().string();
|
||||
for (const auto& issue : issues) {
|
||||
result.details += " " + issue + "\n";
|
||||
}
|
||||
result.details += "Use \"$VAR\" instead of $VAR to handle paths with spaces";
|
||||
} else {
|
||||
result.message = "Variable quoting OK: " + script_path.filename().string();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Main validation handler
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
int validate_handler(const CommandContext& ctx) {
|
||||
std::string template_name = safearg(ctx.args, 0);
|
||||
|
||||
if (template_name.empty()) {
|
||||
error << "Template name is required" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get template info
|
||||
template_info tinfo = gTemplateManager().get_template_info(template_name);
|
||||
if (!tinfo.is_set()) {
|
||||
error << "Template not found: " << template_name << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::filesystem::path template_path = tinfo.local_template_path();
|
||||
|
||||
maketitle("Validating template: " + template_name);
|
||||
info << "Path: " << template_path.string() << std::endl;
|
||||
std::cout << std::endl;
|
||||
|
||||
int errors = 0;
|
||||
int warnings = 0;
|
||||
|
||||
// Step 1: Run existing template validation
|
||||
info << "=== Structure Validation ===" << std::endl;
|
||||
if (template_manager::test_template(template_path.string())) {
|
||||
info << " ✓ Template structure is valid" << std::endl;
|
||||
} else {
|
||||
error << " ✗ Template structure validation failed" << std::endl;
|
||||
errors++;
|
||||
}
|
||||
std::cout << std::endl;
|
||||
|
||||
// Step 2: Find all .sh files
|
||||
std::vector<std::filesystem::path> shell_scripts;
|
||||
for (const auto& entry : std::filesystem::recursive_directory_iterator(template_path)) {
|
||||
if (entry.is_regular_file() && entry.path().extension() == ".sh") {
|
||||
shell_scripts.push_back(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Syntax validation
|
||||
info << "=== Syntax Validation (bash -n) ===" << std::endl;
|
||||
for (const auto& script : shell_scripts) {
|
||||
ValidationResult result = check_bash_syntax(script);
|
||||
if (result.passed) {
|
||||
info << " ✓ " << result.message << std::endl;
|
||||
} else {
|
||||
error << " ✗ " << result.message << std::endl;
|
||||
if (!result.details.empty()) {
|
||||
error << " " << result.details << std::endl;
|
||||
}
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
std::cout << std::endl;
|
||||
|
||||
// Step 4: Linting - check common.sh sourcing (only for main scripts)
|
||||
info << "=== Linting: common.sh sourcing ===" << std::endl;
|
||||
std::vector<std::string> main_scripts = {"install.sh", "uninstall.sh", "start.sh", "stop.sh", "status.sh", "backup.sh", "restore.sh"};
|
||||
for (const auto& script_name : main_scripts) {
|
||||
std::filesystem::path script_path = template_path / script_name;
|
||||
if (std::filesystem::exists(script_path)) {
|
||||
ValidationResult result = check_common_sh_sourced(script_path);
|
||||
if (result.passed) {
|
||||
info << " ✓ " << result.message << std::endl;
|
||||
} else {
|
||||
warning << " ⚠ " << result.message << std::endl;
|
||||
if (!result.details.empty()) {
|
||||
warning << " " << result.details << std::endl;
|
||||
}
|
||||
warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::cout << std::endl;
|
||||
|
||||
// Step 5: Linting - error handling
|
||||
info << "=== Linting: Error Handling ===" << std::endl;
|
||||
for (const auto& script_name : main_scripts) {
|
||||
std::filesystem::path script_path = template_path / script_name;
|
||||
if (std::filesystem::exists(script_path)) {
|
||||
ValidationResult result = check_error_handling(script_path);
|
||||
if (result.passed) {
|
||||
info << " ✓ " << result.message << std::endl;
|
||||
} else {
|
||||
warning << " ⚠ " << result.message << std::endl;
|
||||
if (!result.details.empty()) {
|
||||
// Print each line of details
|
||||
std::istringstream iss(result.details);
|
||||
std::string detail_line;
|
||||
while (std::getline(iss, detail_line)) {
|
||||
warning << " " << detail_line << std::endl;
|
||||
}
|
||||
}
|
||||
warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::cout << std::endl;
|
||||
|
||||
// Step 6: Linting - variable quoting
|
||||
info << "=== Linting: Variable Quoting ===" << std::endl;
|
||||
for (const auto& script_name : main_scripts) {
|
||||
std::filesystem::path script_path = template_path / script_name;
|
||||
if (std::filesystem::exists(script_path)) {
|
||||
ValidationResult result = check_variable_quoting(script_path);
|
||||
if (result.passed) {
|
||||
info << " ✓ " << result.message << std::endl;
|
||||
} else {
|
||||
warning << " ⚠ " << result.message << std::endl;
|
||||
if (!result.details.empty()) {
|
||||
std::istringstream iss(result.details);
|
||||
std::string detail_line;
|
||||
while (std::getline(iss, detail_line)) {
|
||||
warning << " " << detail_line << std::endl;
|
||||
}
|
||||
}
|
||||
warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::cout << std::endl;
|
||||
|
||||
// Summary
|
||||
maketitle("Validation Summary");
|
||||
if (errors == 0 && warnings == 0) {
|
||||
info << "✓ Template '" << template_name << "' passed all checks!" << std::endl;
|
||||
return 0;
|
||||
} else if (errors == 0) {
|
||||
warning << "Template '" << template_name << "' has " << warnings << " warning(s)" << std::endl;
|
||||
return 0; // Warnings don't fail validation
|
||||
} else {
|
||||
error << "Template '" << template_name << "' has " << errors << " error(s) and " << warnings << " warning(s)" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace dropshell
|
||||
Reference in New Issue
Block a user