Skip to content

Implement the scan-exports CLI subcommand#25

Merged
tsapeta merged 1 commit into
mainfrom
tsapeta/scan-exports-cli
Jun 22, 2026
Merged

Implement the scan-exports CLI subcommand#25
tsapeta merged 1 commit into
mainfrom
tsapeta/scan-exports-cli

Conversation

@tsapeta

@tsapeta tsapeta commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Implements the stubbed scan-exports command: a deep scan of the full JS-exported surface of every @ExpoModule, @SharedObject, and @Record type, for TypeScript type generation.

Where scan-modules reports just enough to autolink a module, scan-exports descends into type bodies and captures each @JS func, @JS var, @JS init, and @Record property. Every boundary type is parsed into a structured TypeNode tree, so a generator walks a tagged tree instead of re-parsing Swift type syntax.

Layout

  • New Exports/ target folder (mirrors Modules/): ExportedSurface.swift (result types), TypeNode.swift (the structured type tree), SurfaceVisitor.swift (descends type bodies), ScanExports.swift (scanExports plus Scanner.runExports).
  • The shared file walk and pre-filter move into a reusable scanFiles in Core/; scan-modules now layers on it.
  • main.swift dispatches scan-exports to runExports, replacing the not-yet-implemented stub.

Output shape

Top-level envelope, the surface nested under exports:

{
  "exports": {
    "modules":       [ /* ExportedModule */ ],
    "sharedObjects": [ /* ExportedSharedObject */ ],
    "records":       [ /* ExportedRecord */ ]
  },
  "stats": { "filesScanned": 1, "filesParsed": 1, "durationMs": 4.2 }
}

Per-type:

  • module: name, jsName, functions[], properties[], file
  • sharedObject: same plus constructorParameters (null when no @JS init)
  • record: name, properties[], file

Members (JSON keys use the TS keyword spelling):

node fields
function name, jsName, parameters[], returns? (null for Void), async, throws, static
parameter label, name, type, optional (defaulted or optional-typed)
property (@JS var) name, jsName, type?, readonly, static
record property name, type, optional, required (!hasDefault && !isOptional)

A record crosses the boundary by value (decoded into a fresh Swift instance, re-serialized on the way out), so every record property is inherently read-only from JS. That's a record-level fact, so it isn't stamped per property; a generator makes the whole record interface read-only.

Every boundary type is a TypeNode carrying kind plus typeof (the typeof category: number/string/boolean/object/function) plus per-kind fields:

kind extra from
primitive name, typeof Int/Double/String/Bool
optional wrapped T? / T! / Optional<T>
array element [T] / Array<T>
dictionary key, value [K: V] / Dictionary<K,V>
promise value Promise<T>
function parameters, returns?, async, throws (A) async throws -> B
ref name any other nominal type (records, shared objects, platform/built-in convertibles)
unknown text a spelling the parser doesn't model (preserved verbatim)

A ref is resolved by the consumer against the scanned records/shared objects, a known built-in (CGSize, UIColor, etc.), or treated as unsupported. The built-in catalog lives in the generator, since convertibility is a runtime fact the syntactic scan can't see.

Example

// @JS("doWork") func work(name: String, count: Int = 0) async throws -> [Point]
{
  "name": "work", "jsName": "doWork",
  "async": true, "throws": true, "static": false,
  "parameters": [
    { "label": "name", "name": "name", "optional": false,
      "type": { "kind": "primitive", "name": "String", "typeof": "string" } },
    { "label": "count", "name": "count", "optional": true,
      "type": { "kind": "primitive", "name": "Int", "typeof": "number" } }
  ],
  "returns": {
    "kind": "array", "typeof": "object",
    "element": { "kind": "ref", "name": "Point", "typeof": "object" }
  }
}

Cross-platform

The JSON shape is a platform-neutral contract: a parallel Kotlin/Android scanner will emit the identical structure. typeof is the shared type signal; TypeNode.primitive.name is the only intentionally source-language-specific field (Swift Int/Bool, Kotlin Int/Boolean).

Follow-ups (out of scope here, all additive)

  • @Event members (a new events[] array on modules and shared objects)

Testing

137 tests pass, including a TypeNode parser suite (primitives, optionals, arrays, dictionaries, promises, closures, nesting, unmodeled generics, typeof on every node) and member-extraction suites (functions, properties including willSet/didSet settability, records, macro routing, scoping, end-to-end directory scan).

@tsapeta tsapeta force-pushed the tsapeta/scan-exports-cli branch 3 times, most recently from 7a19cb0 to 0e36307 Compare June 20, 2026 23:36
Implements the stubbed `scan-exports` command: a deep scan of the full
JS-exported surface of every `@ExpoModule`, `@SharedObject`, and `@Record`
type, for TypeScript type generation.

Where `scan-modules` reports just enough to autolink a module, `scan-exports`
descends into type bodies and captures each `@JS func` (sync/async/throws/
static), `@JS var` (readonly/static), `@JS init` parameters, and `@Record`
property (optional/required). Every boundary type is parsed into a structured
`TypeNode` tree (primitive/optional/array/dictionary/promise/function/ref/
unknown) carrying a `typeof` category on every node, so a generator walks a
tagged tree instead of re-parsing Swift type syntax.

The new `Exports/` target folder mirrors `Modules/`; the shared file walk and
pre-filter move into a reusable `scanFiles` in `Core/`. The output JSON uses
TS-keyword keys (`async`, `throws`, `static`, `readonly`, `optional`,
`required`, `typeof`) and is a platform-neutral contract the Kotlin scanner
will target too.

Events, the `@Convertible(typeof:)` marker, and record-field `readonly` are
left to follow-ups.
@tsapeta tsapeta force-pushed the tsapeta/scan-exports-cli branch from 0e36307 to 293d724 Compare June 21, 2026 06:45
@tsapeta tsapeta requested a review from Kudo June 21, 2026 06:58
@tsapeta tsapeta merged commit f6ac5dd into main Jun 22, 2026
1 check passed
@tsapeta tsapeta deleted the tsapeta/scan-exports-cli branch June 22, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants