From 0e251d6083269772400587fa67081bbc40cf9aa7 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 10:05:44 +0000 Subject: [PATCH] feat(validation): structural .tscn/.tres scene validator Standalone SceneValidator(content) -> ValidationReport: header well-formedness, ExtResource/SubResource id resolution, node parent-path integrity. Not wired into any pipeline (validation layer only; scene editing is a later epic). (BACKLOG I) Co-Authored-By: Claude Opus 4.8 --- .../core/validation/scene/SceneValidator.kt | 230 ++++++++++++++++++ .../validation/scene/SceneValidatorTest.kt | 156 ++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt create mode 100644 core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt new file mode 100644 index 00000000..b835e715 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt @@ -0,0 +1,230 @@ +package com.correx.core.validation.scene + +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity + +/** + * Standalone structural validator for Godot `.tscn` / `.tres` text (an INI-like format). + * + * Pure logic: it takes the file content as a [String] and returns a [ValidationReport]. It does + * NOT touch the filesystem and is intentionally not wired into any orchestration/pipeline. + * + * It checks three structural properties: + * 1. Section headers are well-formed and of a known type. + * 2. `ExtResource(...)` / `SubResource(...)` references resolve to a declared `id`. + * 3. Node `parent="..."` paths resolve to an already-declared node. + */ +class SceneValidator { + + private companion object { + const val CODE_MALFORMED_HEADER = "scene.malformed_header" + const val CODE_UNKNOWN_SECTION = "scene.unknown_section" + const val CODE_UNRESOLVED_RESOURCE = "scene.unresolved_resource" + const val CODE_BAD_PARENT = "scene.bad_parent" + + val KNOWN_SECTIONS = setOf( + "gd_scene", + "gd_resource", + "ext_resource", + "sub_resource", + "node", + "resource", + "connection", + ) + + val EXT_RESOURCE_REF = Regex("""ExtResource\(\s*"?([^")\s]+)"?\s*\)""") + val SUB_RESOURCE_REF = Regex("""SubResource\(\s*"?([^")\s]+)"?\s*\)""") + } + + fun validate(content: String): ValidationReport { + val issues = mutableListOf() + val lines = content.split("\n").map { it.trim() } + + // Collect declared ids up front so resource-reference resolution is independent of the + // order references appear relative to their declarations. + val headers = lines.filter { it.startsWith("[") }.mapNotNull { parseHeader(it) } + val extResourceIds = headers + .filter { it.type == "ext_resource" } + .mapNotNull { it.attributes["id"] } + .toSet() + val subResourceIds = headers + .filter { it.type == "sub_resource" } + .mapNotNull { it.attributes["id"] } + .toSet() + + val knownNodePaths = mutableSetOf() + var rootDeclared = false + + lines.forEachIndexed { index, line -> + val lineNumber = index + 1 + if (line.startsWith("[")) { + val header = parseHeader(line) + if (header == null) { + issues += issue( + CODE_MALFORMED_HEADER, + "Malformed section header at line $lineNumber: '$line'", + ValidationSeverity.ERROR, + ) + return@forEachIndexed + } + if (header.type !in KNOWN_SECTIONS) { + issues += issue( + CODE_UNKNOWN_SECTION, + "Unknown section type '${header.type}' at line $lineNumber", + ValidationSeverity.WARNING, + ) + } + if (header.type == "node") { + handleNode(header, lineNumber, rootDeclared, knownNodePaths, issues) + rootDeclared = true + } + } else { + checkResourceReferences(line, lineNumber, extResourceIds, subResourceIds, issues) + } + } + + return ValidationReport( + sections = listOf(ValidationSection(name = "scene", issues = issues)), + ) + } + + private fun checkResourceReferences( + line: String, + lineNumber: Int, + extResourceIds: Set, + subResourceIds: Set, + issues: MutableList, + ) { + EXT_RESOURCE_REF.findAll(line).forEach { match -> + val id = match.groupValues[1] + if (id !in extResourceIds) { + issues += issue( + CODE_UNRESOLVED_RESOURCE, + "Unresolved ExtResource(\"$id\") at line $lineNumber: no [ext_resource] declares id=\"$id\"", + ValidationSeverity.ERROR, + ) + } + } + SUB_RESOURCE_REF.findAll(line).forEach { match -> + val id = match.groupValues[1] + if (id !in subResourceIds) { + issues += issue( + CODE_UNRESOLVED_RESOURCE, + "Unresolved SubResource(\"$id\") at line $lineNumber: no [sub_resource] declares id=\"$id\"", + ValidationSeverity.ERROR, + ) + } + } + } + + /** + * Registers a node's path into [knownNodePaths]. Emits [CODE_BAD_PARENT] when a non-root node + * references a parent path that has not yet been declared. [rootDeclared] tells whether a root + * node has already been seen (the first node is the root). + */ + private fun handleNode( + header: Header, + lineNumber: Int, + rootDeclared: Boolean, + knownNodePaths: MutableSet, + issues: MutableList, + ) { + val name = header.attributes["name"] + val parent = header.attributes["parent"] + + when { + // First node is the root: no parent, or parent=".". + !rootDeclared -> { + if (parent != null && parent != ".") { + issues += issue( + CODE_BAD_PARENT, + "Root node at line $lineNumber declares parent=\"$parent\" but no parent is declared yet", + ValidationSeverity.ERROR, + ) + } + knownNodePaths += "." + } + parent == null -> issues += issue( + CODE_BAD_PARENT, + "Node '${name ?: "?"}' at line $lineNumber has no parent attribute", + ValidationSeverity.ERROR, + ) + parent !in knownNodePaths -> issues += issue( + CODE_BAD_PARENT, + "Node '${name ?: "?"}' at line $lineNumber references undeclared parent \"$parent\"", + ValidationSeverity.ERROR, + ) + name != null -> knownNodePaths += if (parent == ".") name else "$parent/$name" + } + } + + private fun issue(code: String, message: String, severity: ValidationSeverity) = + ValidationIssue(code = code, message = message, severity = severity) + + private data class Header(val type: String, val attributes: Map) + + /** + * Parses a section header line into its type and `key=value` attributes. Returns `null` when the + * line is not a balanced, parseable header (e.g. unclosed `[`, missing type token). + */ + private fun parseHeader(line: String): Header? { + // The header must be a single balanced [...] pair. + val balanced = line.startsWith("[") && line.endsWith("]") && + line.count { it == '[' } == 1 && line.count { it == ']' } == 1 + val tokens = line.takeIf { balanced } + ?.substring(1, line.length - 1) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { tokenize(it) } + ?: return null + + // The section type token must be a bare identifier, not a key=value pair. + return tokens.firstOrNull() + ?.takeIf { !it.contains('=') && it.all { ch -> ch.isLetterOrDigit() || ch == '_' } } + ?.let { type -> parseAttributes(tokens.drop(1))?.let { Header(type, it) } } + } + + /** Parses `key=value` attribute tokens. Returns `null` if any token is not a valid pair. */ + private fun parseAttributes(tokens: List): Map? { + val pairs = tokens.map { token -> + val eq = token.indexOf('=') + val key = if (eq > 0) token.substring(0, eq) else "" + if (key.isBlank()) { + null + } else { + key to token.substring(eq + 1).trim().removeSurrounding("\"") + } + } + return if (pairs.any { it == null }) null else pairs.filterNotNull().toMap() + } + + /** + * Splits header inner text into whitespace-separated tokens while respecting double-quoted + * values (which may themselves contain spaces). Returns `null` if a quote is left unterminated. + */ + private fun tokenize(inner: String): List? { + val tokens = mutableListOf() + val current = StringBuilder() + var inQuotes = false + for (ch in inner) { + when { + ch == '"' -> { + inQuotes = !inQuotes + current.append(ch) + } + ch.isWhitespace() && !inQuotes -> { + if (current.isNotEmpty()) { + tokens += current.toString() + current.clear() + } + } + else -> current.append(ch) + } + } + if (inQuotes) return null + if (current.isNotEmpty()) tokens += current.toString() + return tokens + } +} diff --git a/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt b/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt new file mode 100644 index 00000000..2db8de04 --- /dev/null +++ b/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt @@ -0,0 +1,156 @@ +package com.correx.core.validation.scene + +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSeverity +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SceneValidatorTest { + + private val validator = SceneValidator() + + private fun ValidationReport.issues(): List = + sections.flatMap { it.issues } + + private fun ValidationReport.hasIssue(code: String, severity: ValidationSeverity): Boolean = + issues().any { it.code == code && it.severity == severity } + + @Test + fun `valid tscn with root and two child nodes and a referenced ext_resource has no issues`() { + val content = """ + [gd_scene load_steps=3 format=3 uid="uid://abc123"] + + [ext_resource type="Texture2D" path="res://icon.png" id="1"] + + [node name="Root" type="Node2D"] + + [node name="Sprite" type="Sprite2D" parent="."] + texture = ExtResource("1") + + [node name="Label" type="Label" parent="Sprite"] + text = "hello" + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.issues().isEmpty(), "expected no issues, got ${report.issues()}") + assertFalse(report.hasErrors()) + } + + @Test + fun `malformed unclosed header is flagged as error`() { + val content = """ + [gd_scene format=3] + + [node name="X" + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.malformed_header", ValidationSeverity.ERROR)) + assertTrue(report.hasErrors()) + } + + @Test + fun `ExtResource referencing an undeclared id is an unresolved resource error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + texture = ExtResource("99") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unresolved_resource", ValidationSeverity.ERROR)) + } + + @Test + fun `SubResource referencing an undeclared id is an unresolved resource error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + material = SubResource("MissingMat") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unresolved_resource", ValidationSeverity.ERROR)) + } + + @Test + fun `node with non-existent parent is a bad parent error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + + [node name="Child" type="Node2D" parent="DoesNotExist"] + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.bad_parent", ValidationSeverity.ERROR)) + } + + @Test + fun `valid tres resource file with ext and sub resources has no issues`() { + val content = """ + [gd_resource type="StyleBoxFlat" load_steps=2 format=3 uid="uid://xyz"] + + [ext_resource type="Texture2D" path="res://tex.png" id="1"] + + [sub_resource type="Gradient" id="grad_1"] + colors = PackedColorArray(1, 1, 1, 1) + + [resource] + texture = ExtResource("1") + gradient = SubResource("grad_1") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.issues().isEmpty(), "expected no issues, got ${report.issues()}") + assertFalse(report.hasErrors()) + } + + @Test + fun `unknown section type is a warning`() { + val content = """ + [gd_scene format=3] + + [bogus_section] + + [node name="Root" type="Node2D"] + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unknown_section", ValidationSeverity.WARNING)) + // An unknown section is a warning, not an error. + assertFalse(report.hasErrors()) + } + + @Test + fun `deeper parent path resolves when the intermediate node was declared`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + + [node name="A" type="Node2D" parent="."] + + [node name="B" type="Node2D" parent="A"] + + [node name="C" type="Node2D" parent="A/B"] + """.trimIndent() + + val report = validator.validate(content) + + assertFalse(report.hasIssue("scene.bad_parent", ValidationSeverity.ERROR)) + assertTrue(report.issues().isEmpty()) + } +}