diff --git a/src/main/kotlin/file/SetupApp.kt b/src/main/kotlin/file/SetupApp.kt index d99cae8..7940c46 100644 --- a/src/main/kotlin/file/SetupApp.kt +++ b/src/main/kotlin/file/SetupApp.kt @@ -32,7 +32,7 @@ object SetupApp { private data class WurstProcessResult(val exitCode: Int, val output: List) - internal const val AGENTS_TEMPLATE_VERSION = "2026-06-10" + internal const val AGENTS_TEMPLATE_VERSION = "2026-06-22" private const val AGENTS_TEMPLATE_MARKER_PREFIX = "" private const val AGENTS_TEMPLATE_SOURCE_HINT = "WurstScript Warcraft III map project notes" @@ -90,12 +90,16 @@ object SetupApp { } private fun fail(message: String) { - log.error(message) + if (setup.quiet) { + System.err.println(message) + } else { + log.error(message) + } } private fun detail(message: String) { if (setup.quiet) { - println(message) + System.err.println(message) } else { log.info(message) } @@ -366,13 +370,15 @@ object SetupApp { if (printPjassFailure(result.output)) { return } - fail("❌ Wurst $commandName failed.") - detail("Exit code: ${result.exitCode}") if (setup.quiet) { - detail("Next: rerun without `--quiet` only for the failed file/test.") - } else { - detail("Try: rerun with `--quiet` for a shorter error log, or `--debug` for troubleshooting details.") + val diagnostics = quietCompilerFailureOutput(result.output, setup.debug) + diagnostics.forEach { System.err.println(it) } + fail("❌ Wurst $commandName failed. (Errors: ${quietCompilerErrorCount(result.output, diagnostics)})") + return } + fail("❌ Wurst $commandName failed.") + detail("Exit code: ${result.exitCode}") + detail("Try: rerun with `--quiet` for a shorter error log, or `--debug` for troubleshooting details.") } private fun printPjassFailure(output: List): Boolean { @@ -402,14 +408,99 @@ object SetupApp { return true } - private fun isImportantCompilerLine(line: String): Boolean { - return line.contains("error", ignoreCase = true) || - line.contains("warning", ignoreCase = true) || - line.contains("FAILED", ignoreCase = true) || + internal fun quietCompilerFailureOutput(output: List, debug: Boolean): List { + return if (debug) output else quietCompilerDiagnostics(output) + } + + internal fun quietCompilerDiagnostics(output: List): List { + val diagnostics = ArrayList() + var pendingVerboseError: MatchResult? = null + var preservingTestFailureDetails = false + + for (rawLine in output) { + val line = rawLine.trimEnd() + if (line.isBlank() || isNoisyCompilerVersionLine(line) || isQuietCompilerNoiseLine(line)) { + continue + } + + val verboseError = Regex("""^Error in File (.+):(\d+):\s*$""").find(line.trim()) + if (verboseError != null) { + pendingVerboseError = verboseError + continue + } + + if (pendingVerboseError != null) { + diagnostics.add( + "Error ${pendingVerboseError.groupValues[1]}:${pendingVerboseError.groupValues[2]}: ${line.trim()}" + ) + pendingVerboseError = null + continue + } + + if (preservingTestFailureDetails && isQuietTestFailureDetailLine(line)) { + diagnostics.add(line) + continue + } + + if (isQuietCompilerDiagnosticLine(line)) { + diagnostics.add(line) + if (isQuietTestFailureHeader(line)) { + preservingTestFailureDetails = true + } + } + } + + return diagnostics.distinct() + } + + internal fun quietCompilerErrorCount( + output: List, + diagnostics: List = quietCompilerDiagnostics(output) + ): Int { + output.asSequence() + .map { Regex("""^Errors:\s*(\d+)\s*$""").find(it.trim()) } + .filterNotNull() + .firstOrNull() + ?.let { return it.groupValues[1].toIntOrNull() ?: diagnostics.size.coerceAtLeast(1) } + + return diagnostics.count { + it.startsWith("Error ", ignoreCase = true) || + it.startsWith("FAILED ", ignoreCase = true) || + it.contains(" exception", ignoreCase = true) || + it.contains("Pjass", ignoreCase = true) + }.coerceAtLeast(1) + } + + private fun isQuietCompilerDiagnosticLine(line: String): Boolean { + return line.startsWith("Error ", ignoreCase = true) || + line.startsWith("FAILED ", ignoreCase = true) || + line.contains(" assertion", ignoreCase = true) || line.contains("Exception", ignoreCase = true) || line.contains("Pjass", ignoreCase = true) } + private fun isQuietTestFailureHeader(line: String): Boolean { + return line.trim().equals("FAILED assertion:", ignoreCase = true) + } + + private fun isQuietTestFailureDetailLine(line: String): Boolean { + val trimmed = line.trim() + return trimmed.startsWith("Test failed:", ignoreCase = true) || + trimmed.contains(" inside call ", ignoreCase = true) || + trimmed.contains(" when calling ", ignoreCase = true) + } + + private fun isQuietCompilerNoiseLine(line: String): Boolean { + val trimmed = line.trim() + return trimmed.startsWith("Warning", ignoreCase = true) || + trimmed.matches(Regex("""^Errors:\s*\d+\s*$""")) || + trimmed.matches(Regex("""^Warnings:\s*\d+\s*$""")) || + trimmed.matches(Regex("""^Tests:\s*\d+/\d+\s+passed\s*$""", RegexOption.IGNORE_CASE)) || + trimmed.startsWith("compilation finished", ignoreCase = true) || + trimmed.startsWith("Running tests", ignoreCase = true) || + trimmed.startsWith("Finished running tests", ignoreCase = true) + } + private fun isNoisyCompilerVersionLine(line: String): Boolean { val trimmed = line.trim() return trimmed == "Warning: Ignoring unknown wc3Patch in wurst.build: ${CoreJassProvider.DEFAULT_PATCH}" || @@ -960,11 +1051,6 @@ object SetupApp { } } val exitCode = p.waitFor() - if (setup.quiet && exitCode != 0) { - val printableOutput = if (setup.debug) output else output.filterNot(::isNoisyCompilerVersionLine) - val linesToPrint = if (compactFallback) printableOutput.filter(::isImportantCompilerLine) else printableOutput - linesToPrint.forEach { println(it) } - } return WurstProcessResult(exitCode, output) } diff --git a/src/test/kotlin/GenerateTests.kt b/src/test/kotlin/GenerateTests.kt index d900a77..ec34844 100644 --- a/src/test/kotlin/GenerateTests.kt +++ b/src/test/kotlin/GenerateTests.kt @@ -8,6 +8,7 @@ import file.SetupMain import org.testng.Assert import org.testng.annotations.Test import java.nio.file.Files +import java.nio.file.Paths import java.util.Comparator private class ExitException2(val code: Int) : RuntimeException("exit $code") @@ -251,6 +252,12 @@ class GenerateTests { } @Test(priority = 10) fun testAgentsTemplateMarkerAndWarnings() { + val templateFirstLine = Files.readAllLines(Paths.get("templates", "AGENTS.md")).first() + Assert.assertEquals( + templateFirstLine, + "" + ) + val marked = SetupApp.withAgentsTemplateMarker("# AGENTS.md\n") Assert.assertTrue(marked.startsWith("")) Assert.assertNull(SetupApp.agentsTemplateWarning(marked)) @@ -278,6 +285,87 @@ class GenerateTests { Assert.assertTrue(setup.quiet) } + @Test(priority = 10) + fun testQuietCompilerDiagnosticsSuppressGeneratedJassNoise() { + val output = listOf( + "Warnings: 3", + "Warning: Error: e:Could not find variable silverGladeCounter.", + "Warning: Error: e:Could not find a function with name eg", + "Error Broken.wurst:12: Could not find variable realUserTypo.", + "compilation finished (errors: 1, warnings: 3)", + "Errors: 1" + ) + + Assert.assertEquals( + SetupApp.quietCompilerDiagnostics(output), + listOf("Error Broken.wurst:12: Could not find variable realUserTypo.") + ) + Assert.assertEquals(SetupApp.quietCompilerErrorCount(output), 1) + } + + @Test(priority = 10) + fun testQuietCompilerDiagnosticsKeepFailedTestDetails() { + val output = listOf( + "Running tests", + "Tests: 1/2 passed", + "FAILED MyPkg.testExplodes", + "\tFAILED assertion:", + "\tTest failed: expected 1 but got 2", + "\t ╚ MyTest.wurst:9 inside call assertEquals(1, 2)", + "\t... when calling MyPkg.testExplodes(MyTest.wurst:12)", + "Errors: 1", + "Error MyTest.wurst:9: expected 1 but got 2", + "Finished running tests" + ) + + Assert.assertEquals( + SetupApp.quietCompilerDiagnostics(output), + listOf( + "FAILED MyPkg.testExplodes", + "\tFAILED assertion:", + "\tTest failed: expected 1 but got 2", + "\t ╚ MyTest.wurst:9 inside call assertEquals(1, 2)", + "\t... when calling MyPkg.testExplodes(MyTest.wurst:12)", + "Error MyTest.wurst:9: expected 1 but got 2" + ) + ) + Assert.assertEquals(SetupApp.quietCompilerErrorCount(output), 1) + } + + @Test(priority = 10) + fun testQuietCompilerDiagnosticsNormalizeVerboseFallbackErrors() { + val output = listOf( + "Error in File Broken.wurst:12:", + " Could not find variable realUserTypo.", + "Warning in File war3map.j:44:", + " Error: e:Could not find variable silverGladeCounter." + ) + + Assert.assertEquals( + SetupApp.quietCompilerDiagnostics(output), + listOf("Error Broken.wurst:12: Could not find variable realUserTypo.") + ) + } + + @Test(priority = 10) + fun testQuietDebugCompilerFailureOutputBypassesFilter() { + val output = listOf( + "Error Broken.wurst:12: Could not find variable realUserTypo.", + "java.lang.IllegalStateException: extra debug context", + "\tat de.peeeq.wurstio.Main.main(Main.java:1)", + "Warning: Error: e:Could not find variable generatedNoise." + ) + + Assert.assertEquals(SetupApp.quietCompilerFailureOutput(output, debug = true), output) + Assert.assertEquals( + SetupApp.quietCompilerFailureOutput(output, debug = false), + listOf( + "Error Broken.wurst:12: Could not find variable realUserTypo.", + "java.lang.IllegalStateException: extra debug context" + ) + ) + } + @Test(priority = 10) fun testDevBuildFlag() { val setup = SetupMain() diff --git a/templates/AGENTS.md b/templates/AGENTS.md index eeff5d7..d3370ad 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -1,4 +1,4 @@ - + # AGENTS.md - WurstScript Map Project Notes WurstScript Warcraft III map project notes for editing `.wurst` code, dependencies, generated objects, tests, or map build logic. @@ -8,11 +8,42 @@ WurstScript Warcraft III map project notes for editing `.wurst` code, dependenci - Prefer simple, maintainable code. Fix root causes; avoid brittle workarounds, duplicated branches, and special-case patches. - Keep packages focused and below ~500 lines; split by feature, responsibility, or data type. - Make changes in the source package, not generated output. Do not edit `_build/` or `_build/dependencies/` as source-of-truth. -- Prefer Wurst standard-library wrappers and project helpers over raw `common.j`/Jass-style calls. +- Use Wurst stdlib/library APIs and project helpers; never call a raw `common.j`/Jass native when a wrapper exists, and do not reinvent what stdlib already provides. See **Stdlib-First** below. - When unsure about Wurst syntax or local APIs, inspect nearby working code before guessing. - Keep tests narrow. Add/update tests for behavior, parsing, compiletime generation, or shared utilities. - Avoid broad refactors unless they directly reduce risk or complexity for the requested change. +## Stdlib-First: No Raw JASS Natives (Mandatory) + +The most important coding rule: high-level Wurst packages must use the WurstScript stdlib and library APIs, never ported JASS. The goal is clean, reusable Wurst — not a JASS transliteration. + +- Never call a raw `common.j` / `Blizzard.j` native when a Wurst wrapper or extension function exists. There is one for almost every native (on `unit`, `player`, `group`, `string`, `rect`, ...). Grep the stdlib (`_build/dependencies/wurstStdlib2/wurst/`) before writing a native call. +- The only bar for a raw native is that you searched and confirmed no wrapper exists — then add a one-line comment saying so. +- "It compiles" is not enough. Code that reads like JASS (manual handle juggling, native calls, global trigger callbacks, op-limit chunking) is wrong here; rewrite it idiomatically. + +Use the stdlib API, not a raw native, for at least: + +- Timers → `ClosureTimers` (`doAfter`, `doPeriodically`); never `CreateTimer`/`TimerStart`/`PauseTimer`/`DestroyTimer`. +- Printing → `print` / `printTimed` / `p.print`; never `DisplayText*ToPlayer`/`...ToForce`. +- Player state → `Player` extensions (`p.addGold`, `p.getGold`, `p.getId`, ...); prefer the `players[i]` array over `Player(i)`. +- Unit inspection → `Unit` extensions (`u.getTypeId()`, `u.getOwner()`, `u.getAbilityLevel(id)`, ...). +- Hashtables → `Hashtable` extensions (`ht.saveInt`/`loadInt`/`flushChild`/...). +- Group iteration → `ClosureForGroups` (`forUnitsInRange`, `forUnitsInRect`) + `GroupUtils` (`getGroup()` / `group.release()`), not `GroupEnum*` + `ForGroup` globals. + +The `CreateTrigger()..register...()..addAction() ->` cascade is the accepted idiom and is fine. + +### Do Not Reinvent Stdlib Infrastructure (Mandatory) + +Keep custom engine-level infrastructure to a minimum. Stdlib packages are battle-tested and handle the WC3 edge cases (recycling, op-limits, cleanup, desync) that hand-rolled versions get wrong. Grep for an existing system before building one; do not ship a parallel implementation of something stdlib provides: + +- Dummy spell casting → `DummyCaster` / `InstantDummyCaster` (unit pooling: `DummyRecycler`). +- Triggered damage → `DummyDamage` to deal, `DamageEvent` to detect/modify. +- Events → `ClosureEvents` (`EventListener.add(...)`) / `RegisterEvents`; no custom global-trigger dispatcher or event bus. +- Knockback / FX / sound / interpolation / orders → `Knockback3`, `Fx`, `SoundUtils`/`Sounds`, `Interpolation`, `Orders`/`OrderStringFactory`. +- Collections → `LinkedList`, `HashMap`, `HashList`. + +If stdlib almost fits, prefer a thin wrapper around the stdlib type over a from-scratch system and note why in a comment. Reinventing this is treated as a defect even if it compiles and passes tests, because it reintroduces solved bugs. + ## Agent Workflow Install dependencies: @@ -43,6 +74,8 @@ For build changes: grill build ExampleMap.w3x --quiet ``` +Builds default to production mode, so compiletime `isProductionBuild()` returns `true`. Use `grill build ExampleMap.w3x --dev --quiet` only when validating run/development-mode behavior where `isProductionBuild()` must be `false`; `typecheck` and `test` do not need this flag. + Done means relevant errors/warnings are fixed or explicitly explained. ## Project Configuration @@ -142,6 +175,15 @@ let label = count == 1 ? "unit" : "units" These are recurring real-world Wurst/Warcraft III failure modes. Treat this section as a pre-edit checklist for any non-trivial Wurst change. +### Integer overflow + +WC3 `int` is 32-bit signed and wraps silently at ~2.1 billion (`2^31 - 1`) — no exception, just a negative/garbage value that poisons every downstream comparison and division. Easy to hit when multiplying or summing large game quantities (gold/worth, army totals, damage products, accumulated stats). + +- Promote to `real` BEFORE multiplying two large quantities: `a.toReal() * b`, never `(a * b).toReal()` (the latter already overflowed). +- Same for running sums of products: `total += worth.toReal() * count * mult`. +- Watch `worth * worth`, `count * worth`, `total * total` in scoring/stats; aggregate worths routinely exceed ~46k (the square root of int-max), so their product overflows in ordinary large games. +- Wurst `/` is real division even for two ints (use `div` for integer division), so division itself does not overflow — but its operands still can. Prefer `real` for any accumulator that fans in many large terms. + ### Closure capture is by value Wurst closures capture locals by value. If a closure assigns to a local from an outer scope, the outer local is not updated.