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