feat: Kotlin 语法约束——只当语法糖用,不当新范式用
新增 docs/13-kotlin-style.md,并把它落成可执行的构建门禁,而不是一份只能靠
自觉遵守的建议。两条准入标准:只当语法糖(不引入新的思维模型)、每条规则必须
说得出它挡住的具体故障。
- KotlinSyntaxScanTest(源码扫描):禁 !! / lateinit / suspend / kotlinx.coroutines
/ RxJava / Reactor / data class Entity。走源码文本而非 ArchUnit,是因为这些在
字节码上看不出来——!! 编译成 Intrinsics.checkNotNull(...) 调用,和其他 null
检查混在一起。
- ArchitectureRulesTest 加「禁止工具类」:只禁 Utils/Util/Helper/Common 四个
后缀,不禁 XxxManager——Spring 自身大量使用这个命名,一刀切会误伤。
- PersistedEntity + requiredId:把「从 repository 读出来的实体必有 id」变成
一个有名字的类型,替掉 AuthAppService / LocalSeedDataConfig 里 7 处 .id!!。
- 5 个 Entity 实现 PersistedEntity。
两条故意不自动强制的写进了文档:companion object 靠 review 判断(现存 14 处
用法均合法,硬规则只会误伤);Entity 可被 Hibernate 代理目前没守住——未 apply
kotlin("plugin.jpa"),Entity 是 final 类,fetch = LAZY 会静默降级为 eager,现
因为零懒加载关联而未暴露。
docs/01-project-structure.md 顺带补了 Gradle 版本行的双向约束说明;README 修正
了 12 篇→13 篇文档、10 条→11 条规则,并补上工具类禁名。
验证:./gradlew build -PexcludeIntegration 通过。ArchitectureRulesTest 11 项、
KotlinSyntaxScanTest、NativeQueryScanTest 全绿;两条新规则均以「植入一个故意
违规、确认它变红」的方式验证过不是空转。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Code
parent
54f3b35245
commit
aca7487be2
+13
@@ -163,4 +163,17 @@ class ArchitectureRulesTest {
|
||||
.because("统一用构造器注入,可测试且不可变")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `禁止工具类`() {
|
||||
// 13-kotlin-style.md:无状态逻辑用顶层函数让文件名表达归属,有状态的用 @Component。
|
||||
// 只禁这四个后缀,**不禁 XxxManager**——Spring 自身大量使用 *Manager 命名,一刀切会误伤。
|
||||
noClasses()
|
||||
.should().haveSimpleNameEndingWith("Utils")
|
||||
.orShould().haveSimpleNameEndingWith("Util")
|
||||
.orShould().haveSimpleNameEndingWith("Helper")
|
||||
.orShould().haveSimpleNameEndingWith("Common")
|
||||
.because("工具类是「我懒得想这东西属于谁」的标记,会变成什么都往里塞的垃圾桶")
|
||||
.check(classes)
|
||||
}
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.continental.retailapp.architecture
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.streams.toList
|
||||
|
||||
/**
|
||||
* 13-kotlin-style.md 里那些"字节码上看不出来"的语法规则。
|
||||
*
|
||||
* ArchUnit 看的是字节码:强解包编译成 `Intrinsics.checkNotNull(...)` 调用,和其他 null 检查
|
||||
* 混在一起分不出来;`data class` 在字节码里只是多了 `component1()`/`copy()`,也没法干净判定。
|
||||
* 这些规则在源码里是确定的 token,所以用源码扫描——和 [NativeQueryScanTest] 同一个套路,
|
||||
* 是 10-testing.md 那条"编译器和 ArchUnit 拦不住的就加一道源码扫描"的第二次应用。
|
||||
*
|
||||
* **只扫 `src/main`**:测试代码里强解包不违规。测试里 null 是立即可见的失败信号,
|
||||
* 再包一层 `?: throw` 只是噪音(见 13-kotlin-style.md)。
|
||||
*/
|
||||
class KotlinSyntaxScanTest {
|
||||
|
||||
private data class Rule(val label: String, val token: String, val pattern: Regex)
|
||||
|
||||
private val rules = listOf(
|
||||
Rule("强解包", "!!", Regex("!!")),
|
||||
Rule("延迟初始化", "lateinit", Regex("\\blateinit\\b")),
|
||||
Rule("协程", "suspend fun", Regex("\\bsuspend\\s+fun\\b")),
|
||||
Rule("协程", "kotlinx.coroutines", Regex("kotlinx\\.coroutines")),
|
||||
Rule("RxJava", "io.reactivex", Regex("io\\.reactivex")),
|
||||
Rule("Reactor", "reactor.core", Regex("reactor\\.core")),
|
||||
Rule("Entity 是 data class", "data class", Regex("\\bdata\\s+class\\s+\\w*Entity\\b")),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `生产代码不出现 13-kotlin-style 禁止的语法`() {
|
||||
val violations = productionSources().flatMap { file ->
|
||||
val path = file.toString().replace('\\', '/')
|
||||
codeOnly(Files.readString(file)).lines().flatMapIndexed { index, line ->
|
||||
rules.filter { it.pattern.containsMatchIn(line) }
|
||||
.map { "${path}:${index + 1} 出现 ${it.token}(${it.label})" }
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(violations.isEmpty()) {
|
||||
"违反 13-kotlin-style.md:\n${violations.joinToString("\n")}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun productionSources(): List<Path> =
|
||||
listOf("../domains", "../platform", "../integration", "../bootstrap")
|
||||
.map(Path::of)
|
||||
.filter { Files.exists(it) }
|
||||
.flatMap { root ->
|
||||
Files.walk(root).filter { it.isProductionKotlin() }.toList()
|
||||
}
|
||||
|
||||
private fun Path.isProductionKotlin(): Boolean {
|
||||
val path = toString().replace('\\', '/')
|
||||
return path.endsWith(".kt") && path.contains("/src/main/")
|
||||
}
|
||||
|
||||
/**
|
||||
* 把注释和字符串字面量抹成空白,只留代码,且**保留换行**——报违规时行号要能对上。
|
||||
*
|
||||
* 这一步不能省:注释里为了讲清规则经常要写反例("不要写 `user.id!!` 这种"),
|
||||
* 字符串里也可能出现同样的字符,直接对原文匹配会把它们全报成违规。
|
||||
*/
|
||||
private fun codeOnly(source: String): String {
|
||||
val out = StringBuilder(source.length)
|
||||
var i = 0
|
||||
fun blank() {
|
||||
out.append(if (source[i] == '\n') '\n' else ' ')
|
||||
i++
|
||||
}
|
||||
|
||||
while (i < source.length) {
|
||||
when {
|
||||
source.startsWith("//", i) ->
|
||||
while (i < source.length && source[i] != '\n') blank()
|
||||
|
||||
source.startsWith("/*", i) -> {
|
||||
while (i < source.length && !source.startsWith("*/", i)) blank()
|
||||
repeat(2) { if (i < source.length) blank() }
|
||||
}
|
||||
|
||||
// 三引号原始字符串要排在普通字符串前面判断:@Query 里的 SQL 都用它
|
||||
source.startsWith("\"\"\"", i) -> {
|
||||
while (i < source.length && !source.startsWith("\"\"\"", i)) blank()
|
||||
repeat(3) { if (i < source.length) blank() }
|
||||
}
|
||||
|
||||
source[i] == '"' -> {
|
||||
blank()
|
||||
while (i < source.length && source[i] != '"') {
|
||||
if (source[i] == '\\') blank()
|
||||
if (i < source.length) blank()
|
||||
}
|
||||
if (i < source.length) blank()
|
||||
}
|
||||
|
||||
else -> {
|
||||
out.append(source[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user