From 1ce992adc17ec9ecfa12cc861a5313fd9c48990f Mon Sep 17 00:00:00 2001 From: devcrocod Date: Tue, 12 May 2026 00:29:43 +0200 Subject: [PATCH 1/3] Treat unknown directives and unclosed openers as plain text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseDirective now requires both a `` close on the same line, and only accepts the four known directive names (`IMPORT`, `FUN`, `FUNS`, `END`). Anything else — ``, ``, three-dash HTML comments, or an unclosed opener like `` into the value group. Adds an `unknownDirectives` integration fixture covering TODO/TOC/FIXME/ generic-comment lines, a 3-dash close, and an unclosed opener alongside a real FUN that still expands. --- .../unknownDirectives/build.gradle.kts | 19 +++++++++++ .../unknownDirectives/docs/expected/readme.md | 29 ++++++++++++++++ .../unknownDirectives/docs/in/readme.md | 24 +++++++++++++ .../unknownDirectives/samples/Example.kt | 7 ++++ .../unknownDirectives/settings.gradle.kts | 1 + .../korro/it/KorroIntegrationTest.kt | 10 ++++++ .../kotlin/io/github/devcrocod/korro/Korro.kt | 34 +++++++++++-------- 7 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 integration-tests/fixtures/unknownDirectives/build.gradle.kts create mode 100644 integration-tests/fixtures/unknownDirectives/docs/expected/readme.md create mode 100644 integration-tests/fixtures/unknownDirectives/docs/in/readme.md create mode 100644 integration-tests/fixtures/unknownDirectives/samples/Example.kt create mode 100644 integration-tests/fixtures/unknownDirectives/settings.gradle.kts diff --git a/integration-tests/fixtures/unknownDirectives/build.gradle.kts b/integration-tests/fixtures/unknownDirectives/build.gradle.kts new file mode 100644 index 0000000..8c7e1d6 --- /dev/null +++ b/integration-tests/fixtures/unknownDirectives/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir = layout.projectDirectory.dir("docs/in") + } + samples { + from(fileTree("samples")) + } +} diff --git a/integration-tests/fixtures/unknownDirectives/docs/expected/readme.md b/integration-tests/fixtures/unknownDirectives/docs/expected/readme.md new file mode 100644 index 0000000..4e8362a --- /dev/null +++ b/integration-tests/fixtures/unknownDirectives/docs/expected/readme.md @@ -0,0 +1,29 @@ +# Unknown directive-like lines + + + +The following directive-shaped lines are NOT Korro directives and must pass through: + + + + + + + +A three-dash close also passes through because the inner content is not a valid name: + + + +An unclosed opener — even one that names a real directive — must NOT throw; it stays as plain text: + + + +```kotlin +println("hello") +``` + + diff --git a/integration-tests/fixtures/unknownDirectives/docs/in/readme.md b/integration-tests/fixtures/unknownDirectives/docs/in/readme.md new file mode 100644 index 0000000..86f450d --- /dev/null +++ b/integration-tests/fixtures/unknownDirectives/docs/in/readme.md @@ -0,0 +1,24 @@ +# Unknown directive-like lines + + + +The following directive-shaped lines are NOT Korro directives and must pass through: + + + + + + + +A three-dash close also passes through because the inner content is not a valid name: + + + +An unclosed opener — even one that names a real directive — must NOT throw; it stays as plain text: + + + diff --git a/integration-tests/fixtures/unknownDirectives/samples/Example.kt b/integration-tests/fixtures/unknownDirectives/samples/Example.kt new file mode 100644 index 0000000..a862c78 --- /dev/null +++ b/integration-tests/fixtures/unknownDirectives/samples/Example.kt @@ -0,0 +1,7 @@ +package samples + +fun example() { + //SampleStart + println("hello") + //SampleEnd +} diff --git a/integration-tests/fixtures/unknownDirectives/settings.gradle.kts b/integration-tests/fixtures/unknownDirectives/settings.gradle.kts new file mode 100644 index 0000000..85b4d3e --- /dev/null +++ b/integration-tests/fixtures/unknownDirectives/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-unknownDirectives-fixture" diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index ea9da0a..9383c1b 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -65,6 +65,16 @@ class KorroIntegrationTest { ) } + @Test + fun unknownDirectivesPassThrough(@TempDir tempDir: Path) { + runFixture( + name = "unknownDirectives", + tempDir = tempDir, + generatedRelativePath = "build/korro/docs/readme.md", + expectedRelativePath = "unknownDirectives/docs/expected/readme.md", + ) + } + @Test fun strictModeFailsOnMissing(@TempDir tempDir: Path) { val fixture = loadFixture("strictErrors", tempDir) diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index 15fdbe0..6998f4a 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -7,6 +7,15 @@ const val FUN_DIRECTIVE = "FUN" const val FUNS_DIRECTIVE = "FUNS" const val END_DIRECTIVE = "END" +private val KORRO_DIRECTIVE_NAMES = setOf( + IMPORT_DIRECTIVE, + FUN_DIRECTIVE, + FUNS_DIRECTIVE, + END_DIRECTIVE, +) + +private val DIRECTIVE_INNER_REGEX = Regex("([_a-zA-Z.]+)(?:\\s+(.*))?") + /** * Marker syntax used to wrap a Korro directive on a single line. * @@ -23,12 +32,6 @@ enum class DirectiveSyntax(val start: String, val end: String) { val endSample: String get() = "$start$END_DIRECTIVE$end" - val regex: Regex = run { - val s = Regex.escape(start) - val e = Regex.escape(end) - Regex("$s\\s*([_a-zA-Z.]+)(?:\\s+(.+?(?=$e|)))?(?:\\s*($e))?\\s*") - } - companion object { fun forFile(file: File): DirectiveSyntax = when (file.extension.lowercase()) { "mdx" -> MDX @@ -228,10 +231,6 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { } } } - - else -> logger.warn( - "Unrecognized directive '${directive.name}' on a line starting with '${syntax.start}' in '$inputFile'" - ) } } } @@ -248,11 +247,16 @@ data class Directive( val value: String, ) +// Returns null for anything but a same-line, known-name directive — ``, +// ``, three-dash HTML comments, or unclosed openers all pass through as text. fun parseDirective(line: String, syntax: DirectiveSyntax = DirectiveSyntax.HTML): Directive? { val trimLine = line.trim() - if (!trimLine.startsWith(syntax.start)) return null - val match = syntax.regex.matchEntire(trimLine) ?: return null - val groups = match.groups.filterNotNull().toMutableList() - require(groups.last().value == syntax.end) { "Directive must end on the same line with '${syntax.end}'" } - return Directive(groups[1].value.trim(), groups.getOrNull(2)?.value?.trim() ?: "") + if (!trimLine.startsWith(syntax.start) || !trimLine.endsWith(syntax.end)) return null + val inner = trimLine + .substring(syntax.start.length, trimLine.length - syntax.end.length) + .trim() + val match = DIRECTIVE_INNER_REGEX.matchEntire(inner) ?: return null + val name = match.groupValues[1] + if (name !in KORRO_DIRECTIVE_NAMES) return null + return Directive(name, match.groupValues[2].trim()) } From d60a7a3644eec3595c3ea9d2949fb2ba7867a460 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Tue, 12 May 2026 01:02:02 +0200 Subject: [PATCH 2/3] Support output file resolution by fully qualified function name (FQN) and add integration fixture for samples outputs --- .../fixtures/samplesOutputs/build.gradle.kts | 20 +++++++++++ .../samplesOutputs/docs/expected/readme.md | 27 ++++++++++++++ .../fixtures/samplesOutputs/docs/in/readme.md | 13 +++++++ .../outputs/samples.fqnTargetFun | 2 ++ .../outputs/samples.shortNameFun | 2 ++ .../samplesOutputs/samples/Example.kt | 13 +++++++ .../samplesOutputs/settings.gradle.kts | 1 + .../korro/it/KorroIntegrationTest.kt | 10 ++++++ .../korro/analysis/SamplesTransformer.kt | 5 +-- .../kotlin/io/github/devcrocod/korro/Korro.kt | 35 ++++++++++++------- 10 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 integration-tests/fixtures/samplesOutputs/build.gradle.kts create mode 100644 integration-tests/fixtures/samplesOutputs/docs/expected/readme.md create mode 100644 integration-tests/fixtures/samplesOutputs/docs/in/readme.md create mode 100644 integration-tests/fixtures/samplesOutputs/outputs/samples.fqnTargetFun create mode 100644 integration-tests/fixtures/samplesOutputs/outputs/samples.shortNameFun create mode 100644 integration-tests/fixtures/samplesOutputs/samples/Example.kt create mode 100644 integration-tests/fixtures/samplesOutputs/settings.gradle.kts diff --git a/integration-tests/fixtures/samplesOutputs/build.gradle.kts b/integration-tests/fixtures/samplesOutputs/build.gradle.kts new file mode 100644 index 0000000..a3a0692 --- /dev/null +++ b/integration-tests/fixtures/samplesOutputs/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir = layout.projectDirectory.dir("docs/in") + } + samples { + from(fileTree("samples")) + outputs.from(fileTree("outputs")) + } +} diff --git a/integration-tests/fixtures/samplesOutputs/docs/expected/readme.md b/integration-tests/fixtures/samplesOutputs/docs/expected/readme.md new file mode 100644 index 0000000..d7dcd5c --- /dev/null +++ b/integration-tests/fixtures/samplesOutputs/docs/expected/readme.md @@ -0,0 +1,27 @@ +# Outputs + +Short name + IMPORT resolves the output file by FQN. + + + + + +```kotlin +println("from short name") +``` + + + + + +Explicit FQN also resolves the output file by FQN. + + + +```kotlin +println("from fqn") +``` + + + + diff --git a/integration-tests/fixtures/samplesOutputs/docs/in/readme.md b/integration-tests/fixtures/samplesOutputs/docs/in/readme.md new file mode 100644 index 0000000..85ecc8f --- /dev/null +++ b/integration-tests/fixtures/samplesOutputs/docs/in/readme.md @@ -0,0 +1,13 @@ +# Outputs + +Short name + IMPORT resolves the output file by FQN. + + + + + + +Explicit FQN also resolves the output file by FQN. + + + diff --git a/integration-tests/fixtures/samplesOutputs/outputs/samples.fqnTargetFun b/integration-tests/fixtures/samplesOutputs/outputs/samples.fqnTargetFun new file mode 100644 index 0000000..718f0ed --- /dev/null +++ b/integration-tests/fixtures/samplesOutputs/outputs/samples.fqnTargetFun @@ -0,0 +1,2 @@ + + diff --git a/integration-tests/fixtures/samplesOutputs/outputs/samples.shortNameFun b/integration-tests/fixtures/samplesOutputs/outputs/samples.shortNameFun new file mode 100644 index 0000000..659c9ae --- /dev/null +++ b/integration-tests/fixtures/samplesOutputs/outputs/samples.shortNameFun @@ -0,0 +1,2 @@ + + diff --git a/integration-tests/fixtures/samplesOutputs/samples/Example.kt b/integration-tests/fixtures/samplesOutputs/samples/Example.kt new file mode 100644 index 0000000..0afd1af --- /dev/null +++ b/integration-tests/fixtures/samplesOutputs/samples/Example.kt @@ -0,0 +1,13 @@ +package samples + +fun shortNameFun() { + //SampleStart + println("from short name") + //SampleEnd +} + +fun fqnTargetFun() { + //SampleStart + println("from fqn") + //SampleEnd +} diff --git a/integration-tests/fixtures/samplesOutputs/settings.gradle.kts b/integration-tests/fixtures/samplesOutputs/settings.gradle.kts new file mode 100644 index 0000000..e4921b8 --- /dev/null +++ b/integration-tests/fixtures/samplesOutputs/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-samples-outputs-fixture" diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index 9383c1b..a69087b 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -65,6 +65,16 @@ class KorroIntegrationTest { ) } + @Test + fun samplesOutputsFixture(@TempDir tempDir: Path) { + runFixture( + name = "samplesOutputs", + tempDir = tempDir, + generatedRelativePath = "build/korro/docs/readme.md", + expectedRelativePath = "samplesOutputs/docs/expected/readme.md", + ) + } + @Test fun unknownDirectivesPassThrough(@TempDir tempDir: Path) { runFixture( diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt index 343fa20..4b4ec5c 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt @@ -12,9 +12,10 @@ class SamplesTransformer( private val resolver = FqnResolver(session) private val extractor = SampleExtractor(rewriteAsserts) - operator fun invoke(name: String): String? { + operator fun invoke(name: String): RenderedSample? { val decl = resolver.resolve(name) ?: return null - return extractor.extract(decl) + val fqn = decl.fqName?.asString() ?: decl.name ?: return null + return RenderedSample(fqn, extractor.extract(decl)) } fun matchGlob(globPattern: String, imports: List): List { diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index 6998f4a..dd7ad6e 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -59,21 +59,30 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { fun renderFunBody(funName: String): List? { val functionNames = imports.map { it + funName } return functionNames.firstNotNullOfOrNull { name -> - var text = samplesTransformer(name) ?: groups.firstNotNullOfOrNull { group -> - group.patterns.mapNotNull { pattern -> - samplesTransformer(name + pattern.nameSuffix)?.let { sampleText -> - group.beforeSample?.let { pattern.processSubstitutions(it) } + sampleText + - group.afterSample?.let { pattern.processSubstitutions(it) } + val direct = samplesTransformer(name) + var text: String? = direct?.snippet + var resolvedFqn: String? = direct?.fqn + if (text == null) { + val grouped = groups.firstNotNullOfOrNull { group -> + var baseFqn: String? = null + val pieces = group.patterns.mapNotNull { pattern -> + samplesTransformer(name + pattern.nameSuffix)?.let { rs -> + if (baseFqn == null) baseFqn = rs.fqn.removeSuffix(pattern.nameSuffix) + group.beforeSample?.let { pattern.processSubstitutions(it) } + rs.snippet + + group.afterSample?.let { pattern.processSubstitutions(it) } + } } - }.takeIf { it.isNotEmpty() }?.joinToString( - separator = "\n", - prefix = group.beforeGroup ?: "", - postfix = group.afterGroup ?: "" - ) + pieces.takeIf { it.isNotEmpty() }?.joinToString( + separator = "\n", + prefix = group.beforeGroup ?: "", + postfix = group.afterGroup ?: "" + )?.let { it to baseFqn } + } + text = grouped?.first + resolvedFqn = grouped?.second } - val output = outputsMap[name] - if (text != null && output != null) { - text += output.readText() + if (text != null && resolvedFqn != null) { + outputsMap[resolvedFqn]?.let { text += it.readText() } } text?.split("\n")?.plus(endSample) } From 89c1ce469a81e7ce53cef18cfbe3e5ce7aee13bd Mon Sep 17 00:00:00 2001 From: devcrocod Date: Tue, 12 May 2026 02:22:03 +0200 Subject: [PATCH 3/3] Enforce strict scoping for `IMPORT` directives; add integration fixture to validate behavior --- CLAUDE.md | 4 +- .../fixtures/importScoping/build.gradle.kts | 19 +++++++++ .../fixtures/importScoping/docs/in/readme.md | 11 ++++++ .../importScoping/samples/api/Access.kt | 9 +++++ .../samples/concepts/StringApi.kt | 9 +++++ .../importScoping/settings.gradle.kts | 1 + .../korro/it/KorroIntegrationTest.kt | 33 ++++++++++++++++ .../devcrocod/korro/analysis/FqnResolver.kt | 15 ++++--- .../korro/analysis/SamplesTransformer.kt | 2 +- .../kotlin/io/github/devcrocod/korro/Korro.kt | 39 +++++++++++++------ 10 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 integration-tests/fixtures/importScoping/build.gradle.kts create mode 100644 integration-tests/fixtures/importScoping/docs/in/readme.md create mode 100644 integration-tests/fixtures/importScoping/samples/api/Access.kt create mode 100644 integration-tests/fixtures/importScoping/samples/concepts/StringApi.kt create mode 100644 integration-tests/fixtures/importScoping/settings.gradle.kts diff --git a/CLAUDE.md b/CLAUDE.md index e7b92c2..5a14267 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Analysis code is pulled in at task-execution time: `KorroPlugin` creates a detac Runs inside the worker's isolated classloader. Bundles the Kotlin Analysis API (K2 standalone), low-level FIR, and the IntelliJ platform. `com.intellij.*` and `org.jetbrains.kotlin.*` are **intentionally unrelocated** — the Analysis API is already uniquely namespaced, and relocating it breaks reflection lookups inside the platform. - One `StandaloneAnalysisAPISession` per `KorroWorkAction.execute()` call, disposed in a `try/finally`. Do **not** call `disposeGlobalStandaloneApplicationServices()` — it's a one-shot that invalidates all future Analysis API use in the JVM. `classLoaderIsolation` gives a fresh classloader per task run, so singletons are reloaded naturally. -- FQN resolution is two-tier: a fast-path short-name index over `KtNamedFunction`s for unambiguous bare names, then a dummy-KDoc `/** [fqn] */` fallback for qualified/ambiguous names. First-import-wins on ambiguity. +- FQN resolution is two-tier: `byFqn` (exact full-FQN map) is the primary path, used for every `IMPORT`-qualified candidate and for any `FUN`/`FUNS` value that already contains a `.`. `byShortName.singleOrNull()` is the bare-name fallback and fires only when no `IMPORT` directives are in effect — IMPORT is authoritative when present, so a bare `FUN` under `IMPORT` will *not* slide over to a same-named declaration in an unrelated package. ### Worker boundary @@ -62,6 +62,6 @@ These are contracts for every consumer's docs; breaking any of them silently bre - **Directives start at column 0 after `String.trim()`.** `parseDirective` returns `null` otherwise. - **Three dashes to open, two to close.** `` for `.md` (and anything non-`.mdx`); `{/*---NAME VALUE--*/}` for `.mdx`. Do not collapse the open marker to two dashes — that becomes a standard HTML/MDX comment, and consumer docs rely on the distinction. - **Directive name regex is `[_a-zA-Z.]+`.** Broadening it changes parsing for every consumer. -- **First `IMPORT` wins** on ambiguous short names (`firstNotNullOfOrNull` over the `imports` list). +- **First `IMPORT` wins** when several IMPORT prefixes resolve the same short name (`firstNotNullOfOrNull` over the `imports` list). The `imports` list holds *only* explicit IMPORT prefixes — do not re-introduce an implicit empty seed, since that lets the resolver's bare-name uniqueness fallback hijack IMPORT-scoped lookups. - **`KtNamedFunction`, `KtClassOrObject`, and `KtProperty`** are valid `FUN`/`FUNS` targets. Enum entries, type aliases, local declarations, and `.kts` scripts are not; resolving to a non-target produces a diagnostic, not a silent empty snippet. Class/object/property targets rely on `//SampleStart` / `//SampleEnd` markers inside their body for non-empty output. - **`behavior.ignoreMissing=false` is the strict-by-default contract.** Don't silently lower severity on unresolved references without an explicit opt-in. diff --git a/integration-tests/fixtures/importScoping/build.gradle.kts b/integration-tests/fixtures/importScoping/build.gradle.kts new file mode 100644 index 0000000..8c7e1d6 --- /dev/null +++ b/integration-tests/fixtures/importScoping/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("io.github.devcrocod.korro") +} + +repositories { + mavenLocal() + mavenCentral() + maven("https://cache-redirector.jetbrains.com/intellij-dependencies") +} + +korro { + docs { + from(fileTree("docs/in")) + baseDir = layout.projectDirectory.dir("docs/in") + } + samples { + from(fileTree("samples")) + } +} diff --git a/integration-tests/fixtures/importScoping/docs/in/readme.md b/integration-tests/fixtures/importScoping/docs/in/readme.md new file mode 100644 index 0000000..359a81e --- /dev/null +++ b/integration-tests/fixtures/importScoping/docs/in/readme.md @@ -0,0 +1,11 @@ +# Mismatched IMPORT + +The IMPORT below points at `samples.concepts.StringApi`, but `sharedName` is defined in +`samples.api.Access`. Korro must NOT silently pick up `Access.sharedName` just because its short +name is unique in the sample tree — that would make IMPORT a no-op whenever the bare name happens +to be unambiguous. Expected: strict-mode failure with a diagnostic pointing at the real location. + + + + + diff --git a/integration-tests/fixtures/importScoping/samples/api/Access.kt b/integration-tests/fixtures/importScoping/samples/api/Access.kt new file mode 100644 index 0000000..c6a85af --- /dev/null +++ b/integration-tests/fixtures/importScoping/samples/api/Access.kt @@ -0,0 +1,9 @@ +package samples.api + +class Access { + fun sharedName() { + //SampleStart + println("from Access.sharedName") + //SampleEnd + } +} diff --git a/integration-tests/fixtures/importScoping/samples/concepts/StringApi.kt b/integration-tests/fixtures/importScoping/samples/concepts/StringApi.kt new file mode 100644 index 0000000..cc13050 --- /dev/null +++ b/integration-tests/fixtures/importScoping/samples/concepts/StringApi.kt @@ -0,0 +1,9 @@ +package samples.concepts + +class StringApi { + fun describe() { + //SampleStart + println("StringApi has its own samples but no 'sharedName'") + //SampleEnd + } +} diff --git a/integration-tests/fixtures/importScoping/settings.gradle.kts b/integration-tests/fixtures/importScoping/settings.gradle.kts new file mode 100644 index 0000000..f8fcf4f --- /dev/null +++ b/integration-tests/fixtures/importScoping/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "korro-import-scoping-fixture" diff --git a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt index a69087b..beed5ee 100644 --- a/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/io/github/devcrocod/korro/it/KorroIntegrationTest.kt @@ -115,6 +115,39 @@ class KorroIntegrationTest { } } + @Test + fun strictModeFailsWhenFunIsOutOfImportScope(@TempDir tempDir: Path) { + val fixture = loadFixture("importScoping", tempDir) + + val runner = GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withArguments("korro", "--stacktrace") + .forwardOutput() + + configurePluginClasspath(runner) + System.getProperty("korro.testkit.gradleVersion") + ?.takeIf { it.isNotBlank() } + ?.let(runner::withGradleVersion) + + val result = runner.buildAndFail() + + assertEquals( + TaskOutcome.FAILED, + result.task(":korroGenerate")?.outcome, + "korroGenerate task should fail when the FUN target lives outside every IMPORT prefix", + ) + val output = result.output + assertTrue(output.contains("sharedName")) { + "Expected failure output to name the unresolved directive 'sharedName'; got:\n$output" + } + assertTrue(output.contains("samples.api.Access.sharedName")) { + "Expected diagnostic to point at the real FQN samples.api.Access.sharedName; got:\n$output" + } + assertTrue(output.contains("under current IMPORT")) { + "Expected diagnostic to flag the IMPORT scope mismatch; got:\n$output" + } + } + @Test fun ignoreMissingPreservesSource(@TempDir tempDir: Path) { runFixture( diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt index 5d63c89..e149564 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/FqnResolver.kt @@ -33,15 +33,14 @@ class FqnResolver(session: KorroAnalysisSession) { } /** - * FQNs of every declaration sharing [bareName] when the short name is ambiguous, - * or `null` when the name is unambiguous, qualified (contains a dot), or unknown. - * Used by callers to distinguish "not found" from "multiple matches" in diagnostics. + * Every FQN whose declaration shares [bareName] as its short name, in encounter order. + * Empty for qualified names (containing `.`) or when the short name is unknown. + * Callers use this to distinguish "not found", "ambiguous", and "found-but-out-of-import-scope" + * in diagnostics. */ - fun ambiguous(bareName: String): List? { - if ('.' in bareName) return null - val candidates = byShortName[bareName] ?: return null - if (candidates.size < 2) return null - return candidates.mapNotNull { it.fqName?.asString() } + fun matchesByShortName(bareName: String): List { + if ('.' in bareName) return emptyList() + return byShortName[bareName].orEmpty().mapNotNull { it.fqName?.asString() } } /** diff --git a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt index 4b4ec5c..cb29482 100644 --- a/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt +++ b/korro-analysis/src/main/kotlin/io/github/devcrocod/korro/analysis/SamplesTransformer.kt @@ -28,7 +28,7 @@ class SamplesTransformer( fun suggestions(bareName: String): List = resolver.suggestShortNames(bareName) - fun ambiguous(bareName: String): List? = resolver.ambiguous(bareName) + fun matchesByShortName(bareName: String): List = resolver.matchesByShortName(bareName) override fun close() { session.close() diff --git a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt index dd7ad6e..9dae659 100644 --- a/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt +++ b/korro-gradle-plugin/src/main/kotlin/io/github/devcrocod/korro/Korro.kt @@ -46,7 +46,8 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { val endSample = syntax.endSample val samplesTransformer = this.samplesTransformer val lines = ArrayList() - val imports = mutableListOf("") + // No empty-prefix seed — see renderFunBody for why. + val imports = mutableListOf() fun reportMissing(line: Int, message: String, hint: String? = null) { val sev = if (ignoreMissing) Severity.WARN else Severity.ERROR @@ -57,7 +58,13 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { } fun renderFunBody(funName: String): List? { - val functionNames = imports.map { it + funName } + // A bare FUN under IMPORT(s) must be tried *only* against IMPORT-qualified candidates. + // Adding an implicit empty prefix would let the resolver's byShortName uniqueness fallback + // pick a same-named function from an unrelated package, silently ignoring IMPORT. + val functionNames = when { + '.' in funName || imports.isEmpty() -> listOf(funName) + else -> imports.map { it + funName } + } return functionNames.firstNotNullOfOrNull { name -> val direct = samplesTransformer(name) var text: String? = direct?.snippet @@ -91,14 +98,19 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { fun processFun(funName: String, oldSampleLines: List, directiveLine: Int) { val newSamplesLines = renderFunBody(funName) if (newSamplesLines == null) { - val ambiguous = samplesTransformer.ambiguous(funName) - val (message, hint) = if (ambiguous != null) { - "Ambiguous FUN '$funName'" to - "candidates: ${ambiguous.joinToString(", ")}; qualify with IMPORT" - } else { - val suggestions = samplesTransformer.suggestions(funName).takeIf { it.isNotEmpty() } - ?.joinToString(prefix = "did you mean: ", separator = ", ") - "Cannot resolve FUN '$funName'" to suggestions + val shortNameMatches = samplesTransformer.matchesByShortName(funName) + val (message, hint) = when { + imports.isEmpty() && shortNameMatches.size >= 2 -> + "Ambiguous FUN '$funName'" to + "candidates: ${shortNameMatches.joinToString(", ")}; qualify with IMPORT" + imports.isNotEmpty() && shortNameMatches.isNotEmpty() -> + "Cannot resolve FUN '$funName' under current IMPORT(s)" to + "found at: ${shortNameMatches.joinToString(", ")} — add an IMPORT or qualify the FUN" + else -> { + val suggestions = samplesTransformer.suggestions(funName).takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "did you mean: ", separator = ", ") + "Cannot resolve FUN '$funName'" to suggestions + } } reportMissing(directiveLine, message, hint) lines.addAll(oldSampleLines) @@ -113,7 +125,12 @@ fun KorroContext.korro(inputFile: File, outputFile: File): Boolean { } fun renderFunsBody(glob: String): List? { - val matches = samplesTransformer.matchGlob(glob, imports) + // Same scoping rule as renderFunBody — IMPORT, when present, is authoritative. + val prefixes = when { + '.' in glob || imports.isEmpty() -> listOf("") + else -> imports + } + val matches = samplesTransformer.matchGlob(glob, prefixes) if (matches.isEmpty()) return null val trimmed = matches.map { it.copy(snippet = it.snippet.trim { ch -> ch == '\n' }) }