From bcfad0ccde8b043b8d30c3f63246496b30a2ec81 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Thu, 13 Aug 2026 22:01:41 +0200 Subject: [PATCH] fix: told a C++ error in the project apart from a failed regeneration A regeneration the engine's build tool refused was reported the same way as one GitGame could not run at all, so a half-written header read as GitGame breaking rather than as the project's own C++ not parsing yet. Parsed the complaints the build tool prints against individual files out of a failed run, and used their presence to split the two: a failure carrying them names the file and line it came from, in the chip, the dialog title and the details, while one without them keeps reporting the step, the exit code and the raw output as before. Retried after a failure on any change rather than only on the file set moving. A new header is almost always incomplete when the watcher first sees it appear, and what finishes it is an edit, which the watcher deliberately ignores, so a first failure would otherwise sit there until some unrelated file came or went. Dropped dedent from the details block, whose interpolated multi-line output left every literal line indented by the template's own margin while the injected lines sat flush left. --- src/main/config/compileDb.ts | 13 ++++ src/main/lib/compileDb/run.ts | 41 +++++++++- src/main/lib/compileDb/service.ts | 16 +++- src/main/types/compileDb.d.ts | 16 +++- .../components/fields/StatusCompileDb.tsx | 11 ++- src/renderer/lib/utils/compileDb.ts | 76 +++++++++++++++---- tests/lib/utils/compileDb.ts | 19 +++++ tests/unit/compileDb.test.ts | 54 ++++++++++++- 8 files changed, 225 insertions(+), 21 deletions(-) diff --git a/src/main/config/compileDb.ts b/src/main/config/compileDb.ts index ae872ea..7745691 100644 --- a/src/main/config/compileDb.ts +++ b/src/main/config/compileDb.ts @@ -28,6 +28,19 @@ const COMPILE_DB_CONFIG = { */ maxOutputChars: 8000, + /** + * How many source diagnostics are kept out of a failed run, one broken header + * can cascade into a wall of them and the first few are what get fixed. + */ + maxDiagnostics: 10, + + /** + * Matches how the build tool and the header tool report a complaint against a + * file, `path(line): error: message` with an optional column and an optional + * compiler error code, in either capitalization. + */ + diagnosticPattern: /^(.*?)\((\d+)(?:,\s*\d+)?\)\s*:\s*(?:fatal\s+)?error\s*(?:[A-Z]+\d+)?\s*:\s*(.+)$/gim, + /** * The engine-relative path of the build entry point, keyed by * `process.platform`. diff --git a/src/main/lib/compileDb/run.ts b/src/main/lib/compileDb/run.ts index 9874aa2..83ba09d 100644 --- a/src/main/lib/compileDb/run.ts +++ b/src/main/lib/compileDb/run.ts @@ -1,8 +1,15 @@ import { execFile } from "node:child_process" +import path from "node:path" import { promisify } from "node:util" import COMPILE_DB_CONFIG from "@main/config/compileDb" +import { toPosixPath } from "@main/lib/compileDb/paths" import CONSTANTS from "@main/lib/constants" -import type { CompileDbContext, CompileDbRunKind, CompileDbRunResult } from "@/main/types/compileDb" +import type { + CompileDbContext, + CompileDbDiagnostic, + CompileDbRunKind, + CompileDbRunResult, +} from "@/main/types/compileDb" /** * Promisified version of `child_process.execFile`. @@ -190,6 +197,36 @@ function buildSteps(kind: CompileDbRunKind, context: CompileDbContext): CompileD return [buildSolutionStep(context), buildWorkspaceStep(context), buildClangDatabaseStep(context)] } +/** + * Pulls the build tool's complaints about individual source files out of a failed + * run, so a failure the project's own C++ caused reads as the diagnostic it is + * rather than as a wall of build-tool logging. + * @param output The failed step's captured output. + * @param projectRoot The absolute repository root, trimmed off each path. + * @returns The diagnostics, deduplicated and capped. + */ +export function collectDiagnostics(output: string, projectRoot: string): CompileDbDiagnostic[] { + const collected = new Map() + + for (const match of output.matchAll(COMPILE_DB_CONFIG.diagnosticPattern)) { + const [, rawFile, rawLine, rawMessage] = match + + // A path the build tool reports from outside the project (the engine's own + // headers) has no shorter form worth showing, so it is kept whole + const relativeFile = path.relative(projectRoot, rawFile.trim()) + const file = !relativeFile || relativeFile.startsWith("..") ? rawFile.trim() : relativeFile + + // The same complaint is routinely logged more than once in a single run + collected.set(`${file}:${rawLine}:${rawMessage}`, { + file: toPosixPath(file), + line: Number(rawLine), + message: rawMessage.trim(), + }) + } + + return [...collected.values()].slice(0, COMPILE_DB_CONFIG.maxDiagnostics) +} + /** * Runs a regeneration end to end, stopping at the first invocation that exits * non-zero so a broken solution step cannot be followed by a database written @@ -220,6 +257,7 @@ export async function runRegeneration( ok: false, exitCode, failedStep: step.label, + diagnostics: collectDiagnostics(output, context.projectRoot), output: trimTail(outputs.join("\n\n")), durationMs: Date.now() - startedAt, } @@ -231,6 +269,7 @@ export async function runRegeneration( ok: true, exitCode: 0, failedStep: null, + diagnostics: [], output: trimTail(outputs.join("\n\n")), durationMs: Date.now() - startedAt, } diff --git a/src/main/lib/compileDb/service.ts b/src/main/lib/compileDb/service.ts index a06c4cc..ef6c22a 100644 --- a/src/main/lib/compileDb/service.ts +++ b/src/main/lib/compileDb/service.ts @@ -96,6 +96,16 @@ export class CompileDbService { } } + /** + * Whether the last run left the database out of date, either because the build + * tool refused the project's sources or because it could not be run at all. + * @returns True when the last run did not land a database. + */ + private _hasFailed(): boolean { + const { lastResult, lastError } = compileDbStore.get() + return Boolean(lastError) || lastResult?.ok === false + } + /** * Resolves everything a run needs and caches it, the engine folder comes from * the preference override or from wherever the platform records its installs, @@ -198,7 +208,11 @@ export class CompileDbService { this._knownFiles = next compileDbStore.set({ trackedFileCount: next.size }) - if (!hasChanged) return + // A failed run leaves the database out of date, and what fixes the usual + // cause (a header still half-written when it first appeared) is an edit + // rather than another file appearing, so a failure is worth retrying on any + // change at all rather than waiting for the set to move again + if (!hasChanged && !this._hasFailed()) return // A build or plugin descriptor can move the solution too, which is left to // the user rather than rewritten under an open IDE diff --git a/src/main/types/compileDb.d.ts b/src/main/types/compileDb.d.ts index 6d2e2d7..a57a19a 100644 --- a/src/main/types/compileDb.d.ts +++ b/src/main/types/compileDb.d.ts @@ -24,16 +24,28 @@ export type CompileDbContext = { targetPlatform: string } +/** + * One complaint the build tool made about a source file, parsed out of its output + * so a failure caused by the project's own C++ can be told apart from one caused + * by GitGame or by the engine install, and reported as the diagnostic it is. + */ +export type CompileDbDiagnostic = { + file: string + line: number + message: string +} + /** * The outcome of a single regeneration, `output` is the tail of the combined - * streams so a failure can be read without opening a terminal, and `failedStep` - * names which of the steps in a `full` run gave up. + * streams, `failedStep` names which step of a `full` run gave up, and a non-empty + * `diagnostics` marks the failure as the project's own rather than ours. */ export type CompileDbRunResult = { kind: CompileDbRunKind ok: boolean exitCode: number failedStep: string | null + diagnostics: CompileDbDiagnostic[] output: string durationMs: number } diff --git a/src/renderer/components/fields/StatusCompileDb.tsx b/src/renderer/components/fields/StatusCompileDb.tsx index af27c9b..3a0b3b3 100644 --- a/src/renderer/components/fields/StatusCompileDb.tsx +++ b/src/renderer/components/fields/StatusCompileDb.tsx @@ -1,6 +1,11 @@ import batExecIcon from "@react95-icons/BatExec_16x16_4.png" import useCompileDbState from "@renderer/hooks/useCompileDbState" -import { buildCompileDbFailureDetails, buildCompileDbLabel } from "@renderer/lib/utils/compileDb" +import { + buildCompileDbFailureDetails, + buildCompileDbFailureMessage, + buildCompileDbFailureTitle, + buildCompileDbLabel, +} from "@renderer/lib/utils/compileDb" import { useCallback, useEffect, useMemo, useState } from "react" import StatusBarFrame, { type StatusBarLabelState } from "@/renderer/components/frames/StatusBar" import TileProgressBar from "@/renderer/components/ui/TileProgressBar" @@ -55,8 +60,8 @@ export default function StatusCompileDbField({ className }: StatusCompileDbField if (hasFailed) { window.api.dialogs.errorWithDetails( - "Compile database: the last regeneration failed", - state.lastError ?? "The engine's build tool exited with an error.", + buildCompileDbFailureTitle(state), + buildCompileDbFailureMessage(state), buildCompileDbFailureDetails(state), ) diff --git a/src/renderer/lib/utils/compileDb.ts b/src/renderer/lib/utils/compileDb.ts index 4ff6402..daf6f8d 100644 --- a/src/renderer/lib/utils/compileDb.ts +++ b/src/renderer/lib/utils/compileDb.ts @@ -1,5 +1,15 @@ -import dedent from "dedent" -import type { CompileDbState } from "@/main/types/compileDb" +import type { CompileDbDiagnostic, CompileDbState } from "@/main/types/compileDb" + +/** + * Whether the last failure was the project's own C++ being refused rather than + * GitGame or the engine install failing to run at all, which are worth telling + * apart because only one of them is ours to answer for. + * @param state The current compile-database state. + * @returns True when the failure carries source diagnostics. + */ +export function hasSourceDiagnostics(state: CompileDbState): boolean { + return Boolean(state.lastResult && !state.lastResult.ok && state.lastResult.diagnostics.length > 0) +} /** * Builds the short label shown in the status bar chip, names the step while a run @@ -12,7 +22,10 @@ export function buildCompileDbLabel(state: CompileDbState): string { if (state.isRunning) return state.runningStep ?? "Running" if (state.lastError) return "Error" - if (state.lastResult && !state.lastResult.ok) return "Failed" + if (state.lastResult && !state.lastResult.ok) { + return hasSourceDiagnostics(state) ? "Code error" : "Failed" + } + if (state.isFullRegenSuggested) return "Full regen" if (state.lastResult) return `${(state.lastResult.durationMs / 1000).toFixed(1)}s` @@ -20,20 +33,57 @@ export function buildCompileDbLabel(state: CompileDbState): string { } /** - * Builds the details block shown when the chip is clicked on a failure, pairs the - * step that gave up with the tail of the build tool's own output. + * Builds the title of the dialog a failure opens, naming which side of the line + * the failure fell on so the project's own C++ is never reported as ours. + * @param state The current compile-database state. + * @returns The dialog title. + */ +export function buildCompileDbFailureTitle(state: CompileDbState): string { + if (hasSourceDiagnostics(state)) return "Compile database: C++ errors found" + return "Compile database: regeneration failed" +} + +/** + * Builds the message line of the dialog a failure opens. + * @param state The current compile-database state. + * @returns The dialog message. + */ +export function buildCompileDbFailureMessage(state: CompileDbState): string { + if (hasSourceDiagnostics(state)) { + const { diagnostics } = state.lastResult ?? { diagnostics: [] } + + return `The engine's build tool ran but would not accept the project's sources, it reported ${diagnostics.length} problem${diagnostics.length === 1 ? "" : "s"}. The database is left as it was, and GitGame retries as soon as the sources change again.` + } + + return state.lastError ?? "The engine's build tool exited with an error before writing the database." +} + +/** + * Formats the build tool's complaints one per block, path and line first so the + * file can be opened straight from the dialog. + * @param diagnostics The diagnostics from the failed run. + * @returns The formatted block. + */ +function formatDiagnostics(diagnostics: CompileDbDiagnostic[]): string { + return diagnostics.map(diagnostic => `${diagnostic.file}(${diagnostic.line})\n ${diagnostic.message}`).join("\n\n") +} + +/** + * Builds the details block shown when the chip is clicked on a failure, the source + * diagnostics on their own when the project's C++ was the problem, and the tail of + * the build tool's own output when it was not. * @param state The current compile-database state. * @returns The formatted details block. */ export function buildCompileDbFailureDetails(state: CompileDbState): string { - if (state.lastError) return state.lastError - - if (!state.lastResult) return "No further details available." + if (!state.lastResult) return state.lastError ?? "No further details available." - return dedent` - Step: ${state.lastResult.failedStep ?? "unknown"} - Exit code: ${state.lastResult.exitCode} + if (hasSourceDiagnostics(state)) return formatDiagnostics(state.lastResult.diagnostics) - ${state.lastResult.output || "The build tool produced no output."} - ` + return [ + `Step: ${state.lastResult.failedStep ?? "unknown"}`, + `Exit code: ${state.lastResult.exitCode}`, + "", + state.lastResult.output || "The build tool produced no output.", + ].join("\n") } diff --git a/tests/lib/utils/compileDb.ts b/tests/lib/utils/compileDb.ts index 0a3145d..fdc53b3 100644 --- a/tests/lib/utils/compileDb.ts +++ b/tests/lib/utils/compileDb.ts @@ -1,3 +1,4 @@ +import dedent from "dedent" import type { CompileDbFileKind } from "@/main/types/compileDb" /** @@ -9,3 +10,21 @@ import type { CompileDbFileKind } from "@/main/types/compileDb" export function buildScan(entries: [string, CompileDbFileKind][]): Map { return new Map(entries) } + +/** + * The tail of a real failed run, the header tool refusing a half-written header, + * kept verbatim so the parser is held to what the build tool really prints. + */ +export const HEADER_TOOL_FAILURE_OUTPUT = dedent` + Using bundled DotNet SDK version: 10.0 win-x64 + Running UnrealBuildTool: dotnet "..\\..\\Engine\\Binaries\\DotNET\\UnrealBuildTool\\UnrealBuildTool.dll" -mode=GenerateClangDatabase + Creating target... + Clang compiler version 22.1.3 is newer than latest preferred version 20.1.8. Please use caution as this compiler has not been heavily tested + Log file: C:\\Users\\someone\\AppData\\Local\\UnrealHeaderTool\\Saved\\Logs\\UnrealHeaderTool.log + UHT compiled-in object format Default + F:\\Projects\\Cybearl\\project-windy\\Plugins\\WindyToolkit\\Source\\WindyToolkit\\Public\\Wind\\WindyWindFieldTypes.h(13): Error: Found EOF when expecting public while parsing public access modifier in class 'UWindyWindFieldTypes' + Unhandled 1 aggregate exceptions + + Result: Failed (OtherCompilationError) + Total execution time: 1.79 seconds +` diff --git a/tests/unit/compileDb.test.ts b/tests/unit/compileDb.test.ts index 82c7fb1..fdce3df 100644 --- a/tests/unit/compileDb.test.ts +++ b/tests/unit/compileDb.test.ts @@ -1,6 +1,7 @@ import { classifySourcePath } from "@main/lib/compileDb/paths" +import { collectDiagnostics } from "@main/lib/compileDb/run" import { diffSourceFiles } from "@main/lib/compileDb/scan" -import { buildScan } from "@tests/lib/utils/compileDb" +import { buildScan, HEADER_TOOL_FAILURE_OUTPUT } from "@tests/lib/utils/compileDb" import { describe, expect, it } from "vitest" describe("classifySourcePath", () => { @@ -59,6 +60,57 @@ describe("classifySourcePath", () => { }) }) +describe("collectDiagnostics", () => { + const projectRoot = "F:\\Projects\\Cybearl\\project-windy" + + it("pulls the header tool's complaint out of a real failed run", () => { + expect(collectDiagnostics(HEADER_TOOL_FAILURE_OUTPUT, projectRoot)).toEqual([ + { + file: "Plugins/WindyToolkit/Source/WindyToolkit/Public/Wind/WindyWindFieldTypes.h", + line: 13, + message: + "Found EOF when expecting public while parsing public access modifier in class 'UWindyWindFieldTypes'", + }, + ]) + }) + + it("reads the compiler's own formats, with a column and with an error code", () => { + const output = [ + "F:\\Projects\\Cybearl\\project-windy\\Source\\Thing.cpp(42,9): error: use of undeclared identifier 'foo'", + "F:\\Projects\\Cybearl\\project-windy\\Source\\Other.cpp(7): fatal error C1083: Cannot open include file", + ].join("\n") + + expect(collectDiagnostics(output, projectRoot)).toEqual([ + { file: "Source/Thing.cpp", line: 42, message: "use of undeclared identifier 'foo'" }, + { file: "Source/Other.cpp", line: 7, message: "Cannot open include file" }, + ]) + }) + + it("keeps a path from outside the project whole, there being no shorter form", () => { + const output = "C:\\UE_5.8\\Engine\\Source\\Runtime\\Core\\Public\\Thing.h(9): Error: broken" + + expect(collectDiagnostics(output, projectRoot)).toEqual([ + { file: "C:/UE_5.8/Engine/Source/Runtime/Core/Public/Thing.h", line: 9, message: "broken" }, + ]) + }) + + it("collapses a complaint the build tool logged more than once", () => { + const line = "F:\\Projects\\Cybearl\\project-windy\\Source\\Thing.cpp(42): error: broken" + + expect(collectDiagnostics([line, line, line].join("\n"), projectRoot)).toHaveLength(1) + }) + + it("finds nothing in output that only carries warnings or a plain summary", () => { + const output = [ + "F:\\Projects\\Cybearl\\project-windy\\Source\\Thing.cpp(42): Warning: shadowed variable", + "Result: Succeeded", + "Total execution time: 1.41 seconds", + ].join("\n") + + expect(collectDiagnostics(output, projectRoot)).toEqual([]) + }) +}) + describe("diffSourceFiles", () => { const baseline = buildScan([ ["Source/ProjectWindy/ProjectWindy.cpp", "source"],