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
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ jobs:
# associates with dumps (#1171 merged a stale dump exactly that way; #1174 repaired it).
- name: api-compatibility
tasks: apiCheck
# Every NotImplementedError / TODO( in production code is either @NotImplemented
# (so it shows in the ops coverage matrix) or listed in config/stub-allowlist.txt.
- name: stub-markers
tasks: checkUnmarkedStubs

permissions:
contents: read
Expand Down
29 changes: 26 additions & 3 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,20 @@ on:
- '.github/workflows/docs.yml'
- 'build.gradle.kts'
- 'build-logic/**'
- 'skainet-lang/skainet-lang-core/**'
- 'skainet-lang/**'
- 'skainet-backends/skainet-backend-cpu/**'
- 'skainet-compile/skainet-compile-hlo/**'
- 'skainet-compile/skainet-compile-dag/**'
pull_request:
paths:
- 'docs/**'
- '.github/workflows/docs.yml'
- 'build.gradle.kts'
- 'build-logic/**'
- 'skainet-lang/skainet-lang-core/**'
- 'skainet-lang/**'
- 'skainet-backends/skainet-backend-cpu/**'
- 'skainet-compile/skainet-compile-hlo/**'
- 'skainet-compile/skainet-compile-dag/**'
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -81,8 +87,25 @@ jobs:
# bundle it; running both here means commit 6 is a pure
# workflow-step + Gradle-task-registration change with no
# Gradle re-run cost.
# The StableHLO / Backward matrix columns come from two jvmTest dumps
# (HloCoverageDumpTest, BackwardCoverageDumpTest); run just those first.
- name: Generate operator docs and Dokka
run: ./gradlew --no-daemon generateDocs dokkaGenerate
run: >-
./gradlew --no-daemon
:skainet-compile:skainet-compile-hlo:jvmTest --tests '*HloCoverageDumpTest'
:skainet-compile:skainet-compile-dag:jvmTest --tests '*BackwardCoverageDumpTest'
generateDocs dokkaGenerate

# The committed coverage matrix and operator pages must match the code:
# a new @NotImplemented, a new backend override or a new StableHLO
# converter has to land together with the regenerated page. The
# "Generated from … on <date>" stamp is ignored.
- name: Check generated docs are up to date
run: |
if ! git diff --exit-code -I 'Generated from' -- docs/modules/ROOT/pages/reference/; then
echo "::error::Generated operator docs are stale. Run the generateDocs command from build.gradle.kts (documentation block) and commit docs/modules/ROOT/pages/reference/."
exit 1
fi

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/documentation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ jobs:
with:
name: operator-documentation
path: |
docs/modules/operators/_generated_/**
docs/modules/ROOT/pages/reference/operators/generated/**
docs/modules/ROOT/pages/reference/ops-status-matrix.adoc
skainet-lang/skainet-lang-core/build/generated/ksp/metadata/commonMain/resources/operators.json
retention-days: 30

Expand Down
151 changes: 151 additions & 0 deletions build-logic/convention/src/main/kotlin/CheckUnmarkedStubsTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.*
import java.io.File

/**
* Fails the build when production Kotlin code throws `NotImplementedError` / calls `TODO(`
* without the gap being declared, so every missing op or layer shows up in the operator
* coverage matrix and has a tracking issue.
*
* A stub is declared when either
* - it sits inside a declaration annotated `@NotImplemented` / `@InProgress` (the KSP docs
* processor renders these as ❌ / 🚧), or
* - its file is listed in [allowlistFile] as `path|count|issue or reason`. Non-op gaps
* (loaders, platform actuals, DSL) use this; `count` is the exact number of unmarked stubs
* expected in the file, so adding one more fails until the entry is updated.
*
* Comment lines are ignored, so `// TODO:` notes are not stubs.
*/
@CacheableTask
abstract class CheckUnmarkedStubsTask : DefaultTask() {

@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sources: ConfigurableFileCollection

/** Repository root that report and allowlist paths are relative to. */
@get:Internal
abstract val rootDirectory: DirectoryProperty

@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val allowlistFile: RegularFileProperty

@get:OutputFile
abstract val reportFile: RegularFileProperty

private val stubCall = Regex("""\bNotImplementedError\s*\(|\bTODO\s*\(""")
private val mainSourceDir = Regex("""/src/[A-Za-z0-9]*[Mm]ain/""")
private val marker = Regex("""^\s*@(NotImplemented|InProgress)\b""")

@TaskAction
fun check() {
val root = rootDirectory.get().asFile
val allowlist = parseAllowlist(allowlistFile.get().asFile)
val unmarked = mutableMapOf<String, MutableList<Int>>()

sources.files
.filter { it.extension == "kt" }
.forEach { file ->
val rel = file.relativeTo(root).invariantSeparatorsPath
if (!mainSourceDir.containsMatchIn("/$rel")) return@forEach
val lines = file.readLines()
val covered = markedRanges(lines)
lines.forEachIndexed { index, line ->
val trimmed = line.trimStart()
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) return@forEachIndexed
// Mentions inside string literals (messages, regexes) are not calls.
if (!stubCall.containsMatchIn(stripStrings(line))) return@forEachIndexed
if (covered.any { index in it }) return@forEachIndexed
unmarked.getOrPut(rel) { mutableListOf() } += index + 1
}
}

val problems = mutableListOf<String>()
unmarked.toSortedMap().forEach { (path, hits) ->
val expected = allowlist[path]
when {
expected == null -> hits.forEach { problems += "$path:$it — unmarked stub" }
expected != hits.size -> problems += "$path — ${hits.size} unmarked stubs, allowlist expects $expected (lines ${hits.joinToString()})"
}
}
allowlist.keys.filter { it !in unmarked }.sorted().forEach { path ->
problems += "$path — allowlisted but has no unmarked stubs any more; remove the entry"
}

reportFile.get().asFile.apply { parentFile.mkdirs() }.writeText(
unmarked.toSortedMap().entries.joinToString("\n", postfix = "\n") { (path, hits) -> "$path ${hits.joinToString(",")}" },
)

if (problems.isNotEmpty()) {
throw GradleException(
buildString {
appendLine("Unmarked NotImplementedError / TODO( stubs in production code:")
problems.forEach { appendLine(" $it") }
appendLine()
appendLine("Mark a missing op or layer with @NotImplemented(reason = …, issue = …) so it shows in")
appendLine("docs/modules/ROOT/pages/reference/ops-status-matrix.adoc, or add/update the file's entry in")
appendLine("${allowlistFile.get().asFile.relativeTo(root).invariantSeparatorsPath} (path|count|issue).")
},
)
}
logger.lifecycle("✅ No unmarked stubs (${unmarked.values.sumOf { it.size }} allowlisted in ${unmarked.size} files)")
}

/** Blank out the contents of `"…"` literals on one line (escape-aware). */
private fun stripStrings(line: String): String = buildString {
var inString = false
var i = 0
while (i < line.length) {
val ch = line[i]
when {
inString && ch == '\\' -> i++
ch == '"' -> { inString = !inString; append(ch) }
!inString -> append(ch)
}
i++
}
}

private fun parseAllowlist(file: File): Map<String, Int> =
file.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.associate { line ->
val parts = line.split("|").map { it.trim() }
if (parts.size < 3 || parts[2].isEmpty()) {
throw GradleException("Malformed allowlist line (want path|count|issue or reason): $line")
}
parts[0] to (parts[1].toIntOrNull() ?: throw GradleException("Bad count in allowlist line: $line"))
}

/**
* Line ranges (0-based, inclusive) of declarations carrying a status marker: from the
* annotation to the matching closing brace, or to the `= throw` / `= TODO(` line of an
* expression-bodied function.
*/
private fun markedRanges(lines: List<String>): List<IntRange> {
val ranges = mutableListOf<IntRange>()
lines.forEachIndexed { start, line ->
if (!marker.containsMatchIn(line)) return@forEachIndexed
var depth = 0
var opened = false
var end = lines.lastIndex
loop@ for (i in start until lines.size) {
val text = lines[i]
if (!opened && (text.contains("= throw") || text.contains("= TODO("))) { end = i; break@loop }
for (ch in text) {
if (ch == '{') { depth++; opened = true }
if (ch == '}') depth--
if (opened && depth == 0) { end = i; break@loop }
}
}
ranges += start..end
}
return ranges
}
}
18 changes: 18 additions & 0 deletions build-logic/convention/src/main/kotlin/DocumentationExtension.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import models.DocumentationFormat
import org.gradle.api.Project
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
Expand All @@ -15,6 +16,23 @@ open class DocumentationExtension(project: Project) {
val generateIndex: Property<Boolean> = project.objects.property(Boolean::class.java)
.convention(true)

/**
* operators.json files emitted by running the docs KSP processor in backend modules
* (e.g. skainet-backend-cpu). Only their `statusByBackend` maps and notes are merged into
* [inputFile]; missing files are skipped.
*/
val backendStatusFiles: ConfigurableFileCollection = project.objects.fileCollection()

/** hlo-coverage.json (op names the StableHLO converter lowers), from HloCoverageDumpTest. */
val hloCoverageFile: RegularFileProperty = project.objects.fileProperty()

/** backward-coverage.json (op name → implemented / stubbed / missing), from BackwardCoverageDumpTest. */
val backwardCoverageFile: RegularFileProperty = project.objects.fileProperty()

/** Base URL issue references like `#123` resolve against in rendered matrix cells. */
val issueBaseUrl: Property<String> = project.objects.property(String::class.java)
.convention("https://github.com/SKaiNET-developers/SKaiNET/issues/")

/** kernel-support.json emitted by KernelSupportMatrixTest (registry introspection). */
val kernelInputFile: RegularFileProperty = project.objects.fileProperty()

Expand Down
26 changes: 26 additions & 0 deletions build-logic/convention/src/main/kotlin/DocumentationPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class DocumentationPlugin : Plugin<Project> {
task.format.set(extension.format)
task.includeBackendStatus.set(extension.includeBackendStatus)
task.generateIndex.set(extension.generateIndex)
task.backendStatusFiles.from(extension.backendStatusFiles)
task.hloCoverageFile.set(extension.hloCoverageFile)
task.backwardCoverageFile.set(extension.backwardCoverageFile)
task.issueBaseUrl.set(extension.issueBaseUrl)
}
})

Expand All @@ -31,6 +35,28 @@ class DocumentationPlugin : Plugin<Project> {
}
})

// Guard: every NotImplementedError / TODO( in production code is either inside a
// @NotImplemented / @InProgress declaration (and so in the coverage matrix) or allowlisted.
project.tasks.register("checkUnmarkedStubs", CheckUnmarkedStubsTask::class.java, object : Action<CheckUnmarkedStubsTask> {
override fun execute(task: CheckUnmarkedStubsTask) {
task.group = "verification"
task.description = "Fail on NotImplementedError / TODO( stubs that are neither @NotImplemented nor allowlisted"
val root = project.rootProject.layout.projectDirectory
// Per-project src/ trees (not the repo root) so the scan never overlaps other
// tasks' outputs such as the generated docs pages.
project.rootProject.allprojects.forEach { p ->
task.sources.from(p.fileTree("src") { include("**/*.kt") })
}
task.sources.from(root.dir("build-logic").asFileTree.matching {
include("**/src/**/*.kt")
exclude("**/build/**", "**/.gradle/**")
})
task.rootDirectory.set(root)
task.allowlistFile.set(root.file("config/stub-allowlist.txt"))
task.reportFile.set(project.layout.buildDirectory.file("reports/stubs/unmarked-stubs.txt"))
}
})

// Register schema validation task in plugin (migrated from skainet-lang-export-ops)
val validateTaskProvider = project.tasks.register("validateOperatorSchema", SchemaValidationTask::class.java, object : Action<SchemaValidationTask> {
override fun execute(task: SchemaValidationTask) {
Expand Down
Loading
Loading