feature-option
Guides step-by-step through all 11 touchpoints for adding a new @Fake annotation option (like mutability or callHistory). Covers annotation enum, CLI processor, Gradle plugin, FIR metadata, FIR transl
SKILL.md.claude/skills/feature-option/SKILL.md
GitHubFeature Option Guide
Adds a new option to the @Fake annotation, following the confirmed pattern from mutability and callHistory.
Instructions
1. Gather Option Details
Use AskUserQuestion to collect:
- Option name (e.g.,
threadSafety) - Enum values — always
DEFAULTplus two semantically opposite values (e.g.,ENABLED/DISABLED) - Plugin-level default — the boolean value when annotation says
DEFAULT(e.g.,trueorfalse) - What it controls — which generators are affected
2. Walk Through All 11 Layers
Guide the implementation in this exact order. Each layer depends on the previous.
Layers 1-2 define the user-facing API that developers interact with — the enum they import and the annotation parameter they set.
Layer 1 — Annotation Enum (new file)
File: annotations/src/commonMain/kotlin/com/rsicarelli/fakt/{OptionName}Mode.kt
public enum class {OptionName}Mode {
DEFAULT, // Follow plugin-level setting
{VALUE_A}, // Explicit enable
{VALUE_B}, // Explicit disable
}
Pattern: MutabilityMode(DEFAULT, IMMUTABLE, MUTABLE), CallHistoryMode(DEFAULT, ENABLED, DISABLED).
Layer 2 — @Fake Annotation Parameter
File: annotations/src/commonMain/kotlin/com/rsicarelli/fakt/Fake.kt
Add parameter with DEFAULT default:
public annotation class Fake(
val callHistory: CallHistoryMode = CallHistoryMode.DEFAULT,
val mutability: MutabilityMode = MutabilityMode.DEFAULT,
val {optionName}: {OptionName}Mode = {OptionName}Mode.DEFAULT, // NEW
)
Naming note: Real field names are descriptive and may not follow the template literally. E.g., mutability uses
enableMutableFakes(notenableMutability) andgenerateMutableBehaviors(notgenerateMutability). Choose names that best describe the feature's effect.
Layers 3-4 wire the option through the Gradle build system to the compiler — this is how users set project-wide defaults in
build.gradle.kts.
Layer 3 — Gradle Plugin Extension
File: gradle-plugin/src/main/kotlin/com/rsicarelli/fakt/gradle/FaktPluginExtension.kt
Add Property<Boolean> with convention default:
public val enable{OptionName}: Property<Boolean> =
objects.property(Boolean::class.java).convention({defaultValue})
Layer 4 — Gradle Subplugin (passes option to compiler)
File: gradle-plugin/src/main/kotlin/com/rsicarelli/fakt/gradle/FaktGradleSubplugin.kt
In applyToCompilation, add SubpluginOption:
add(SubpluginOption(key = "enable{OptionName}", value = extension.enable{OptionName}.get().toString()))
Layers 5-6 register the option with compiler plugin infrastructure — the CLI processor receives the value from Gradle, and FaktOptions makes it available throughout the compiler.
Layer 5 — Compiler CLI Processor
File: compiler/src/main/kotlin/com/rsicarelli/fakt/compiler/core/config/FaktCommandLineProcessor.kt
Three additions in companion object + one in processOption:
// Companion:
val ENABLE_{OPTION}_KEY = CompilerConfigurationKey<Boolean>("fakt.enable{OptionName}")
val ENABLE_{OPTION}_OPTION = CliOption(
optionName = "enable{OptionName}",
valueDescription = "true|false",
description = "Enable {description} by default (default: {defaultValue})",
required = false,
)
// Add to pluginOptions list:
ENABLE_{OPTION}_OPTION
// In processOption when-branch:
"enable{OptionName}" -> configuration.put(ENABLE_{OPTION}_KEY, value.toBoolean())
Layer 6 — FaktOptions Data Class
File: compiler/src/main/kotlin/com/rsicarelli/fakt/compiler/core/config/FaktOptions.kt
Add field + read in load():
data class FaktOptions(
// ...existing...
val enable{OptionName}Default: Boolean = {defaultValue},
)
// In load():
val enable{OptionName} = configuration.get(FaktCommandLineProcessor.ENABLE_{OPTION}_KEY) ?: {defaultValue}
return FaktOptions(/* ...existing... */, enable{OptionName}Default = enable{OptionName})
Layers 7-8 extract the annotation value at compile time during the FIR phase — mirror enums avoid a runtime dependency on the annotations module, and checkers read the actual
@Fakeparameter value from source code.
Layer 7 — FIR Mirror Enum + Validated Metadata
File: codegen-runtime/src/main/kotlin/com/rsicarelli/fakt/compiler/fir/metadata/FirFakeMetadata.kt
// Mirror enum (avoids runtime dep on annotations):
enum class Fir{OptionName}Mode { DEFAULT, {VALUE_A}, {VALUE_B} }
// Add to BOTH ValidatedFakeInterface and ValidatedFakeClass:
val {optionName}Mode: Fir{OptionName}Mode = Fir{OptionName}Mode.DEFAULT,
Layer 8 — FIR Checkers (annotation extraction)
Files: compiler/.../fir/checkers/FakeInterfaceChecker.kt + FakeClassChecker.kt
Add private extraction method in both:
private fun extract{OptionName}Mode(declaration: FirClass, session: FirSession): Fir{OptionName}Mode {
val fakeAnnotation = declaration.annotations.find { annotation ->
annotation.toAnnotationClassIdSafe(session) == FAKE_ANNOTATION_CLASS_ID
} ?: return Fir{OptionName}Mode.DEFAULT
val arg = fakeAnnotation.argumentMapping.mapping.entries
.find { it.key.asString() == "{optionName}" }?.value
return when (arg) {
is FirPropertyAccessExpression -> {
val enumEntry = arg.calleeReference.toResolvedEnumEntrySymbol()
when (enumEntry?.name?.asString()) {
"{VALUE_A}" -> Fir{OptionName}Mode.{VALUE_A}
"{VALUE_B}" -> Fir{OptionName}Mode.{VALUE_B}
else -> Fir{OptionName}Mode.DEFAULT
}
}
else -> Fir{OptionName}Mode.DEFAULT
}
}
Call in analyzeMetadata() and pass to ValidatedFakeInterface/ValidatedFakeClass constructor.
Layers 9-10 resolve the option and carry the result to code generation. New generation behaviour goes on the FIR path (see CLAUDE.md); the resolver implements the three-tier resolution (annotation → plugin → default) and the shared
FakeDeclarationmodel carries the final boolean that generators consume.
Layer 9 — Resolver + FIR Translator
Files: codegen-runtime/.../compiler/fir/metadata/FirFakeMetadata.kt + compiler/.../fir/generation/FirToFakeDeclarationTranslator.kt + compiler/.../fir/generation/FirFakeEmitter.kt
a) Resolver next to the mirror enum (pattern: resolveCallHistory, resolveMutability):
fun Fir{OptionName}Mode.resolve{OptionName}(pluginDefault: Boolean): Boolean =
when (this) {
Fir{OptionName}Mode.{VALUE_A} -> true
Fir{OptionName}Mode.{VALUE_B} -> false
Fir{OptionName}Mode.DEFAULT -> pluginDefault
}
b) In both toFakeInterface() and toFakeClass(), add an enable{OptionName}Default: Boolean parameter and set generate{OptionName} = {optionName}Mode.resolve{OptionName}(enable{OptionName}Default).
c) In both FirFakeEmitter.emit() overloads, pass sharedContext.options.enable{OptionName}Default.
Layer 10 — Shared Model (final boolean)
File: codegen-runtime/.../codegen/analysis/FakeDeclaration.kt
Add the resolved boolean to the sealed interface and override it in both Interface and Class:
val generate{OptionName}: Boolean
The legacy compiler/.../ir/analysis/IrToFakeDeclarationTranslator.kt builds the same model, so it needs the argument to compile until #150 removes it; add no other IR-side logic.
Layer 11 is where the option actually affects generated code — generators read the resolved boolean and conditionally emit different Kotlin source.
Layer 11 — Code Generators (consume the boolean)
Modify generators based on what the option controls:
ImplementationGenerator.kt— passdecl.generate{OptionName}togenerateCompleteFake()ConfigurationDslGenerator.kt— conditionally generate DSL elementsFakeGenerator.kt/ extensions — conditional code generation
3. Validate Implementation
Checklist:
- Enum file created in
annotations/withDEFAULT+ two values -
@Fakeannotation has new parameter withDEFAULTdefault - Gradle extension has
Property<Boolean>with convention - Subplugin passes option via
SubpluginOption - CLI processor has KEY, OPTION, pluginOptions entry, processOption branch
-
FaktOptionshas field +load()reads it - FIR mirror enum + fields in both
ValidatedFakeInterfaceandValidatedFakeClass - Both checkers have
extract{OptionName}Mode()+ call inanalyzeMetadata() -
resolve{OptionName}()next to the mirror enum inFirFakeMetadata.kt -
toFakeInterface()andtoFakeClass()set the resolved boolean; bothFirFakeEmitter.emit()overloads pass the plugin default -
FakeDeclaration(and itsInterface/Class) hasgenerate{OptionName}: Boolean - Generators consume the boolean
-
make publish-local && make test-samplepasses -
make formatapplied
4. Three-Tier Resolution Pattern
Every option follows this resolution:
Annotation value (per-interface)
↓ if DEFAULT
Plugin setting (build.gradle.kts)
↓ if not set
Hardcoded default (in FaktOptions)
This allows: global default via Gradle, per-interface override via annotation, sensible fallback.
Related Skills
sample-scaffolder— add a sample that demonstrates the optioncodegen— understand code generation pipeline for Layer 11