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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 10:05:44 +00:00
parent f715ffa540
commit 0e251d6083
2 changed files with 386 additions and 0 deletions
@@ -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<ValidationIssue>()
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<String>()
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<String>,
subResourceIds: Set<String>,
issues: MutableList<ValidationIssue>,
) {
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<String>,
issues: MutableList<ValidationIssue>,
) {
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<String, String>)
/**
* 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<String>): Map<String, String>? {
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<String>? {
val tokens = mutableListOf<String>()
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
}
}
@@ -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<ValidationIssue> =
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())
}
}