Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
root = true

[*.{kt,kts}]
ij_kotlin_code_style = intellij_idea
max_line_length = 140
ij_kotlin_name_count_to_use_star_import = 2147483647
ij_kotlin_name_count_to_use_star_import_with_matching_name = 2147483647
ktlint_standard_trailing-comma-on-call-site = enabled
ktlint_standard_trailing-comma-on-declaration-site = enabled
ktlint_function_naming_ignore_when_annotated_with = Composable, ObjCName

# Comment mechanics: comments wrap at max_line_length, `//` over `/* */` for
# single-line remarks.
ktlint_standard_comment-wrapping = enabled
ktlint_standard_no-single-line-block-comment = enabled
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ binary-compatibility-validator. The golden dumps are committed at
- `./gradlew apiCheck` — fails if the public API drifts from the committed dumps. Runs on every PR (`.github/workflows/api-check.yml`, macOS so the iOS klib targets build).
- `./gradlew apiDump` — regenerate the dumps after an *intentional* API change, then commit the updated `api/*.api` files in the same PR.

## Comments policy

Code comments are for *why*, never *what*. Default to **no comment**; write code that explains itself.

- DO NOT add comments narrating the obvious (`// increment counter`, `// call the API`, section-divider banners).
- DO NOT add "AI notes": comments narrating the change you just made (`// added null check`, `// new implementation`, `// refactored for clarity`), restating the function name, or addressing the reviewer.
- DO NOT leave TODO/FIXME without an issue reference (`// TODO(#NN):`).
- Comments ARE justified for: a non-obvious invariant or trade-off, a workaround with its bug/issue link, a pointer to the primary source (spec/ADR), or a public-API contract the signature can't express — prefer KDoc for that.
- If a comment only survives because the code is hard to read, fix the code instead.
- NO multi-line comments: a `//` run of two or more adjacent lines, or a `/* ... */` block spanning lines, is flagged by ktlint (`sharingan-comments:no-multi-line-comment`). If it doesn't fit on one line, shorten it — or if it is documentation, write KDoc (`/** ... */`), which is exempt.
- ktlint enforces the mechanics (wrapping, `//` over `/* */`, no multi-line comments); this policy is enforced by review.

## Agent skills

### Issue tracker
Expand Down
26 changes: 26 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.sqldelight) apply false
alias(libs.plugins.kotlinSerialization) apply false
alias(libs.plugins.ktlint) apply false
// Applied to the root project: BCV injects apiDump/apiCheck into every
// subproject and guards the committed public-API dumps (issue #11).
alias(libs.plugins.binaryCompatibilityValidator)
Expand All @@ -18,6 +19,9 @@ apiValidation {
// The sample app is not a published library — nothing to protect.
ignoredProjects += "composeApp"

// Custom ktlint ruleset (AGENTS.md comments policy) — internal tooling.
ignoredProjects += "ktlint-rules"

// Flight-recorder persistence (issue #27/#49) is SQLDelight-generated code
// that the compiler emits `public` (SQLDelight offers no internal-generation
// option). It is a debug-only internal seam, not consumer API, so it is
Expand Down Expand Up @@ -206,3 +210,25 @@ tasks.register("checkApiParity") {

// Make the standard `check` lifecycle run the parity gate locally when present.
tasks.matching { it.name == "check" }.configureEach { dependsOn("checkApiParity") }

// ---------------------------------------------------------------------------
// Custom ktlint ruleset (issue tracker: AGENTS.md "Comments policy").
// Every project that applies the ktlint plugin also loads the local
// `:ktlint-rules` ruleset, so `ktlintCheck` enforces the comments policy in
// addition to the standard style rules. The ruleset jar ships no runtime
// deps; ktlint provides its own classes at load time.
// ---------------------------------------------------------------------------
subprojects {
plugins.withId("org.jlleitschuh.gradle.ktlint") {
// Generated code (SQLDelight etc.) lives under build/generated and
// does not follow our style — never lint it.
configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {
filter {
exclude { it.file.invariantSeparatorsPath.contains("/build/generated/") }
}
}
dependencies {
add("ktlintRuleset", rootProject.project(":ktlint-rules"))
}
}
}
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ agp = "8.13.2"
composeMultiplatform = "1.11.1"
vanniktechMavenPublish = "0.36.0"
binaryCompatibilityValidator = "0.18.1"
ktlintGradle = "14.2.0"
ktor = "3.5.0"
kotlinx-coroutines = "1.11.0"
sqldelight = "2.0.2"
Expand Down Expand Up @@ -48,3 +49,5 @@ mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechM
binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidator" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" }
29 changes: 29 additions & 0 deletions ktlint-rules/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
// No version: the Kotlin plugins are already on the root classpath
// (root applies kotlinMultiplatform `apply false` for the whole build).
kotlin("jvm")
}

// Internal tooling — the custom ktlint ruleset enforcing the AGENTS.md
// comments policy. Not published, not API-guarded, and deliberately not
// ktlint-checked itself (it would depend on itself via ktlintRuleset).
dependencies {
// Must match the ktlint version resolved by org.jlleitschuh.gradle.ktlint
// (14.2.0 defaults to 1.5.0). compileOnly: the ktlint runtime provides
// these classes when the ruleset jar is loaded.
compileOnly("com.pinterest.ktlint:ktlint-rule-engine-core:1.5.0")
compileOnly("com.pinterest.ktlint:ktlint-cli-ruleset-core:1.5.0")
}

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
170 changes: 170 additions & 0 deletions ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package dev.sharingan.ktlint

import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3
import com.pinterest.ktlint.rule.engine.core.api.ElementType
import com.pinterest.ktlint.rule.engine.core.api.Rule
import com.pinterest.ktlint.rule.engine.core.api.RuleId
import com.pinterest.ktlint.rule.engine.core.api.RuleProvider
import com.pinterest.ktlint.rule.engine.core.api.RuleSetId
import org.jetbrains.kotlin.com.intellij.lang.ASTNode
import org.jetbrains.kotlin.com.intellij.psi.PsiComment

// ponytail: heuristics — these rules catch the common AI narration patterns
// and bare TODOs; they cannot judge genuine usefulness. If a false positive
// appears in legit code, refine the word list or use a `// ktlint-disable`.
const val SHARINGAN_RULE_SET_ID = "sharingan-comments"

/**
* `TODO` / `FIXME` comments must reference an issue: `TODO(#123): ...`.
* Enforces the AGENTS.md comments policy — an unowned TODO is a promise
* nobody will keep.
*/
class TodoWithoutIssueRule :
Rule(
RuleId("$SHARINGAN_RULE_SET_ID:todo-without-issue"),
Rule.About(
maintainer = "Sharingan maintainers",
repositoryUrl = "https://github.com/mibrahimdev/Sharingan",
issueTrackerUrl = "https://github.com/mibrahimdev/Sharingan/issues",
),
) {
private val unownedTodo = Regex("\\b(TODO|FIXME)\\b(?!\\s*\\(\\s*#\\d+\\s*\\))")

override fun beforeVisitChildNodes(
node: ASTNode,
autoCorrect: Boolean,
emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit,
) {
if (node.elementType != ElementType.EOL_COMMENT && node.elementType != ElementType.BLOCK_COMMENT) return
val text = (node.psi as? PsiComment)?.text ?: return
if (text.contains("ktlint-disable") || text.contains("ktlint-enable")) return
if (unownedTodo.containsMatchIn(text)) {
emit(
node.startOffset,
"TODO/FIXME must reference an issue: 'TODO(#123): ...' (AGENTS.md comments policy)",
false,
)
}
}
}

/**
* Flags narration comments — the AI-generated "what I just did" notes:
* `// added null check`, `// removed unused import`, `// refactored ...`.
* Per the AGENTS.md comments policy, comments explain *why*, never
* narrate *what* or the change history (git log does that).
*/
class NoNarrationCommentRule :
Rule(
RuleId("$SHARINGAN_RULE_SET_ID:no-narration-comment"),
Rule.About(
maintainer = "Sharingan maintainers",
repositoryUrl = "https://github.com/mibrahimdev/Sharingan",
issueTrackerUrl = "https://github.com/mibrahimdev/Sharingan/issues",
),
) {
override fun beforeVisitChildNodes(
node: ASTNode,
autoCorrect: Boolean,
emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit,
) {
if (node.elementType != ElementType.EOL_COMMENT && node.elementType != ElementType.BLOCK_COMMENT) return
val comment = node.psi as? PsiComment ?: return
val text = comment.text
if (text.contains("ktlint-disable") || text.contains("ktlint-enable")) return
val body = text
.removePrefix("//")
.removePrefix("/*")
.trim()
if (NARRATION_START.containsMatchIn(body)) {
emit(
node.startOffset,
"Narration comment: comments explain *why*, not what was just changed " +
"(AGENTS.md comments policy). Delete it or state the invariant/reason.",
false,
)
}
}

private companion object {
// Past-tense change verbs + classic AI note openers. Deliberately
// conservative: present-tense instructions (`// add the item ...`)
// are often legitimate and are not flagged.
val NARRATION_START = Regex(
"^(?:added|removed|fixed|updated|changed|refactored|renamed|moved|" +
"implemented|extracted|replaced|deprecated|bumped|adjusted|modified|" +
"addressed|cleaned(?:\\s+up)?|new\\b|now\\b)\\b",
RegexOption.IGNORE_CASE,
)
}
}

/**
* Flags multi-line comments:
* - a run of consecutive `//` lines (comment continuation), and
* - a `/* ... */` block comment spanning more than one line.
*
* Per the AGENTS.md comments policy, if it doesn't fit on one line it is
* either not worth saying or it is documentation — and documentation is
* KDoc (`/** ... */`), which this rule leaves untouched.
*/
class NoMultiLineCommentRule :
Rule(
RuleId("$SHARINGAN_RULE_SET_ID:no-multi-line-comment"),
Rule.About(
maintainer = "Sharingan maintainers",
repositoryUrl = "https://github.com/mibrahimdev/Sharingan",
issueTrackerUrl = "https://github.com/mibrahimdev/Sharingan/issues",
),
) {
override fun beforeVisitChildNodes(
node: ASTNode,
autoCorrect: Boolean,
emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit,
) {
when (node.elementType) {
ElementType.EOL_COMMENT -> if (isContinuation(node)) {
emit(
node.startOffset,
"Multi-line comment: keep it to one line, or use KDoc if it is documentation " +
"(AGENTS.md comments policy)",
false,
)
}
ElementType.BLOCK_COMMENT -> if (node.text.contains('\n')) {
emit(
node.startOffset,
"Multi-line block comment: keep it to one line, or use KDoc if it is documentation " +
"(AGENTS.md comments policy)",
false,
)
}
}
}

/**
* True when the next non-whitespace sibling is another `//` comment on the
* immediately following line. The whitespace between them must hold exactly
* one newline: a blank line or code in between breaks the "run".
*/
private fun isContinuation(node: ASTNode): Boolean {
var next = node.treeNext
while (next != null && next.elementType == ElementType.WHITE_SPACE) next = next.treeNext
if (next == null || next.elementType != ElementType.EOL_COMMENT) return false
if (next.treePrev.elementType != ElementType.WHITE_SPACE) return false
return next.treePrev.text.count { it == '\n' } == 1
}
}

class SharinganRuleSetProvider :
RuleSetProviderV3(RuleSetId(SHARINGAN_RULE_SET_ID)) {
// getRuleProviders is deprecated in ktlint's API but remains the abstract
// member in 1.5.x — there is no replacement to migrate to on this version.
@Suppress("OVERRIDE_DEPRECATION")
override fun getRuleProviders(): Set<RuleProvider> =
setOf(
RuleProvider { TodoWithoutIssueRule() },
RuleProvider { NoNarrationCommentRule() },
RuleProvider { NoMultiLineCommentRule() },
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dev.sharingan.ktlint.SharinganRuleSetProvider
25 changes: 16 additions & 9 deletions sample/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.ktlint)
}

kotlin {
Expand All @@ -31,12 +32,10 @@ kotlin {
implementation(compose.material3)
implementation(compose.ui)
implementation(libs.ktor.client.core)
// Demo traffic is served by MockEngine so the sample works offline
// and mirrors the design's deterministic IoT data.
// Demo traffic via MockEngine so the sample works offline with deterministic IoT data.
implementation(libs.ktor.client.mock)

// KMP no-op swap pattern: `-Psharingan.noop` builds the sample
// against the no-op artifact, proving API parity and zero UI payload.
// `-Psharingan.noop` builds against the no-op twin: proves API parity, zero UI payload.
if (providers.gradleProperty("sharingan.noop").isPresent) {
implementation(project(":sharingan-noop"))
} else {
Expand All @@ -46,8 +45,7 @@ kotlin {
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
}
// Capture-notification E2E (needs the real app + zero-setup init):
// ./gradlew :sample:composeApp:connectedDebugAndroidTest
// Capture-notification E2E: ./gradlew :sample:composeApp:connectedDebugAndroidTest
androidInstrumentedTest.dependencies {
implementation(libs.junit)
implementation(libs.androidx.test.runner)
Expand All @@ -59,12 +57,21 @@ kotlin {

android {
namespace = "dev.sharingan.sample"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()

defaultConfig {
applicationId = "dev.sharingan.sample"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
targetSdk =
libs.versions.android.targetSdk
.get()
.toInt()
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Expand Down
Loading
Loading