<div align="center">

# 🔩 sdkspindle

**SDK-aware linter, query engine & auto-fixer for `com.google.genai`**

[![Built by](https://img.shields.io/badge/built%20by-Claude%20%26%20Ethan-orange?style=for-the-badge&labelColor=2d2d2d)](https://github.com/elb-pr)
[![Powered by](https://img.shields.io/badge/powered%20by-spite%20%26%20coffee-brightgreen?style=for-the-badge&labelColor=2d2d2d)](https://github.com/elb-pr/sdkspindle)

[![PyPI](https://img.shields.io/pypi/v/sdkspindle?style=flat-square&logo=pypi&logoColor=white)](https://pypi.org/project/sdkspindle/)
[![Python](https://img.shields.io/badge/python-3.9+-blue?style=flat-square&logo=python&logoColor=white)](https://python.org)
[![License](https://img.shields.io/badge/license-Apache--2.0-green?style=flat-square)](LICENSE)
[![SDK](https://img.shields.io/badge/google--genai-1.43.0-4285F4?style=flat-square&logo=google&logoColor=white)](https://github.com/googleapis/java-genai)
[![Zero Dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen?style=flat-square)](pyproject.toml)
[![SARIF](https://img.shields.io/badge/output-SARIF%20v2.1.0-purple?style=flat-square)](https://sarifweb.azurewebsites.net/)

*Born from a session where 7 SDK type mismatches made it to CI because there's no compiler in the loop when AI writes code.*

</div>

---

## What it does

sdkspindle extracts the **entire public API surface** of the [Google GenAI Java SDK](https://github.com/googleapis/java-genai) into a JSON reference file, then checks your Kotlin/Java source code against it. It catches the bugs that only show up at compile time — before compile time.

```
$ sdkspindle check GeminiAdviceHelper.kt

SDK: 396 types, 37 services
  ❌ GeminiAdviceHelper.kt:16  [MISSING_ARGS] caches.list() requires (ListCachedContentsConfig) — called with no args
  ❌ GeminiAdviceHelper.kt:20  [OPTIONAL_NO_CALL] 'displayName' on CachedContent returns Optional<String> — use .displayName().orElse(...)
  ❌ GeminiAdviceHelper.kt:30  [WRONG_TYPE] .ttl() expects Duration, got String literal
  ⚠️  GeminiAdviceHelper.kt:31  [LISTOF_VARARGS] .contents() accepts varargs — listOf() is unnecessary

────────────────────────────────────────────────────────────
  3 error(s), 1 warning(s) across 1 file(s)
```

## Install

```bash
pip install sdkspindle
```

For the Gemini-powered query engine:
```bash
pip install sdkspindle[query]
```

## Commands

### `check` — Lint your code

```bash
# Lint specific files
sdkspindle check GeminiAdviceHelper.kt GeminiVisionHelper.kt

# Lint with auto-fix
sdkspindle check --fix **/*.kt

# Output SARIF for GitHub Actions
sdkspindle check --format sarif **/*.kt > results.sarif

# Output JSON
sdkspindle check --format json **/*.kt
```

### `extract` — Build SDK reference from source

```bash
# From a zip download
sdkspindle extract --sdk-zip java-genai-main.zip

# From a local directory
sdkspindle extract --sdk-dir path/to/java-genai/src/main/java/com/google/genai

# Custom output path
sdkspindle extract --sdk-zip java-genai-main.zip -o my_reference.json
```

### `query` — Ask the SDK (Gemini-powered)

```bash
# Natural language SDK lookup
sdkspindle query "how do I create a cached content with a 7-day TTL?"
sdkspindle query "what fields does CachedContent have?"
sdkspindle query "show me the correct way to call caches.list in Kotlin"
sdkspindle query "what's the difference between expireTime and ttl?"

# Use a specific model
sdkspindle query --model gemini-2.5-flash "how to set thinking level?"
```

Requires `GOOGLE_API_KEY` env var or `--api-key` flag.

### `stats` — SDK reference statistics

```bash
$ sdkspindle stats

  sdkspindle SDK Reference Statistics
  ────────────────────────────────────────────
  Source:           googleapis/java-genai
  SDK:              com.google.genai:google-genai
  Total files:      518

  Types:            396
  Services:         37
  Other classes:    70

  Total fields:     1847
    Optional<T>:    1623
    Duration:       12
    Instant:        28
  Builder methods:  3291
    Varargs:        186
  Service methods:  94
  Enum types:       42

  Duration fields (common source of String/Duration bugs):
    CreateCachedContentConfig.ttl
    UpdateCachedContentConfig.ttl
    ...
```

### `diff` — Lint only changed files

```bash
# Lint files changed since last commit
sdkspindle diff

# Lint files changed since a specific ref
sdkspindle diff HEAD~5

# With auto-fix
sdkspindle diff --fix
```

### `init` — Generate config file

```bash
sdkspindle init
```

## Configuration

sdkspindle looks for `.sdkspindle.json` walking up from the current directory (like `.eslintrc`):

```json
{
  "sdk_reference": "tools/sdkspindle/genai_sdk_reference.json",
  "include": [
    "android/app/src/main/java/**/*.kt"
  ],
  "exclude": [
    "**/test/**",
    "**/build/**",
    "**/generated/**"
  ],
  "rules": {
    "OPTIONAL_NO_CALL": "error",
    "WRONG_TYPE": "error",
    "MISSING_ARGS": "error",
    "UNKNOWN_TYPE": "error",
    "MISSING_IMPORT": "error",
    "LISTOF_VARARGS": "warn",
    "STRING_ENUM": "warn"
  },
  "gemini_model": "gemini-2.0-flash",
  "suppress_inline": true
}
```

Set any rule to `"off"` to disable it.

## Inline suppression

```kotlin
// Suppress a specific rule on the next line
// sdkspindle-ignore OPTIONAL_NO_CALL
val name = cache.name

// Suppress multiple rules
// sdkspindle-ignore OPTIONAL_NO_CALL,WRONG_TYPE
val ttl = config.ttl

// Suppress all rules on a line
val x = something.name // sdkspindle-ignore *
```

## Checks

| Code | Default | What it catches | Auto-fix? |
|------|---------|----------------|-----------|
| `OPTIONAL_NO_CALL` | error | `cache.name` instead of `cache.name().orElse(...)` | ⬜ |
| `WRONG_TYPE` | error | `.ttl("604800s")` instead of `.ttl(Duration.ofDays(7))` | ⬜ |
| `MISSING_ARGS` | error | `caches.list()` instead of `caches.list(config)` | ⬜ |
| `UNKNOWN_TYPE` | error | `import com.google.genai.types.DoesNotExist` | ⬜ |
| `MISSING_IMPORT` | error | Using `ListCachedContentsConfig` without importing it | ✅ |
| `LISTOF_VARARGS` | warn | `.contents(listOf(x))` instead of `.contents(x)` | ✅ |
| `STRING_ENUM` | warn | `.thinkingLevel("LOW")` instead of `ThinkingLevel.Known.LOW` | ⬜ |

## CI Integration

### GitHub Actions (SARIF)

```yaml
name: SDK Lint
on: [push, pull_request]

jobs:
  sdkspindle:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install sdkspindle
      - run: sdkspindle check --format sarif **/*.kt > results.sarif
        continue-on-error: true
      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: results.sarif
```

This gives you **inline PR annotations** on the exact lines with issues.

### Reusable workflow

```yaml
jobs:
  sdk-lint:
    uses: elb-pr/sdkspindle/.github/workflows/sdkspindle.yml@main
    with:
      files: 'android/app/src/main/java/**/*.kt'
```

### Pre-commit hook

```bash
#!/bin/sh
# .git/hooks/pre-commit
sdkspindle diff HEAD --format text
```

## JSON Reference Structure

The extracted SDK reference (`genai_sdk_reference.json`, ~936KB) contains:

```json
{
  "_meta": { "types": 396, "services": 37, "other": 70 },
  "types": {
    "CreateCachedContentConfig": {
      "fields": [
        { "name": "ttl", "type": "Optional<Duration>", "optional": true },
        { "name": "contents", "type": "Optional<List<Content>>", "optional": true }
      ],
      "builderMethods": [
        { "name": "ttl", "paramType": "Duration", "varargs": false },
        { "name": "contents", "paramType": "Content", "varargs": true }
      ]
    }
  },
  "services": {
    "Caches": {
      "methods": [
        { "name": "list", "params": [{"type": "ListCachedContentsConfig"}],
          "returnType": "Pager<CachedContent>" }
      ]
    }
  }
}
```

## Origin story

During a session building [the-adviser](https://github.com/elb-pr/the-adviser) (an on-device poker advisor), Claude wrote Gemini SDK integration code with 7 type mismatches that all passed code review but failed CI:

- `.ttl("604800s")` — SDK wants `Duration`, not `String`
- `cache.name` — returns `Optional<String>`, needs `.orElse()`
- `client.caches.list()` — requires a config parameter
- `.contents(listOf(...))` — method has varargs overload

The Kotlin compiler catches all of these instantly. The problem is there's no compiler in the loop when an AI writes code in a text editor. sdkspindle is the compiler-in-a-regex that fills that gap.

## License

Apache-2.0 — see [LICENSE](LICENSE).
