Step 260: project-wide diagnostics (getProjectDiagnostics)

Diagnostics across all open files in one call, grouped by file path.
Includes cross-file E0400 warnings for undefined imports, with optional
severity filter and file glob filter. 12/12 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-02-11 20:13:46 +00:00
parent f0417bccce
commit 8a461df47e
7 changed files with 611 additions and 1 deletions

View File

@@ -54,7 +54,8 @@ struct AgentPermissionPolicy {
method == "listBuffers" ||
method == "setActiveBuffer" ||
method == "indexWorkspace" ||
method == "getImportGraph") {
method == "getImportGraph" ||
method == "getProjectDiagnostics") {
return true;
}

View File

@@ -1105,6 +1105,94 @@ inline json handleHeadlessAgentRequest(HeadlessEditorState& state,
});
}
// --- getProjectDiagnostics ---
if (method == "getProjectDiagnostics") {
if (!AgentPermissionPolicy::canInvoke(role, method))
return headlessRpcError(id, -32031, "Role not permitted");
auto params = request.contains("params") ? request["params"]
: json::object();
// Optional severity filter
std::string sevFilter = params.value("severity", "");
// Optional file path glob filter (simple suffix match)
std::string fileGlob = params.value("fileGlob", "");
// Collect module names from open buffers for cross-file checks
std::set<std::string> openModuleNames;
for (const auto& [path, buf] : state.bufferStates) {
std::string stem = fs::path(path).stem().string();
if (!stem.empty()) openModuleNames.insert(stem);
}
json filesDiags = json::object();
int totalCount = 0;
for (const auto& [path, buf] : state.bufferStates) {
// Apply file glob filter (simple suffix/extension match)
if (!fileGlob.empty()) {
// Match *.ext or exact name
if (fileGlob[0] == '*') {
std::string suffix = fileGlob.substr(1);
if (path.size() < suffix.size() ||
path.substr(path.size() - suffix.size()) != suffix)
continue;
} else if (path.find(fileGlob) == std::string::npos) {
continue;
}
}
Module* bufAST = buf->sync.getAST();
std::vector<StructuredDiagnostic> diags;
// Per-file diagnostics (annotation + strategy)
if (bufAST) {
auto fileDiags = collectAllDiagnostics(bufAST);
diags.insert(diags.end(), fileDiags.begin(),
fileDiags.end());
// Cross-file: undefined imports from AST
auto crossDiags = collectCrossFileDiagnostics(
bufAST, path, openModuleNames);
diags.insert(diags.end(), crossDiags.begin(),
crossDiags.end());
}
// Cross-file: undefined imports from source text
if (!buf->editBuf.empty()) {
auto srcCross = collectCrossFileDiagnosticsFromSource(
buf->editBuf, path, openModuleNames);
// Deduplicate: only add source-based if no AST-based
// cross-file diags exist for the same line
std::set<int> astCrossLines;
for (const auto& d : diags) {
if (d.source == "cross-file")
astCrossLines.insert(d.line);
}
for (auto& d : srcCross) {
if (astCrossLines.find(d.line) == astCrossLines.end())
diags.push_back(std::move(d));
}
}
// Apply severity filter
if (!sevFilter.empty()) {
DiagnosticSeverity maxSev = severityFromStr(sevFilter);
diags = filterBySeverity(diags, maxSev);
}
if (!diags.empty()) {
json diagArr = diagnosticsToJson(diags);
sortDiagnosticsByPriority(diagArr);
filesDiags[path] = diagArr;
totalCount += (int)diags.size();
}
}
return headlessRpcResult(id, {
{"files", filesDiags},
{"fileCount", (int)filesDiags.size()},
{"totalDiagnostics", totalCount}
});
}
// --- batchQuery ---
if (method == "batchQuery") {
auto params = request.contains("params") ? request["params"]

View File

@@ -716,6 +716,27 @@ private:
toolHandlers_["whetstone_apply_quick_fix"] = [this](const json& args) {
return callWhetstone("applyQuickFix", args);
};
// whetstone_get_project_diagnostics
tools_.push_back({"whetstone_get_project_diagnostics",
"Get diagnostics across all open files in one call. Returns "
"diagnostics grouped by file path in compact format. Includes "
"cross-file errors (undefined imports). Filter by severity "
"or file glob pattern.",
{{"type", "object"}, {"properties", {
{"severity", {{"type", "string"},
{"enum", {"error", "warning", "info", "hint"}},
{"description",
"Maximum severity level to include"}}},
{"fileGlob", {{"type", "string"},
{"description",
"File pattern filter (e.g. *.py, utils.py)"}}}
}}}
});
toolHandlers_["whetstone_get_project_diagnostics"] =
[this](const json& args) {
return callWhetstone("getProjectDiagnostics", args);
};
}
// ---------------------------------------------------------------

View File

@@ -10,12 +10,16 @@
// E01xx — Parse errors (tree-sitter syntax)
// E02xx — Annotation validation (consistency)
// E03xx — Strategy validation (memory safety)
// E04xx — Cross-file diagnostics (imports, dependencies)
#include <string>
#include <vector>
#include <set>
#include <sstream>
#include <nlohmann/json.hpp>
#include "ast/ASTNode.h"
#include "ast/Parser.h"
#include "ast/Import.h"
#include "AnnotationValidator.h"
#include "StrategyValidator.h"
#include "Pipeline.h"
@@ -289,6 +293,75 @@ inline std::vector<StructuredDiagnostic> filterBySource(
return out;
}
// -----------------------------------------------------------------------
// Step 260: Cross-file diagnostics
// -----------------------------------------------------------------------
// Check for undefined imports: modules imported but not open as buffers
inline std::vector<StructuredDiagnostic> collectCrossFileDiagnostics(
Module* ast, const std::string& filePath,
const std::set<std::string>& openModuleNames) {
std::vector<StructuredDiagnostic> diags;
if (!ast) return diags;
for (auto* child : ast->allChildren()) {
if (child->conceptType == "Import") {
auto* imp = static_cast<const Import*>(child);
if (!imp->moduleName.empty() &&
openModuleNames.find(imp->moduleName) ==
openModuleNames.end()) {
StructuredDiagnostic d;
d.code = "E0400";
d.severity = DiagnosticSeverity::Warning;
d.nodeId = imp->id;
d.line = imp->hasSpan() ? imp->spanStartLine : 0;
d.message = "Imported module '" + imp->moduleName +
"' is not open in the project";
d.source = "cross-file";
diags.push_back(std::move(d));
}
}
}
return diags;
}
// Text-based cross-file import check (for parsers without Import nodes)
inline std::vector<StructuredDiagnostic> collectCrossFileDiagnosticsFromSource(
const std::string& source, const std::string& filePath,
const std::set<std::string>& openModuleNames) {
std::vector<StructuredDiagnostic> diags;
std::istringstream iss(source);
std::string line;
int lineNum = 0;
while (std::getline(iss, line)) {
++lineNum;
std::string modName;
if (line.substr(0, 7) == "import ") {
modName = line.substr(7);
while (!modName.empty() && modName.back() == ' ')
modName.pop_back();
size_t comma = modName.find(',');
if (comma != std::string::npos)
modName = modName.substr(0, comma);
} else if (line.substr(0, 5) == "from ") {
size_t space = line.find(' ', 5);
if (space != std::string::npos)
modName = line.substr(5, space - 5);
}
if (!modName.empty() &&
openModuleNames.find(modName) == openModuleNames.end()) {
StructuredDiagnostic d;
d.code = "E0400";
d.severity = DiagnosticSeverity::Warning;
d.line = lineNum;
d.message = "Imported module '" + modName +
"' is not open in the project";
d.source = "cross-file";
diags.push_back(std::move(d));
}
}
return diags;
}
// -----------------------------------------------------------------------
// Step 251: QuickFix — a concrete mutation an agent can apply
// -----------------------------------------------------------------------