AgentDemoDriftGuard.kt
src/jvmMain/kotlin/afield/tools/docs/AgentDemoDriftGuard.kt · 230 lines
package afield.tools.docs
import afield.document.persistence.SavingContext
import java.io.File
import kotlin.system.exitProcess
Checks that each committed, authored document agrees with the loose source files beside it: every
document under dev_docs/agents/demos/, and every document directly under AUTHORED_ROOTS that
carries inline copies.
A #afield/file-inline property has two copies: the fallback embedded in metadata.edn, and the
loose file beside it. Field loads the loose file, unless metadata.edn is the newer of the two and
says something else (SavingContext.sidecarOrNewerInline takes that as a hand edit of the inline
copy). So a stale inline copy is a trap: an edited sidecar runs fine until someone touches
metadata.edn for any other reason, and from then on the old code loads instead. On 2026-10-31
that put the Khronos Box.gltf lesson back into canonical_graphics, whose inline copies were stale
for 77 sidecars. This guard compares the two byte-for-byte. --write refreshes the inline
fallbacks through the EDN codec in SavingContext.
--write rewrites metadata.edn for every document it visits, and re-encoding is not always
byte-identical to what is committed — a hand-edited artifact can carry \uXXXX escapes that the
codec re-emits as the literal character. That turns "add one demo" into a diff against unrelated
documents, which is an invitation to a merge conflict between two branches that each added one.
--only <document>[,<document>…] narrows the run to the documents named, by directory name or by
path from the checkout root, so adding a demo touches that demo alone.
object AgentDemoDriftGuard
Compares each authored document's #afield/file-inline fallbacks with the loose files beside them.
object AgentDemoDriftGuard {
private const val DEMOS_REL = "dev_docs/agents/demos"
Authored documents outside the demo corpus. Generated copies (public/docs/assets/web_publications,
public/docs/assets/agent_transcripts) sit one level deeper and are written by Field, so they are
not visited.
private val AUTHORED_ROOTS = listOf("public/docs/assets", "templates", "tools/presentation/example")
private const val USAGE = "usage: AgentDemoDriftGuard [--write] [--only <document>[,<document>...]]"
fun main(args: Array<String>)
body — 121 lines, from line 42
@JvmStatic
fun main(args: Array<String>) {
var write = false
val only = linkedSetOf<String>()
var cursor = 0
while (cursor < args.size) {
val arg = args[cursor]
when {
arg == "--write" -> write = true
arg == "--only" -> {
require(cursor + 1 < args.size) { "--only needs a document name. $USAGE" }
only += splitNames(args[cursor + 1])
cursor++
}
arg.startsWith("--only=") -> only += splitNames(arg.removePrefix("--only="))
else -> error("unrecognized argument '$arg'. $USAGE")
}
cursor++
}
val root = repoRoot()
val demosRoot = File(root, DEMOS_REL)
require(demosRoot.isDirectory) { "no agent demo directory at ${demosRoot.absolutePath}" }
val demoDocuments = (demosRoot.listFiles() ?: emptyArray())
.filter { it.isDirectory && File(it, SavingContext.METADATA_EDN).isFile }
.sortedBy { it.name }
require(demoDocuments.isNotEmpty()) { "no Field documents under ${demosRoot.absolutePath}" }
// A document outside the demo corpus need not have sidecars at all; only one that has them can drift.
val authored = AUTHORED_ROOTS.flatMap { rel ->
(File(root, rel).listFiles() ?: emptyArray())
.filter { File(it, SavingContext.METADATA_EDN).let { m -> m.isFile && "#afield/file-inline" in m.readText() } }
.sortedBy { it.name }
}
val all = demoDocuments + authored
val demos = select(root, all, only)
// A demo keeps every sidecar beside it. Another document may hold a property only inline (no file
// beside it), which is what Field then loads; with one copy there is nothing to drift.
fun inlineOnlyAllowed(document: File) = document !in demoDocuments
val saving = SavingContext()
if (write) {
// `--write` copies each loose sidecar over its inline copy, so an edit made inside
// metadata.edn would be thrown away without a word. Refuse when metadata.edn has changed
// against git and the sidecar it now disagrees with has not: that is a hand edit.
val lost = demos.flatMap { demo -> handEditedInlines(root, demo, saving) }
if (lost.isNotEmpty()) {
System.err.println("AgentDemoDriftGuard: refusing to regenerate over edits made in metadata.edn:")
lost.forEach { System.err.println(" - $it") }
System.err.println("Make the edit in the named sidecar (or in Field), restore metadata.edn " +
"(git checkout -- <document>/metadata.edn), then regenerate.")
exitProcess(1)
}
demos.forEach { demo ->
val refreshed = saving.refreshInlineFilesFromDisk(demo.absolutePath, inlineOnlyAllowed(demo))
println("[regenerate] ${label(root, demo)}: ${refreshed.size} inline file(s)")
}
}
val problems = mutableListOf<String>()
var inlineFiles = 0
for (demo in demos) {
val documentRoot = demo.canonicalFile.toPath().normalize()
val snapshots = saving.readInlineFileSnapshots(demo.absolutePath)
if (snapshots.isEmpty()) {
problems += "${label(root, demo)}: metadata.edn has no #afield/file-inline properties"
continue
}
inlineFiles += snapshots.size
for (snapshot in snapshots) {
val target = File(demo, snapshot.path).canonicalFile
if (!target.toPath().normalize().startsWith(documentRoot)) {
problems += "${label(root, demo)}/${snapshot.boxId}.${snapshot.property}: path escapes the demo (${snapshot.path})"
continue
}
if (!target.isFile) {
if (inlineOnlyAllowed(demo)) continue
problems += "${label(root, demo)}/${snapshot.boxId}.${snapshot.property}: missing ${snapshot.path}"
continue
}
val loose = target.readText()
if (loose != snapshot.inline) {
val inlineLines = snapshot.inline.lines()
val looseLines = loose.lines()
val at = (0 until maxOf(inlineLines.size, looseLines.size))
.firstOrNull { inlineLines.getOrNull(it) != looseLines.getOrNull(it) } ?: 0
problems += "${label(root, demo)}/${snapshot.boxId}.${snapshot.property}: ${snapshot.path} " +
"differs from metadata.edn at line ${at + 1} — the sidecar is the copy to edit; " +
"regenerating copies it into metadata.edn\n" +
" metadata: ${inlineLines.getOrNull(at)}\n" +
" loose: ${looseLines.getOrNull(at)}"
}
}
// The comparison above is on decoded values, so it is blind to how metadata.edn spells
// them: a six-character « escape and a literal « decode alike, and both pass. That
// is how logging_layer sat in a form the codec does not produce — the parser accepts the
// escape, the printer only ever emits the character — and every `--write` came back
// with it modified. Require the committed bytes to be the codec's own output, so a
// regenerate on a clean tree is a no-op and a demo cannot go back to hand-spelled EDN.
val committed = File(demo, SavingContext.METADATA_EDN).readText()
val canonical = saving.canonicalInlineMetadata(demo.absolutePath, inlineOnlyAllowed(demo)).metadataEdn
if (committed != canonical) {
val at = (0 until maxOf(committed.length, canonical.length))
.firstOrNull { committed.getOrNull(it) != canonical.getOrNull(it) } ?: 0
problems += "${label(root, demo)}: metadata.edn is not what Field's EDN codec writes, " +
"first difference at character ${at + 1}\n" +
" committed: ${excerpt(committed, at)}\n" +
" codec: ${excerpt(canonical, at)}"
}
}
if (problems.isNotEmpty()) {
System.err.println("AgentDemoDriftGuard FAILED with ${problems.size} problem(s):")
problems.forEach { System.err.println(" - $it") }
System.err.println("Fix with: ./gradlew regenerateAgentDemos")
exitProcess(1)
}
val scope = if (demos.size == all.size) "" else " of ${all.size}"
println("AgentDemoDriftGuard ok — ${demos.size}$scope document(s), $inlineFiles inline file(s)")
}
Inline copies in demo's metadata.edn that disagree with their sidecar when metadata.edn differs from git HEAD and the sidecar does not. Outside a git checkout nothing is reported.
private fun handEditedInlines(root: File, demo: File, saving: SavingContext): List<String> {
fun changed(file: File): Boolean? = runCatching {
val rel = file.relativeTo(root).path
val p = ProcessBuilder("git", "diff", "--quiet", "HEAD", "--", rel).directory(root)
.redirectErrorStream(true).start()
p.inputStream.readAllBytes()
when (p.waitFor()) { 0 -> false; 1 -> true; else -> null }
}.getOrNull()
val metadata = File(demo, SavingContext.METADATA_EDN)
if (changed(metadata) != true) return emptyList()
return saving.readInlineFileSnapshots(demo.absolutePath).mapNotNull { snapshot ->
val sidecar = File(demo, snapshot.path)
if (!sidecar.isFile || sidecar.readText() == snapshot.inline) return@mapNotNull null
if (changed(sidecar) != false) return@mapNotNull null
"${label(root, demo)}/${snapshot.boxId}.${snapshot.property}: edit ${label(root, demo)}/${snapshot.path}, not metadata.edn"
}
}
A metadata.edn is one long line, so a difference is only legible as a window around it.
private fun excerpt(text: String, at: Int): String {
val from = maxOf(0, at - 30)
val to = minOf(text.length, at + 30)
val prefix = if (from > 0) "…" else ""
val suffix = if (to < text.length) "…" else ""
return prefix + text.substring(from, to).replace("\n", "\\n") + suffix
}
private fun splitNames(raw: String): List<String> =
raw.split(',').map(String::trim).filter(String::isNotEmpty)
How messages name document: a demo by its directory, any other document by its path from root.
private fun label(root: File, document: File): String =
if (document.parentFile.name == File(DEMOS_REL).name) document.name else document.relativeTo(root).path
Resolves --only names, each a directory name or a path from root, against the documents on
disk, naming the alternatives on a typo and refusing a directory name two documents share.
private fun select(root: File, all: List<File>, only: Set<String>): List<File> {
if (only.isEmpty()) return all
val chosen = only.map { name ->
val matches = all.filter { it.name == name || it.relativeTo(root).path == name }
require(matches.isNotEmpty()) {
"no such document: $name — have ${all.joinToString(", ") { label(root, it) }}"
}
require(matches.size == 1) {
"$name names ${matches.size} documents (${matches.joinToString(", ") { it.relativeTo(root).path }}); give its path"
}
matches.single()
}.toSet()
return all.filter { it in chosen }
}
private fun repoRoot(): File {
var at: File? = File(System.getProperty("user.dir")).absoluteFile
while (at != null) {
if (File(at, "settings.gradle.kts").isFile && File(at, DEMOS_REL).isDirectory) return at
at = at.parentFile
}
error("could not locate Field repo from user.dir=${System.getProperty("user.dir")}")
}
}