feat(router): idea-board -> project-profile promotion (E)

Promote an auto-captured idea from the cross-session board into the curated per-repo
profile at <workspaceRoot>/.correx/project.toml as a `conventions` entry. New
IdeaPromotedEvent tombstones the idea off the board (IdeaReader treats it like a
discard) and records the promotion as a fact; the server PromoteIdea handler loads
the profile, appends the idea text (deduped via ProjectProfile.withConvention), and
writes it back. Adds ProjectProfileWriter (escape-aware TOML serialize/write) and
makes SimpleToml's reader unescape \\ and \" symmetrically with the writers
(fixes a pre-existing read/write asymmetry surfaced by the round-trip test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 07:30:43 +00:00
parent eeb6830387
commit f107ff554c
11 changed files with 396 additions and 10 deletions
@@ -57,8 +57,11 @@ internal object SimpleToml {
var depth = 0
var inQuotes = false
var quoteChar = ' '
var escaped = false
for (ch in value) {
when {
escaped -> escaped = false
ch == '\\' && inQuotes && quoteChar == '"' -> escaped = true
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch }
ch == quoteChar && inQuotes -> inQuotes = false
ch == '[' && !inQuotes -> depth++
@@ -92,8 +95,13 @@ internal object SimpleToml {
var current = StringBuilder()
var inQuotes = false
var quoteChar = ' '
var escaped = false
for (ch in content) {
when {
// Keep escape sequences (e.g. \") intact so they reach stripQuotes verbatim; an
// escaped quote must not be treated as the string's closing delimiter.
escaped -> { current.append(ch); escaped = false }
ch == '\\' && inQuotes && quoteChar == '"' -> { current.append(ch); escaped = true }
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) }
ch == quoteChar && inQuotes -> { inQuotes = false; current.append(ch) }
ch == ',' && !inQuotes -> { result.add(stripQuotes(current.toString().trim())); current = StringBuilder() }
@@ -104,10 +112,39 @@ internal object SimpleToml {
return result
}
/**
* Strips the surrounding quotes from [value] and, for double-quoted strings, unescapes the
* `\\` and `\"` sequences emitted by the writers (mirrors [ProjectProfileWriter]/[CorrexConfigWriter]).
*/
private fun stripQuotes(value: String): String {
val isDoubleQuoted = value.startsWith("\"") && value.endsWith("\"")
val isSingleQuoted = value.startsWith("'") && value.endsWith("'")
return if (isDoubleQuoted || isSingleQuoted) value.substring(1, value.length - 1) else value
val isDoubleQuoted = value.startsWith("\"") && value.endsWith("\"") && value.length >= 2
val isSingleQuoted = value.startsWith("'") && value.endsWith("'") && value.length >= 2
return when {
isDoubleQuoted -> unescapeDoubleQuoted(value.substring(1, value.length - 1))
isSingleQuoted -> value.substring(1, value.length - 1)
else -> value
}
}
/** Reverses the writer's escaping: `\\` -> `\`, `\"` -> `"`; a trailing lone `\` is kept as-is. */
private fun unescapeDoubleQuoted(value: String): String {
if ('\\' !in value) return value
val sb = StringBuilder(value.length)
var i = 0
while (i < value.length) {
val ch = value[i]
if (ch == '\\' && i + 1 < value.length) {
val next = value[i + 1]
if (next == '\\' || next == '"') {
sb.append(next)
i += 2
continue
}
}
sb.append(ch)
i++
}
return sb.toString()
}
}
@@ -0,0 +1,64 @@
package com.correx.core.config
import java.nio.file.Files
/**
* Serializes a [ProjectProfile] back to TOML text that [ProjectProfileLoader] reads identically:
* the invariant is `load(write(profile)) == profile` for every field the loader understands
* (about, conventions, commands).
*
* Like [CorrexConfigWriter] this **regenerates** the file from the profile object — it does not
* preserve hand-written comments. Empty sections are skipped: a blank `about` and an empty
* `conventions`/`commands` produce no output for that part (the loader reconstructs the defaults
* from their absence), so a [ProjectProfile.isEmpty] profile serializes to an empty string.
*/
object ProjectProfileWriter {
/** Renders [profile] as TOML. String values escape `\` and `"`; empty sections are skipped. */
fun serialize(profile: ProjectProfile): String {
val b = StringBuilder()
if (profile.about.isNotBlank()) {
b.append("about = ").append(str(profile.about)).append('\n')
}
if (profile.conventions.isNotEmpty()) {
b.append("conventions = ").append(list(profile.conventions)).append('\n')
}
if (profile.commands.isNotEmpty()) {
if (b.isNotEmpty()) b.append('\n')
b.append("[commands]\n")
profile.commands.forEach { (key, value) ->
b.append(key).append(" = ").append(str(value)).append('\n')
}
}
return b.toString()
}
/**
* Writes [profile] to `<workspaceRoot>/.correx/project.toml`, creating the `.correx/` directory
* if it is missing. Overwrites any existing file (the writer owns the file once a save happens).
*/
fun write(workspaceRoot: String, profile: ProjectProfile) {
val path = ProjectProfileLoader.profilePath(workspaceRoot)
Files.createDirectories(path.parent)
Files.writeString(path, serialize(profile))
}
private fun str(v: String): String = "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
private fun list(v: List<String>): String = "[" + v.joinToString(", ") { str(it) } + "]"
}
/**
* Appends [text] to this profile's conventions, de-duplicated by exact string match. A blank
* [text] is ignored (returns the profile unchanged). Used when promoting an idea-board entry into
* the curated profile so repeated promotions of the same text don't pile up duplicate conventions.
*/
fun ProjectProfile.withConvention(text: String): ProjectProfile {
val trimmed = text.trim()
if (trimmed.isBlank() || trimmed in conventions) return this
return copy(conventions = conventions + trimmed)
}
@@ -0,0 +1,79 @@
package com.correx.core.config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class ProjectProfileWriterTest {
@TempDir
lateinit var tempDir: Path
@Test
fun `write then load round-trips about conventions and commands including embedded quotes`() {
val profile = ProjectProfile(
about = "Event-sourced kernel \"the\" orchestrator, Kotlin/JVM 21",
conventions = listOf(
"no bare try-catch",
"prefer the \"narrow\" interface", // a convention with a double-quote
),
commands = mapOf(
"build" to "./gradlew build",
"test" to "./gradlew check",
),
)
ProjectProfileWriter.write(tempDir.toString(), profile)
val loaded = ProjectProfileLoader.load(tempDir.toString())
assertEquals(profile.about, loaded.about)
assertEquals(profile.conventions, loaded.conventions)
assertEquals(profile.commands, loaded.commands)
assertEquals(profile, loaded)
}
@Test
fun `an empty profile serializes to an empty string and skips empty sections`() {
val serialized = ProjectProfileWriter.serialize(ProjectProfile())
assertEquals("", serialized)
assertFalse(serialized.contains("[commands]"))
}
@Test
fun `blank about is omitted but conventions and commands are still written`() {
val serialized = ProjectProfileWriter.serialize(
ProjectProfile(
about = " ",
conventions = listOf("c1"),
commands = mapOf("run" to "x"),
),
)
assertFalse(serialized.contains("about ="), "blank about must be skipped")
assertTrue(serialized.contains("conventions = [\"c1\"]"))
assertTrue(serialized.contains("[commands]"))
}
@Test
fun `withConvention appends a new convention`() {
val updated = ProjectProfile(conventions = listOf("a")).withConvention("b")
assertEquals(listOf("a", "b"), updated.conventions)
}
@Test
fun `withConvention de-dupes an exact existing convention`() {
val profile = ProjectProfile(conventions = listOf("no bare try-catch"))
val updated = profile.withConvention("no bare try-catch")
assertSame(profile, updated, "an exact duplicate must return the same profile unchanged")
assertEquals(listOf("no bare try-catch"), updated.conventions)
}
@Test
fun `withConvention ignores blank text`() {
val profile = ProjectProfile(conventions = listOf("a"))
assertSame(profile, profile.withConvention(" "))
}
}