build.gradle.kts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. @DisableCachingByDefault(because = "架构验证任务没有输出文件")
  2. abstract class VerifyConfigArchitectureTask : DefaultTask() {
  3. @get:InputDirectory
  4. @get:PathSensitive(PathSensitivity.RELATIVE)
  5. abstract val sourceRoot: DirectoryProperty
  6. @get:Input
  7. abstract val legacyPreferenceCallBaseline: MapProperty<String, Int>
  8. @get:Input
  9. abstract val legacyDaoInjectionBaseline: MapProperty<String, Int>
  10. @get:Input
  11. abstract val legacyUiDaoAccessBaseline: MapProperty<String, Int>
  12. @TaskAction
  13. fun verify() {
  14. val sourceRootDir = sourceRoot.get().asFile
  15. val kotlinFiles = sourceRootDir.walkTopDown()
  16. .filter { it.isFile && it.extension == "kt" }
  17. .toList()
  18. val preferenceBaseline = legacyPreferenceCallBaseline.get()
  19. val daoInjectionBaseline = legacyDaoInjectionBaseline.get()
  20. val uiDaoAccessBaseline = legacyUiDaoAccessBaseline.get()
  21. val violations = mutableListOf<String>()
  22. val forbiddenConfigImport = Regex(
  23. """^import io\.legado\.app\.(?:help\.config\.AppConfig|ui\.config\..*Config)$""",
  24. RegexOption.MULTILINE,
  25. )
  26. val preferenceCall = Regex("""\b(?:getPref|putPref)[A-Za-z0-9_]*\s*\(""")
  27. val daoImport = Regex(
  28. """^import io\.legado\.app\.data\.dao\.[A-Za-z0-9_*]+$""",
  29. RegexOption.MULTILINE,
  30. )
  31. val appDbDaoAccess = Regex(
  32. """(?:\bappDb|io\.legado\.app\.data\.appDb)\.[A-Za-z0-9_]*Dao\b"""
  33. )
  34. val readBookConfigWrite = Regex(
  35. """\bReadBookConfig\.[a-z_][A-Za-z0-9_]*(?:\.[a-z_][A-Za-z0-9_]*)?\s*="""
  36. )
  37. val readBookConfigMutationCall = Regex(
  38. """\bReadBookConfig\.durConfig\.set[A-Za-z0-9_]*\s*\("""
  39. )
  40. // 上面两条都按 `ReadBookConfig.` 前缀找,成员 import 之后的裸写一个都看不见:
  41. // `import io.legado.app.help.config.ReadBookConfig.durConfig`(含 as 别名)之后
  42. // `durConfig = ...` 就是绕过 gateway 的写——不落盘也不 publishState。
  43. // 不必管通配 import:Kotlin 不允许从 object 按需导入。
  44. val readBookConfigMemberImport = Regex(
  45. """^import io\.legado\.app\.help\.config\.ReadBookConfig\.durConfig\b""",
  46. RegexOption.MULTILINE,
  47. )
  48. // 同样的裸写还能从 `with(ReadBookConfig) { durConfig = ... }`/`ReadBookConfig.apply { }`
  49. // 这类作用域函数里冒出来。与其枚举作用域函数(还会误伤 ChapterProvider 里只读的
  50. // `with(ReadBookConfig)`),不如直接盯裸赋值本身:带 `.` 前缀的限定写法归上面那条管。
  51. val readBookConfigBareWrite = Regex("""(?<![.\w])durConfig\s*=(?!=)""")
  52. // 文件读写层 ReadStyleRepository 同理是 Koin 单例:谁 inject 谁就能直接 save()
  53. // 覆盖 readConfig.json,磁盘与 ReadStyleConfigStore 的内存状态就此分叉。
  54. val styleRepositoryOwners = setOf(
  55. "io/legado/app/data/repository/ReadStyleRepository.kt",
  56. "io/legado/app/data/repository/ReadStyleConfigStore.kt",
  57. "io/legado/app/data/repository/ReadBookStyleConfigRepository.kt",
  58. "io/legado/app/di/appModule.kt",
  59. )
  60. // R4.7:Config 的值字段已是 val,字段写入由编译器拦;剩下的唯一写入口是
  61. // ReadStyleConfigStore 的列表操作。它是 Koin 单例,谁 inject 谁就能绕过 gateway
  62. // 改配置且不触发 save/publishState——所以限定只有下面这几个文件能提到这个类型。
  63. val configStoreOwners = setOf(
  64. "io/legado/app/data/repository/ReadStyleConfigStore.kt",
  65. "io/legado/app/data/repository/ReadBookStyleConfigRepository.kt",
  66. "io/legado/app/help/config/ReadBookConfig.kt",
  67. "io/legado/app/di/appModule.kt",
  68. )
  69. val settingsUpdateDeclaration = Regex(
  70. """\b(?:class|interface|object|typealias)\s+[A-Za-z0-9_]*SettingsUpdate\b"""
  71. )
  72. val updateAllDeclaration = Regex("""\bfun\s+(?:<[^>\n]+>\s*)?updateAll\s*\(""")
  73. val injectedConfigFiles = setOf(
  74. "io/legado/app/help/config/AppConfig.kt",
  75. "io/legado/app/help/config/ReadBookConfig.kt",
  76. "io/legado/app/help/config/ThemePackageManager.kt",
  77. )
  78. kotlinFiles.forEach { file ->
  79. val text = file.readText()
  80. val relativePath = file.relativeTo(sourceRootDir).invariantSeparatorsPath
  81. val displayPath = "app/src/main/java/$relativePath"
  82. if ("prefDelegate" in text || "prefStateDelegate" in text ||
  83. "Snapshot.withMutableSnapshot" in text
  84. ) {
  85. violations += "$displayPath: 禁止 Snapshot 配置桥"
  86. }
  87. if ((relativePath.startsWith("io/legado/app/data/") ||
  88. relativePath.startsWith("io/legado/app/domain/")) &&
  89. forbiddenConfigImport.containsMatchIn(text)
  90. ) {
  91. violations += "$displayPath: data/domain 禁止导入全局 Config"
  92. }
  93. if (("@Composable" in text || "import androidx.compose" in text) &&
  94. forbiddenConfigImport.containsMatchIn(text)
  95. ) {
  96. violations += "$displayPath: Composable 禁止读取兼容 Config"
  97. }
  98. if (file.name.endsWith("Config.kt") &&
  99. ("mutableStateOf(" in text || "Snapshot.withMutableSnapshot" in text ||
  100. "import androidx.compose.runtime.State" in text ||
  101. "import androidx.compose.runtime.MutableState" in text)
  102. ) {
  103. violations += "$displayPath: 配置门面禁止持有 Compose State"
  104. }
  105. if (relativePath !=
  106. "io/legado/app/data/repository/ReadBookStyleConfigRepository.kt" &&
  107. (readBookConfigWrite.containsMatchIn(text) ||
  108. readBookConfigMutationCall.containsMatchIn(text) ||
  109. readBookConfigMemberImport.containsMatchIn(text) ||
  110. readBookConfigBareWrite.containsMatchIn(text))
  111. ) {
  112. violations += "$displayPath: ReadBookConfig 写入必须经过 ReadStyleGateway"
  113. }
  114. if (relativePath !in configStoreOwners && "ReadStyleConfigStore" in text) {
  115. violations += "$displayPath: 排版配置的写入口只对 ReadStyleGateway 的实现开放," +
  116. "不要注入 ReadStyleConfigStore"
  117. }
  118. if (relativePath !in styleRepositoryOwners && "ReadStyleRepository" in text) {
  119. violations += "$displayPath: readConfig.json 的读写只对 ReadStyleConfigStore 与 " +
  120. "ReadStyleGateway 的实现开放,不要注入 ReadStyleRepository"
  121. }
  122. if (relativePath in injectedConfigFiles && "GlobalContext" in text) {
  123. violations += "$displayPath: 配置所有者必须显式注入依赖,禁止 GlobalContext"
  124. }
  125. if (settingsUpdateDeclaration.containsMatchIn(text)) {
  126. violations += "$displayPath: 设置网关禁止重新引入 *SettingsUpdate 分发类型"
  127. }
  128. if (relativePath.startsWith("io/legado/app/domain/gateway/") &&
  129. file.name.endsWith("SettingsGateway.kt") &&
  130. updateAllDeclaration.containsMatchIn(text)
  131. ) {
  132. violations += "$displayPath: 设置网关批量修改必须使用单次 update { copy(...) }"
  133. }
  134. val preferenceCalls = preferenceCall.findAll(text).count()
  135. val allowedCalls = preferenceBaseline[relativePath] ?: 0
  136. if (preferenceCalls > allowedCalls) {
  137. violations += "$displayPath: 新增了 ${preferenceCalls - allowedCalls} 个旧偏好调用"
  138. }
  139. if (relativePath.startsWith("io/legado/app/ui/") &&
  140. file.name.contains("ViewModel")
  141. ) {
  142. val daoDependencies = daoImport.findAll(text).count() +
  143. appDbDaoAccess.findAll(text).count()
  144. val allowedDaoDependencies = daoInjectionBaseline[relativePath] ?: 0
  145. if (daoDependencies > allowedDaoDependencies) {
  146. violations += "$displayPath: ViewModel 新增了 ${daoDependencies - allowedDaoDependencies} 个 DAO 直连"
  147. } else if (daoDependencies < allowedDaoDependencies) {
  148. violations += "$displayPath: 已减少 DAO 直连,请将基线从 $allowedDaoDependencies 下调到 $daoDependencies"
  149. }
  150. }
  151. if (relativePath.startsWith("io/legado/app/ui/") &&
  152. !file.name.contains("ViewModel")
  153. ) {
  154. val daoDependencies = daoImport.findAll(text).count() +
  155. appDbDaoAccess.findAll(text).count()
  156. val allowedDaoDependencies = uiDaoAccessBaseline[relativePath] ?: 0
  157. if (daoDependencies > allowedDaoDependencies) {
  158. violations += "$displayPath: UI 层新增了 ${daoDependencies - allowedDaoDependencies} 个 DAO 直连"
  159. } else if (daoDependencies < allowedDaoDependencies) {
  160. violations += "$displayPath: 已减少 DAO 直连,请将基线从 $allowedDaoDependencies 下调到 $daoDependencies"
  161. }
  162. }
  163. }
  164. val sourcePaths = kotlinFiles.mapTo(hashSetOf()) {
  165. it.relativeTo(sourceRootDir).invariantSeparatorsPath
  166. }
  167. (daoInjectionBaseline.keys - sourcePaths).forEach { relativePath ->
  168. violations += "app/src/main/java/$relativePath: 文件已移除,请删除 DAO 直连基线"
  169. }
  170. (uiDaoAccessBaseline.keys - sourcePaths).forEach { relativePath ->
  171. violations += "app/src/main/java/$relativePath: 文件已移除,请删除 UI DAO 直连基线"
  172. }
  173. check(violations.isEmpty()) {
  174. violations.joinToString(prefix = "配置架构护栏失败:\n", separator = "\n")
  175. }
  176. }
  177. }
  178. buildscript {
  179. extra.apply {
  180. set("compile_sdk_version", 36)
  181. set("build_tool_version", "34.0.0")
  182. }
  183. }
  184. plugins {
  185. alias(libs.plugins.compose.compiler) apply false
  186. alias(libs.plugins.android.application) apply false
  187. alias(libs.plugins.android.library) apply false
  188. alias(libs.plugins.kotlin.parcelize) apply false
  189. alias(libs.plugins.kotlin.serialization) apply false
  190. alias(libs.plugins.ksp) apply false
  191. alias(libs.plugins.google.services) apply false
  192. alias(libs.plugins.room) apply false
  193. alias(libs.plugins.download) apply false
  194. alias(libs.plugins.android.test) apply false
  195. alias(libs.plugins.baselineprofile) apply false
  196. }
  197. tasks.register<Delete>("clean") {
  198. delete(rootProject.layout.buildDirectory)
  199. }
  200. val verifyConfigArchitecture = tasks.register<VerifyConfigArchitectureTask>(
  201. "verifyConfigArchitecture"
  202. ) {
  203. group = "verification"
  204. description = "禁止配置架构回退、UI 层(ViewModel 及其它)新增 DAO 直连和新增旧偏好调用"
  205. sourceRoot.set(layout.projectDirectory.dir("app/src/main/java"))
  206. legacyPreferenceCallBaseline.set(
  207. mapOf(
  208. "io/legado/app/App.kt" to 3,
  209. "io/legado/app/base/BaseActivity.kt" to 2,
  210. "io/legado/app/base/BaseService.kt" to 1,
  211. "io/legado/app/data/repository/CoverAlbumRepository.kt" to 4,
  212. "io/legado/app/data/repository/HighlightRuleRepository.kt" to 9,
  213. "io/legado/app/data/repository/HomeDashboardRepository.kt" to 3,
  214. "io/legado/app/data/repository/ReadRecordRepository.kt" to 1,
  215. "io/legado/app/data/repository/SettingsRepository.kt" to 7,
  216. "io/legado/app/help/config/LocalConfig.kt" to 3,
  217. "io/legado/app/help/config/ThemeConfigStore.kt" to 8,
  218. "io/legado/app/help/storage/Restore.kt" to 2,
  219. "io/legado/app/receiver/MediaButtonReceiver.kt" to 2,
  220. "io/legado/app/service/WebService.kt" to 2,
  221. "io/legado/app/ui/association/ImportReplaceRuleDialog.kt" to 1,
  222. "io/legado/app/ui/book/explore/ExploreShowViewModel.kt" to 2,
  223. "io/legado/app/ui/book/read/ReadBookViewModel.kt" to 2,
  224. "io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt" to 1,
  225. "io/legado/app/ui/book/search/SearchViewModel.kt" to 3,
  226. "io/legado/app/ui/config/CheckSourceConfig.kt" to 1,
  227. "io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt" to 1,
  228. "io/legado/app/ui/replace/ReplaceRuleViewModel.kt" to 2,
  229. "io/legado/app/utils/ContextExtensions.kt" to 12,
  230. "io/legado/app/web/socket/BookSearchWebSocket.kt" to 2,
  231. )
  232. )
  233. legacyDaoInjectionBaseline.set(
  234. mapOf(
  235. // R2.1 已清零:ReadBookViewModel 的书籍/目录读写全部经 BookRepository。
  236. // 保留 0 值条目让棘轮继续盯着这个文件——新增一处直连就报红。
  237. "io/legado/app/ui/book/read/ReadBookViewModel.kt" to 0,
  238. // 护栏缺席期间(MAD-3 未合并窗口)main 新增的直连,随合并冻结,清理归 Track A/F2
  239. "io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt" to 13,
  240. )
  241. )
  242. // 非 ViewModel 的 UI 层文件直连 DAO 的历史债,只冻结不修复;
  243. // 清理时逐条下调/删除。护栏会自动要求"减少了就下调基线",防止回退。
  244. legacyUiDaoAccessBaseline.set(
  245. mapOf(
  246. "io/legado/app/ui/association/AddToBookshelfDialog.kt" to 5,
  247. "io/legado/app/ui/association/ImportReplaceRuleDialog.kt" to 1,
  248. "io/legado/app/ui/association/ImportRssSourceDialog.kt" to 1,
  249. "io/legado/app/ui/book/read/ReadBookController.kt" to 3,
  250. // 护栏缺席期间 main 新增(整书页码估算),随合并冻结
  251. "io/legado/app/ui/book/read/pageestimate/ExactChapterPageCountStore.kt" to 3,
  252. "io/legado/app/ui/book/search/SearchScope.kt" to 4,
  253. "io/legado/app/ui/config/bookshelfConfig/BookshelfManageScreenConfig.kt" to 1,
  254. "io/legado/app/ui/main/MainNavGraph.kt" to 2,
  255. "io/legado/app/ui/rss/article/RssArticlesCompose.kt" to 1,
  256. "io/legado/app/ui/rss/read/RssJsExtensions.kt" to 8,
  257. "io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt" to 1,
  258. )
  259. )
  260. }
  261. subprojects {
  262. tasks.configureEach {
  263. if (name.startsWith("assemble") || name.startsWith("compile")) {
  264. dependsOn(verifyConfigArchitecture)
  265. }
  266. }
  267. }