Skip to content
Draft
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
132 changes: 132 additions & 0 deletions client/dynamic-client-errorprone/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Dynamic client usage check (Error Prone)

An **[Error Prone](https://errorprone.info) check** that validates `DynamicClient` usage against the Smithy model each
client is *actually built from*, at compile time — **without** any code generation. The dynamic client stays fully
runtime-driven and document-based; this check only reads the source being compiled and catches the mistakes that are
statically catchable.

## What it checks

Given code like:

```java
DynamicClient client = DynamicClient.builder()
.serviceId(ShapeId.from("smithy.example#Sprockets"))
.model(Model.assembler().addUnparsedModel("demo.smithy", MODEL).assemble().unwrap())
.build();

client.call("GetSprokcet", Map.of("id", "1")); // <-- compile error: typo, no such operation
client.call("GetSprocket", Map.of("idd", "1")); // <-- compile error: 'idd' not an input member
```

the check emits, anchored to the exact argument:

```
error: [DynamicClientUsage] Operation 'GetSprokcet' not found in service 'smithy.example#Sprockets'.
Known operations: [CreateSprocket, GetSprocket]
Did you mean 'GetSprocket'? <-- suggested auto-fix
error: [DynamicClientUsage] 'idd' is not a member of input 'GetSprocketInput' for operation 'GetSprocket'.
Known members: [id]
```

Concretely, per `call(...)` site whose receiver is a `DynamicClient` it can resolve:

1. **operation name** — the first argument, when a String literal or `static final String` constant, must be an
operation on the resolved service (with a Levenshtein-based *"did you mean"* suggested fix);
2. **input keys** — when the second argument is a `Map.of("k", v, ...)` literal, every key must be a member of the
operation's input structure.

## Why Error Prone (vs. a raw javac plugin)

This started as a raw `com.sun.source.util.Plugin`. Porting to Error Prone kept the same idea but replaced hand-rolled
machinery with framework features:

| Hand-rolled in the raw plugin | Provided by Error Prone |
|---|---|
| Custom string-constant folding | `ASTHelpers.constValue(...)` |
| Report-only diagnostics | **`SuggestedFix`** — an applyable "did you mean `GetSprocket`?" auto-fix |
| A `run.sh` shell driver | `CompilationTestHelper` with inline `// BUG: Diagnostic contains:` assertions |
| No suppression | `@SuppressWarnings("DynamicClientUsage")` for free |
| Manual `TaskListener` wiring | `@AutoService(BugChecker.class)` registration |

The **model-resolution core** (`ModelResolver`, `ResolvedClient`, `MapLiteral`) is unchanged from the raw-plugin
prototype and has no dependency on Error Prone or javac internals — it takes a `constantResolver` function, so the same
core could back an Error Prone check, a javac plugin, or a standalone parser task. Error Prone is just the driver.

## How it works

- **Match** (`DynamicClientUsageChecker`) — a `MethodInvocationTreeMatcher` matching `call(...)` on `DynamicClient`.
- **Resolve the client** — walk from the `call` receiver to the local's `DynamicClient.builder()...build()`
initializer, read the `.model(...)` and `.serviceId(...)` arguments (handling `ShapeId.from("...")`).
- **Resolve + assemble the model** (`ModelResolver`) — statically read the `Model.assembler()...` chain
(`addUnparsedModel` / `addImport` / `discoverModels`, folding constants) and assemble it with the **real
`smithy-model` `ModelAssembler`** — the same code the runtime uses, so no drift. Cached per client.
- **Validate** — check the operation name and any `Map.of(...)` input keys, reporting via `buildDescription(...)`.

## The load-bearing rule: abstain, never false-positive

The check only reasons about statically-resolvable values. The instant anything is genuinely dynamic — an operation
name from a field or parameter, a model passed in from elsewhere, `addImport(someUrl)` — it returns
`Description.NO_MATCH` and never errors. `DynamicClient` exists *for* runtime dynamism; a checker that flags valid
dynamic code gets turned off. It catches typos and stays out of the way. (See `abstainsOnDynamicOperationName` and
`abstainsWhenModelNotStaticallyResolvable` in the tests, and the CLI's `SmithyCall`, which is entirely in the abstain
set.)

## Running it in a consumer build

With the `net.ltgt.errorprone` Gradle plugin, put this module on the `errorprone` configuration:

```kotlin
plugins { id("net.ltgt.errorprone") version "..." }
dependencies {
errorprone("com.google.errorprone:error_prone_core:2.50.0")
errorprone(project(":client:dynamic-client-errorprone"))
}
```

### Running *only* this check (no other Error Prone checks)

Consumers who don't want Error Prone's ~500 built-in checks can disable them all and enable just this one:

```kotlin
tasks.withType<JavaCompile>().configureEach {
options.errorprone {
disableAllChecks = true
error("DynamicClientUsage")
}
}
```

Note this still requires Error Prone the framework to be present — see "Known limitations".

## Try it

```
./gradlew :client:dynamic-client-errorprone:test
```

The `CompilationTestHelper` tests compile inline sources against the real `DynamicClient` with the check active and
assert the diagnostics (unknown operation, bad input key, auto-detected service, and correct abstention on dynamic op
name and unresolved model).

## Java 25 / toolchain note

Error Prone runs inside `javac` and depends on compiler internals, so it needs the standard `--add-exports` /
`--add-opens` set and `--should-stop=ifError=FLOW`. Verified working on this project's **Java 25** toolchain with
Error Prone **2.50.0**. Because `--add-exports` of a system-module package is incompatible with `--release`, this
module compiles with plain `source`/`target` 21 rather than the `--release 21` the shared conventions set (see
`build.gradle.kts`).

## Known limitations / next steps

- **Requires Error Prone the framework.** "Only this check" disables the *other checks*, but consumers still adopt
Error Prone + `net.ltgt.errorprone`. If a zero-Error-Prone dependency is required, the raw-javac-plugin form (in git
history) or a standalone parser-based Gradle task are the alternatives; both reuse the same `ModelResolver` core.
- **Coupled to javac / Error Prone versions.** Newer JDKs can require a matching Error Prone bump.
- **Intra-unit flow only.** A client built in one file and used in another isn't tracked; an optional
`@SmithyModel(...)` fallback annotation naming the source would extend reach.
- **`discoverModels()` / classpath imports** are approximated via import roots; wiring the compilation's real
`JavaFileManager` classpath would make it exact.
- Only operation names and `Map.of` input keys are checked today; required members, enum values, and nested document
shapes are natural extensions.
```
72 changes: 72 additions & 0 deletions client/dynamic-client-errorprone/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
plugins {
id("smithy-java.module-conventions")
}

description = "Error Prone check that validates DynamicClient usage against the Smithy model at compile time"

extra["displayName"] = "Smithy :: Java :: Dynamic client Error Prone check"
extra["moduleName"] = "software.amazon.smithy.java.dynamicclient.compiler"

dependencies {
// The Error Prone check API this BugChecker extends.
compileOnly(libs.errorprone.check.api)
compileOnly(libs.errorprone.annotation)

// AutoService generates the META-INF/services/com.google.errorprone.bugpatterns.BugChecker registration.
compileOnly("com.google.auto.service:auto-service-annotations:1.1.1")
annotationProcessor("com.google.auto.service:auto-service:1.1.1")

// The check runs inside javac and assembles the resolved model with the same ModelAssembler the runtime uses.
implementation(libs.smithy.model)

testImplementation(libs.errorprone.test.helpers)
testImplementation(libs.errorprone.core)
testImplementation(project(":client:dynamic-client"))
}

// Error Prone's check API compiles against javac internals, which require these exports on JDK 16+.
// `--add-exports` of a system-module package is incompatible with `--release`, so this module compiles with
// plain source/target (21, matching its dependencies) instead of the `--release 21` the conventions set.
tasks.withType<JavaCompile>().configureEach {
options.release.set(null as Int?)
sourceCompatibility = "21"
targetCompatibility = "21"
options.compilerArgs.addAll(
listOf(
"--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
),
)
}

// Javadoc also parses the compiler-internal references, so it needs the same exports.
tasks.withType<Javadoc>().configureEach {
(options as StandardJavadocDocletOptions).addMultilineStringsOption("-add-exports").value = listOf(
"jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
"jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
"jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
)
}

// SpotBugs analyzes bytecode that references javac internals it cannot load; not meaningful for an Error Prone check.
tasks.named("spotbugsMain") { enabled = false }

// The Error Prone test helpers spin up an in-process javac that also needs the module opens/exports at runtime.
tasks.withType<Test>().configureEach {
jvmArgs(
"--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.java.dynamicclient.compiler;

import com.google.errorprone.VisitorState;
import com.google.errorprone.util.ASTHelpers;
import com.sun.source.tree.VariableTree;
import com.sun.source.util.TreePathScanner;
import com.sun.tools.javac.code.Symbol;

/**
* Scans a compilation unit for the {@link VariableTree} that declares a given {@link Symbol}. Used as a fallback when
* the declaration is not on the path from the current call site to the top level (e.g. a field, or a local declared in
* a sibling method within the same file).
*/
final class DeclarationFinder {

private DeclarationFinder() {}

static VariableTree find(Symbol target, VisitorState state) {
var unit = state.getPath().getCompilationUnit();
if (unit == null) {
return null;
}
var scanner = new TreePathScanner<Void, Void>() {
VariableTree found;

@Override
public Void visitVariable(VariableTree node, Void unused) {
if (found == null && ASTHelpers.getSymbol(node) == target) {
found = node;
}
return super.visitVariable(node, unused);
}
};
scanner.scan(unit, null);
return scanner.found;
}
}
Loading
Loading