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:
@@ -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
|
||||
}
|
||||
}
|
||||
+156
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user