diff --git a/.sobelow-skips b/.sobelow-skips index 993075b..e109c21 100644 --- a/.sobelow-skips +++ b/.sobelow-skips @@ -5,4 +5,8 @@ RCE.CodeModule: Code Execution in `Code.eval_file`,lib/ccxt_ocx/bundle_surface/m Traversal.FileModule: Directory Traversal in `File.read!`,lib/ccxt_ocx/bundle_surface/compile.ex:121,3835524 Traversal.FileModule: Directory Traversal in `File.mkdir_p!`,lib/ccxt_ocx/bundle_surface/manifest.ex:51,623409F Traversal.FileModule: Directory Traversal in `File.read!`,lib/ccxt_ocx/bundle_surface/compile.ex:165,69BB3C4 -Traversal.FileModule: Directory Traversal in `File.read!`,lib/ccxt_ocx/tiers/compile.ex:68,7844D5D \ No newline at end of file +Traversal.FileModule: Directory Traversal in `File.read!`,lib/ccxt_ocx/tiers/compile.ex:68,7844D5D +Traversal.FileModule: Directory Traversal in `File.read!`,lib/ccxt_ocx/bundle_surface/compile.ex:127,3D28FF5 +Traversal.FileModule: Directory Traversal in `File.read!`,lib/ccxt_ocx/declarations/compile.ex:175,6A320B6 +Traversal.FileModule: Directory Traversal in `File.read!`,lib/ccxt_ocx/bundle_surface/compile.ex:83,7F523E6 +Traversal.FileModule: Directory Traversal in `File.read!`,lib/ccxt_ocx/declarations/compile.ex:180,2BFD6B \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c245972..bee672b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to `ccxt_ocx` are recorded here. The format follows ## [Unreleased] +### Phase 2: Macro-Driven Method Generation + +#### Task 6: Discover and parse CCXT declaration sources (`CcxtOcx.Declarations`) +**Completed** | [D:6/B:8/U:8 โ†’ Eff:1.33] ๐Ÿ“‹ + +Compile-time parser that turns CCXT's real TypeScript declarations into rich +per-method terms (name, params, return type, owning surface, overrides) for +the Phase 2 macro layer to consume. + +- New `CcxtOcx.Declarations` facade + `CcxtOcx.Declarations.Compile` parser โ€” walks `js/src/base/Exchange.d.ts`, the per-exchange `*.d.ts`, and `pro/*.d.ts` with `OXC.parse/2` + `OXC.collect/2`, classifying methods by `:base`, `:exchange`, or `:pro` surface and capturing per-exchange overrides. +- Filter ownership (verb-prefix allowlist, internal-prefix denylist, exact-name denylist, bare-name trade-plane allowlist) centralized here. `CcxtOcx.BundleSurface.Compile` now delegates to `Declarations.Compile.public_unified_method?/1` so the "what gets a `defunified` wrapper" decision lives in one place. +- Overrides map keyed by `"surface:exchange_id"` so a method declared in both `js/src/.d.ts` and `js/src/pro/.d.ts` (e.g. `kucoinfutures.fetchBidsAsks`) keeps both override entries. +- Loud layout guard raises with actionable instructions if CCXT bumps and the three smoke methods (`fetchTicker`, `createOrder`, `watchTicker`) disappear from the base surface. + ### Phase 5: Production Hardening #### Task 21: PromEx plugin (`CcxtOcx.PromEx.Plugin`) diff --git a/CLAUDE.md b/CLAUDE.md index 1822bf9..acaff38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The public API is generated at compile time from CCXT's type definitions โ€” **never hand-written**. CCXT's surface is ~100 exchanges ร— ~50 unified methods ร— (REST + WS) ร— (public + private); hand-written per-method wrappers don't scale. The macro layer is the contract; the adapter behind it (JS via QuickBEAM, or native Elixir for the venues that matter) is an implementation detail. -**Planned macro surface** (no macros implemented yet โ€” Phase 1 foundation modules (`Runtime`, `RuntimePool`, `Error`, `Tiers`, `BundleSurface`, `Telemetry`) are in place; status per macro in [ROADMAP.md](ROADMAP.md)): +**Planned macro surface** (no macros implemented yet โ€” Phase 1 foundation modules (`Runtime`, `RuntimePool`, `Error`, `Tiers`, `BundleSurface`, `Telemetry`) are in place, plus Phase 2's `Declarations` (Task 6) as the compile-time data source the macros below will consume; status per macro in [ROADMAP.md](ROADMAP.md)): | Macro | Phase | Role | |---|---|---| diff --git a/ROADMAP.md b/ROADMAP.md index 6c245fd..7c990cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,7 +49,7 @@ | Task | Status | Notes | |------|--------|-------| -| Task 6 | โฌœ | ๐ŸŽ **macros** ยท ๐Ÿš€ **v0_1** ยท Discover and parse CCXT declaration sources with OXC [D:6/B:8/U:8 โ†’ Eff:1.33] ๐Ÿ“‹ | +| Task 6 | โœ… | ๐ŸŽ **macros** ยท ๐Ÿš€ **v0_1** ยท Discover and parse CCXT declaration sources with OXC [D:6/B:8/U:8 โ†’ Eff:1.33] ๐Ÿ“‹ | | Task 6b | โฌœ | ๐ŸŽ **macros** ยท ๐Ÿš€ **v0_1** ยท `use CcxtOcx` โ€” exchange-scope entrypoint [D:5/B:9/U:9 โ†’ Eff:1.8] ๐Ÿš€ | | Task 7 | โฌœ | ๐ŸŽ **macros** ยท ๐Ÿš€ **v0_1** ยท `defunified` macro [D:7/B:10/U:9 โ†’ Eff:1.36] ๐Ÿ“‹ | | Task 8 | โฌœ | ๐ŸŽ **macros** ยท ๐Ÿš€ **v0_1** ยท Typed structs for unified return shapes (with declarative field mapping) [D:5/B:8/U:8 โ†’ Eff:1.6] ๐Ÿš€ | diff --git a/lib/ccxt_ocx/bundle_surface/compile.ex b/lib/ccxt_ocx/bundle_surface/compile.ex index a4c7d93..7168cc8 100644 --- a/lib/ccxt_ocx/bundle_surface/compile.ex +++ b/lib/ccxt_ocx/bundle_surface/compile.ex @@ -23,6 +23,8 @@ defmodule CcxtOcx.BundleSurface.Compile do See the quickbeam skill for the canonical pattern. """ + alias CcxtOcx.Declarations + @bundle_relative "node_modules/ccxt/dist/ccxt.browser.min.js" @exchange_dts "node_modules/ccxt/js/src/base/Exchange.d.ts" @load_timeout to_timeout(second: 30) @@ -32,50 +34,10 @@ defmodule CcxtOcx.BundleSurface.Compile do # Keeps verification fast while still exercising the important signing/WS paths. @default_sample_exchanges ["binance", "bybit", "okx", "deribit", "coinbaseexchange"] - # Verb prefixes that mark a method as part of the public unified surface. - @verb_prefixes ~w(fetch create watch cancel edit loadMarkets set close describe) - - # Prefixes that mark a method as internal/base-class helper machinery and - # MUST be filtered out even if they collide with a verb prefix (e.g. `parse`, - # `handle`, `safeMarket`). - @internal_prefixes ~w(parse handle sign request safe market nonce define extend) - - # Exact-name denylist for CCXT base-class helpers that *do* match a verb - # prefix and *don't* match an internal prefix (so the prefix heuristic alone - # captures them) but are not part of the unified public surface. Pollution - # source: CCXT base Exchange ships these as plumbing โ€” pagination, partial - # balance access, HTTP retry, webpage fetch, safe-dict construction, and the - # `loadMarkets` internal helper. Letting them stay in the manifest would mean - # Phase 2's `defunified` macro generates wrappers for internal helpers with no - # documented contract. Extend when future CCXT versions add new helpers that - # slip past the prefix filter. - @additional_denied ~w( - fetch2 - fetchPaginatedCallCursor - fetchPaginatedCallDeterministic - fetchPaginatedCallDynamic - fetchPaginatedCallIncremental - fetchPartialBalance - fetchWebEndpoint - createSafeDictionary - loadMarketsHelper - ) - - # Trade-plane methods that don't match the verb-prefix filter but are part of - # the public unified surface (CCXT exposes them as bare-name methods on - # exchanges that support them โ€” withdrawals, transfers, isolated/cross - # margin adjustments). Without this allowlist they'd be dropped from the - # snapshot and never get a defunified wrapper. - @additional_unified_methods ~w( - withdraw - transfer - addMargin - reduceMargin - borrowCrossMargin - borrowIsolatedMargin - repayCrossMargin - repayIsolatedMargin - ) + # Filter ownership moved to CcxtOcx.Declarations.Compile (Task 6). + # The four lists and the predicate now live in one place so the "public unified + # surface" decision is the single source of truth for both the legacy name + # extractor and the rich declaration parser. @doc """ Absolute path to the CCXT browser bundle (same logic as Tiers.Compile). @@ -124,7 +86,7 @@ defmodule CcxtOcx.BundleSurface.Compile do names = OXC.collect(ast, fn %{type: :method_definition, key: %{name: name}} = _node -> - if public_unified_method?(name), do: {:keep, name}, else: :skip + if Declarations.Compile.public_unified_method?(name), do: {:keep, name}, else: :skip _ -> :skip @@ -197,16 +159,6 @@ defmodule CcxtOcx.BundleSurface.Compile do # --- Private helpers ------------------------------------------------------ - @spec public_unified_method?(String.t()) :: boolean() - defp public_unified_method?(name) do - has_good_prefix = Enum.any?(@verb_prefixes, &String.starts_with?(name, &1)) - has_bad_prefix = Enum.any?(@internal_prefixes, &String.starts_with?(name, &1)) - in_allowlist = name in @additional_unified_methods - in_denylist = name in @additional_denied - - (has_good_prefix or in_allowlist) and not has_bad_prefix and not in_denylist - end - @spec apply_browser_stubs(pid()) :: :ok defp apply_browser_stubs(rt) do {:ok, _} = diff --git a/lib/ccxt_ocx/declarations.ex b/lib/ccxt_ocx/declarations.ex new file mode 100644 index 0000000..db3cfd9 --- /dev/null +++ b/lib/ccxt_ocx/declarations.ex @@ -0,0 +1,51 @@ +defmodule CcxtOcx.Declarations do + @moduledoc """ + Public compile-time surface for CCXT declaration parsing (Task 6). + + This module (and its `Compile` submodule) are the single source of truth for + the unified method surface extracted from CCXT's own TypeScript declarations. + + Consumers in Phase 2 (Task 6b `use CcxtOcx`, Task 7 `defunified`, Task 9 + `defexchange`) call `parse_unified_surface/0` at compile time to obtain the + rich per-method terms that drive macro expansion. + + The underlying parser uses OXC (`OXC.parse/2 + OXC.collect/2`) against the + real declaration files inside `node_modules/ccxt/js/src/...` (not the barrel + `ccxt.d.ts`). It distinguishes `:base`, `:exchange`, and `:pro` surfaces and + captures overrides. + + ## Layout stability + + The parser contains loud guards that raise with actionable messages if the + expected CCXT file layout changes or the smoke-test methods disappear. This + turns "CCXT bumped and broke our codegen" into a fast, obvious failure instead + of a mysterious macro bug. + """ + + alias CcxtOcx.Declarations.Compile + + # Recompile the facade when ANY discovered declaration source changes โ€” base, + # per-exchange, or pro. Limiting this to the base file would let + # `parse_unified_surface/0` return stale terms after `mix npm.update ccxt` + # touches only an exchange-specific or pro .d.ts. + for path <- [Compile.base_dts_path()] ++ Compile.exchange_dts_paths() ++ Compile.pro_dts_paths() do + @external_resource path + end + + @doc """ + Parse the discovered CCXT declaration surface and return one rich term per + public unified method. + + See `CcxtOcx.Declarations.Compile.method_term/0` for the exact shape. + """ + @spec parse_unified_surface() :: [Compile.method_term()] + def parse_unified_surface, do: Compile.parse_unified_surface() + + @doc "Delegates to `Compile.public_unified_method?/1` (the single source of truth)." + @spec public_unified_method?(String.t()) :: boolean() + def public_unified_method?(name), do: Compile.public_unified_method?(name) + + @doc "Absolute path to the core Exchange.d.ts (convenience for docs / tests)." + @spec base_dts_path() :: String.t() + def base_dts_path, do: Compile.base_dts_path() +end diff --git a/lib/ccxt_ocx/declarations/compile.ex b/lib/ccxt_ocx/declarations/compile.ex new file mode 100644 index 0000000..1013b1b --- /dev/null +++ b/lib/ccxt_ocx/declarations/compile.ex @@ -0,0 +1,407 @@ +defmodule CcxtOcx.Declarations.Compile do + @moduledoc """ + Compile-time parser for CCXT TypeScript declaration sources via OXC. + + Discovers `js/src/base/Exchange.d.ts`, per-exchange `*.d.ts`, and `pro/*.d.ts`, + extracts unified method signatures (name + params + return type), classifies + by owning surface (`:base`, `:exchange`, `:pro`), and captures exchange-specific + overrides. + + This is the foundation for the Phase 2 macro layer (`defunified`, `defexchange`, + `use CcxtOcx`). Output is a list of stable Elixir terms consumed at compile time + by the macro generators. + + ## Layout guard + + The parser hard-fails (with actionable instructions) if the expected CCXT + declaration layout disappears or the three smoke-test methods are absent from + the base surface. This makes "CCXT changed their .d.ts" a loud, fast failure + instead of a silent macro-generation bug later. + """ + + # --- Filter lists (source of truth for "public unified surface") ------------ + + # Verb prefixes that mark a method as part of the public unified surface. + @verb_prefixes ~w(fetch create watch cancel edit loadMarkets set close describe) + + # Prefixes that mark a method as internal/base-class helper machinery and + # MUST be filtered out even if they collide with a verb prefix. + @internal_prefixes ~w(parse handle sign request safe market nonce define extend) + + # Exact-name denylist for CCXT base-class helpers that slip past the prefix + # heuristic but are not part of the unified public surface. + @additional_denied ~w( + fetch2 + fetchPaginatedCallCursor + fetchPaginatedCallDeterministic + fetchPaginatedCallDynamic + fetchPaginatedCallIncremental + fetchPartialBalance + fetchWebEndpoint + createSafeDictionary + loadMarketsHelper + ) + + # Trade-plane methods that don't match the verb-prefix filter but belong in + # the public unified surface. + @additional_unified_methods ~w( + withdraw + transfer + addMargin + reduceMargin + borrowCrossMargin + borrowIsolatedMargin + repayCrossMargin + repayIsolatedMargin + ) + + # --- Path configuration (mirrors BundleSurface.Compile / Tiers.Compile) ----- + + @base_dts "node_modules/ccxt/js/src/base/Exchange.d.ts" + @exchange_glob "node_modules/ccxt/js/src/*.d.ts" + @pro_glob "node_modules/ccxt/js/src/pro/*.d.ts" + + @doc """ + Absolute path to the core Exchange.d.ts (the :base unified contract). + """ + @spec base_dts_path() :: String.t() + def base_dts_path do + Path.join(File.cwd!(), @base_dts) + end + + @doc """ + Absolute paths to all top-level per-exchange .d.ts files (surface :exchange). + Excludes subdirectories (base/, pro/, abstract/, etc.). + """ + @spec exchange_dts_paths() :: [String.t()] + def exchange_dts_paths do + Path.wildcard(Path.join(File.cwd!(), @exchange_glob)) + end + + @doc """ + Absolute paths to all CCXT Pro declaration files (surface :pro). + """ + @spec pro_dts_paths() :: [String.t()] + def pro_dts_paths do + Path.wildcard(Path.join(File.cwd!(), @pro_glob)) + end + + # --- Public entry point ----------------------------------------------------- + + @type param :: %{name: String.t(), type: String.t(), optional: boolean()} + @type surface :: :base | :exchange | :pro + @type declaration_ref :: %{surface: surface, source: String.t()} + @type override_ref :: %{surface: surface, source: String.t(), params: [param()], return_type: String.t()} + @type method_term :: %{ + name: String.t(), + params: [param()], + return_type: String.t(), + primary: declaration_ref(), + overrides: %{optional(String.t()) => override_ref()} + } + + @doc """ + Parse the entire discovered CCXT declaration surface and return one rich term + per public unified method. + + The term contains the canonical (primary) signature from the base surface, + plus any per-exchange or pro overrides that re-declare the same method with + different types or JSDoc. + + Raises with a loud, actionable message if the CCXT .d.ts layout has changed + (missing key files or disappearance of the three smoke-test methods). + """ + @spec parse_unified_surface() :: [method_term()] + def parse_unified_surface do + base_path = base_dts_path() + + if !File.exists?(base_path) do + raise """ + CCXT declaration layout changed. + + Expected core file not found: + #{base_path} + + Run `mix npm.install` (or `mix npm.ci`) to restore node_modules/ccxt/, + then recompile. + """ + end + + base_methods = parse_dts(base_path, {:base, nil}) + + exchange_methods = + Enum.flat_map(exchange_dts_paths(), fn p -> parse_dts(p, {:exchange, exchange_id_from_path(p)}) end) + + pro_methods = + Enum.flat_map(pro_dts_paths(), fn p -> parse_dts(p, {:pro, exchange_id_from_path(p)}) end) + + all = base_methods ++ exchange_methods ++ pro_methods + + grouped = Enum.group_by(all, & &1.name) + + terms = + for {name, decls} <- grouped, into: [] do + primary = Enum.find(decls, &(&1.surface == :base)) || hd(decls) + + overrides = + decls + |> Enum.reject(&(&1.surface == primary.surface and &1.source == primary.source)) + |> Map.new(fn d -> + # Key by "surface:exchange_id" so an :exchange and a :pro override + # for the same exchange (e.g. kucoinfutures.fetchBidsAsks lives in + # both src/kucoinfutures.d.ts and src/pro/kucoinfutures.d.ts) both + # survive rather than silently overwriting one another. + eid = d.exchange_id || "unknown" + key = "#{d.surface}:#{eid}" + {key, Map.take(d, [:surface, :source, :params, :return_type])} + end) + + %{ + name: name, + params: primary.params, + return_type: primary.return_type, + primary: %{surface: primary.surface, source: primary.source}, + overrides: overrides + } + end + + terms = Enum.sort_by(terms, & &1.name) + + assert_required_methods!(terms) + + terms + end + + # --- Core parser ------------------------------------------------------------ + + @spec parse_dts(String.t(), {surface(), String.t() | nil}) :: [map()] + defp parse_dts(path, {surface, exchange_id}) do + if File.exists?(path) do + source = File.read!(path) + + ast = + case OXC.parse(source, Path.basename(path)) do + {:ok, ast} -> + ast + + {:error, errors} -> + messages = Enum.map_join(errors, "\n - ", & &1.message) + + raise """ + Failed to parse CCXT declaration file with OXC: + + #{path} + + OXC errors: + - #{messages} + + This usually means CCXT shipped invalid TypeScript or OXC's parser + doesn't yet understand a syntax form in this file. Pin CCXT to a + known-good version in package.json, then bump OXC. + """ + end + + OXC.collect(ast, &collect_method(&1, surface, exchange_id, path)) + else + # Non-fatal for secondary surfaces during discovery; just skip. + [] + end + end + + @spec collect_method(map(), surface(), String.t() | nil, String.t()) :: + {:keep, map()} | :skip + defp collect_method(node, surface, exchange_id, path) do + case node do + %{type: :method_definition, key: %{name: name}, value: value} -> + if public_unified_method?(name) do + {:keep, build_method_term(name, value, surface, exchange_id, path)} + else + :skip + end + + _ -> + :skip + end + end + + @spec build_method_term(String.t(), map(), surface(), String.t() | nil, String.t()) :: map() + defp build_method_term(name, value, surface, exchange_id, path) do + %{ + name: name, + params: extract_params(value.params || []), + return_type: extract_return_type(value.returnType), + surface: surface, + source: path, + exchange_id: exchange_id + } + end + + # --- Signature extraction helpers (follow oxc.md shapes) -------------------- + + @spec extract_params([map()]) :: [param()] + defp extract_params(params) when is_list(params) do + Enum.map(params, fn p -> + name = Map.get(p, :name, "unknown") + optional = Map.get(p, :optional, false) + type_node = get_in(p, [:typeAnnotation, :typeAnnotation]) + + %{ + name: name, + type: render_type(type_node), + optional: optional + } + end) + end + + @spec extract_return_type(map() | nil) :: String.t() + defp extract_return_type(nil), do: "void" + + defp extract_return_type(%{type: :ts_type_annotation, typeAnnotation: node}) do + render_type(node) + end + + defp extract_return_type(_), do: "any" + + # Recursive TS type renderer โ€” produces the strings required by acceptance. + # Unknown nodes produce "UNKNOWN(#{type})" so tests fail loudly instead of + # silently emitting garbage. + @spec render_type(map() | nil) :: String.t() + def render_type(nil), do: "any" + + def render_type(%{type: :ts_string_keyword}), do: "string" + def render_type(%{type: :ts_number_keyword}), do: "number" + def render_type(%{type: :ts_boolean_keyword}), do: "boolean" + def render_type(%{type: :ts_any_keyword}), do: "any" + def render_type(%{type: :ts_unknown_keyword}), do: "unknown" + def render_type(%{type: :ts_void_keyword}), do: "void" + def render_type(%{type: :ts_null_keyword}), do: "null" + def render_type(%{type: :ts_undefined_keyword}), do: "undefined" + + def render_type(%{type: :ts_type_reference} = node) do + name = get_in(node, [:typeName, :name]) || "UnknownRef" + args = get_in(node, [:typeArguments, :params]) || [] + + if args == [] do + name + else + rendered = Enum.map_join(args, ", ", &render_type/1) + "#{name}<#{rendered}>" + end + end + + def render_type(%{type: :ts_type_literal}), do: "{}" + + def render_type(%{type: :ts_array_type, elementType: elem}) do + "#{render_type(elem)}[]" + end + + def render_type(%{type: :ts_union_type, types: types}) when is_list(types) do + Enum.map_join(types, " | ", &render_type/1) + end + + def render_type(%{type: :ts_literal_type, literal: lit}) do + case lit do + %{value: v} when is_binary(v) -> "\"#{v}\"" + %{value: v} -> inspect(v) + _ -> "literal" + end + end + + def render_type(%{type: :ts_type_annotation, typeAnnotation: inner}) do + render_type(inner) + end + + def render_type(%{type: t}), do: "UNKNOWN(#{t})" + def render_type(_), do: "any" + + # --- Filter (will be the single source of truth; BundleSurface delegates) --- + + @doc """ + Returns true for method names that belong to the public unified surface. + + Central predicate used by both the declaration parser and the legacy + BundleSurface name extractor. Moving the lists here makes the "what gets a + defunified wrapper" decision live in one place. + """ + @spec public_unified_method?(String.t()) :: boolean() + def public_unified_method?(name) do + has_good_prefix = Enum.any?(@verb_prefixes, &String.starts_with?(name, &1)) + has_bad_prefix = Enum.any?(@internal_prefixes, &String.starts_with?(name, &1)) + in_allowlist = name in @additional_unified_methods + in_denylist = name in @additional_denied + + (has_good_prefix or in_allowlist) and not has_bad_prefix and not in_denylist + end + + # --- Helpers ---------------------------------------------------------------- + + @spec exchange_id_from_path(String.t()) :: String.t() + defp exchange_id_from_path(path) do + path + |> Path.basename() + |> Path.rootname() + end + + # --- Layout guard ------------------------------------------------------------ + + @required_smoke_methods ~w(fetchTicker createOrder watchTicker) + + @spec assert_required_methods!([method_term()]) :: :ok + defp assert_required_methods!(terms) do + present = MapSet.new(Enum.map(terms, & &1.name)) + + missing = Enum.reject(@required_smoke_methods, &MapSet.member?(present, &1)) + + if missing != [] do + raise """ + CCXT declaration layout changed โ€” required unified methods disappeared. + + Missing from :base surface (or filtered out): + #{inspect(missing)} + + Expected them in: + #{base_dts_path()} + + This is a deliberate loud failure (Task 6 acceptance). + After a `mix npm.update ccxt`, review the diff and either: + - pin a known-good version in package.json, or + - run `mix ccxt.verify_bundle --accept` (after human review). + + If the methods genuinely moved to a different .d.ts, update the discovery + globs in Declarations.Compile. + """ + end + + # Each required smoke method MUST be primary :base from Exchange.d.ts. + # `Enum.any?` would pass silently if e.g. createOrder moved to pro while + # fetchTicker stayed on base โ€” exactly the layout drift this guard exists + # to catch. + by_name = Map.new(terms, &{&1.name, &1}) + + non_base = + Enum.reject(@required_smoke_methods, fn name -> + case Map.get(by_name, name) do + %{primary: %{surface: :base, source: src}} -> String.ends_with?(src, "Exchange.d.ts") + _ -> false + end + end) + + if non_base != [] do + raise """ + CCXT declaration layout changed โ€” required smoke methods are not primary :base + from Exchange.d.ts. + + Not primary :base: + #{inspect(non_base)} + + Expected each of #{inspect(@required_smoke_methods)} to be declared in: + #{base_dts_path()} + + If CCXT genuinely reorganized these methods onto another surface, update the + discovery globs in Declarations.Compile and the @required_smoke_methods list. + """ + end + + :ok + end +end diff --git a/roadmap/data.json b/roadmap/data.json index 13f21aa..e4131b4 100644 --- a/roadmap/data.json +++ b/roadmap/data.json @@ -243,7 +243,7 @@ "phase": 2, "bundle": "macros", "milestone": "v0_1", - "status": "pending", + "status": "done", "title": "Discover and parse CCXT declaration sources with OXC", "scores": { "d": 6, @@ -260,7 +260,9 @@ "Tests fail loudly if CCXT declaration file layout changes or required methods disappear" ], "body": "Build a compile-time module that discovers the actual CCXT TypeScript declaration sources and extracts unified method metadata with `OXC.parse/2` + `OXC.collect/2`. Tidewave/file inspection showed `node_modules/ccxt/js/ccxt.d.ts` is an import/export barrel and does not itself contain methods such as `fetchTicker`, `createOrder`, or `watchTicker`; method declarations live in `js/src/base/Exchange.d.ts`, per-exchange declarations under `js/src/*.d.ts`, and pro declarations under `js/src/pro/*.d.ts`. Output an Elixir term describing the method name, parameter list, return type, owning surface (`:base`, `:exchange`, `:pro`), and exchange-specific overrides where present.\n", + "done_at": "2026-05-17", "scored_at": "2026-05-16", + "implemented": "OXC parser (Declarations.Compile) extracts name/params/return_type/source/surface + overrides from base/Exchange.d.ts + exchange + pro/*.d.ts. Loud layout guards on the 3 smoke methods. Filter ownership centralized; BundleSurface delegates. All gates (coverage, compile, dialyzer, credo, integration tests, verify_bundle) passed.", "cross_repo": [] }, { diff --git a/roadmap/tasks.toml b/roadmap/tasks.toml index c24a9fa..cec8dc8 100644 --- a/roadmap/tasks.toml +++ b/roadmap/tasks.toml @@ -213,7 +213,7 @@ id = "6" phase = 2 bundle = "macros" milestone = "v0_1" -status = "pending" +status = "done" title = "Discover and parse CCXT declaration sources with OXC" scores = { d = 6, b = 8, u = 8 } acceptance_criteria = [ @@ -225,7 +225,9 @@ acceptance_criteria = [ body = """ Build a compile-time module that discovers the actual CCXT TypeScript declaration sources and extracts unified method metadata with `OXC.parse/2` + `OXC.collect/2`. Tidewave/file inspection showed `node_modules/ccxt/js/ccxt.d.ts` is an import/export barrel and does not itself contain methods such as `fetchTicker`, `createOrder`, or `watchTicker`; method declarations live in `js/src/base/Exchange.d.ts`, per-exchange declarations under `js/src/*.d.ts`, and pro declarations under `js/src/pro/*.d.ts`. Output an Elixir term describing the method name, parameter list, return type, owning surface (`:base`, `:exchange`, `:pro`), and exchange-specific overrides where present. """ +implemented = "OXC parser (Declarations.Compile) extracts name/params/return_type/source/surface + overrides from base/Exchange.d.ts + exchange + pro/*.d.ts. Loud layout guards on the 3 smoke methods. Filter ownership centralized; BundleSurface delegates. All gates (coverage, compile, dialyzer, credo, integration tests, verify_bundle) passed." scored_at = "2026-05-16" +done_at = "2026-05-17" [[task]] id = "6b" diff --git a/test/ccxt_ocx/declarations_test.exs b/test/ccxt_ocx/declarations_test.exs new file mode 100644 index 0000000..a057800 --- /dev/null +++ b/test/ccxt_ocx/declarations_test.exs @@ -0,0 +1,117 @@ +defmodule CcxtOcx.DeclarationsTest do + use ExUnit.Case, async: false + + alias CcxtOcx.Declarations + alias CcxtOcx.Declarations.Compile + + describe "public_unified_method?/1 (filter ownership)" do + test "accepts core smoke methods" do + assert Compile.public_unified_method?("fetchTicker") + assert Compile.public_unified_method?("createOrder") + assert Compile.public_unified_method?("watchTicker") + end + + test "accepts bare trade-plane methods from the allowlist" do + assert Compile.public_unified_method?("withdraw") + assert Compile.public_unified_method?("transfer") + end + + test "rejects internal helpers even when they match a verb prefix" do + refute Compile.public_unified_method?("parseOrder") + refute Compile.public_unified_method?("handleTicker") + refute Compile.public_unified_method?("sign") + end + + test "rejects the explicit denylist" do + refute Compile.public_unified_method?("fetch2") + refute Compile.public_unified_method?("loadMarketsHelper") + end + end + + describe "type renderer (pure)" do + test "renders primitive keywords" do + assert Compile.render_type(%{type: :ts_string_keyword}) == "string" + assert Compile.render_type(%{type: :ts_number_keyword}) == "number" + assert Compile.render_type(%{type: :ts_boolean_keyword}) == "boolean" + end + + test "renders type references with and without type args" do + assert Compile.render_type(%{type: :ts_type_reference, typeName: %{name: "Ticker"}}) == "Ticker" + + assert Compile.render_type(%{ + type: :ts_type_reference, + typeName: %{name: "Promise"}, + typeArguments: %{params: [%{type: :ts_type_reference, typeName: %{name: "Order"}}]} + }) == "Promise" + end + + test "renders inline object and array forms" do + assert Compile.render_type(%{type: :ts_type_literal}) == "{}" + assert Compile.render_type(%{type: :ts_array_type, elementType: %{type: :ts_string_keyword}}) == "string[]" + end + + test "falls back loudly on unknown nodes (test guard)" do + assert Compile.render_type(%{type: :ts_foo_bar_baz}) == "UNKNOWN(ts_foo_bar_baz)" + end + end + + describe "parse_unified_surface/0 (integration โ€” real CCXT .d.ts)" do + @tag :integration + test "extracts the three smoke methods with correct primary surface and basic shape" do + terms = Declarations.parse_unified_surface() + names = Enum.map(terms, & &1.name) + + for m <- ["fetchTicker", "createOrder", "watchTicker"] do + assert m in names, "expected #{m} in parsed unified surface" + end + + ticker = Enum.find(terms, &(&1.name == "fetchTicker")) + assert ticker.primary.surface == :base + assert String.ends_with?(ticker.primary.source, "Exchange.d.ts") + assert length(ticker.params) >= 2 + assert ticker.return_type =~ "Promise" + assert ticker.return_type =~ "Ticker" + + create = Enum.find(terms, &(&1.name == "createOrder")) + assert create.primary.surface == :base + assert length(create.params) >= 5 + + watch = Enum.find(terms, &(&1.name == "watchTicker")) + assert watch.primary.surface == :base + end + + @tag :integration + test "distinguishes base / exchange / pro surfaces and populates overrides" do + terms = Declarations.parse_unified_surface() + + # `overrides` is keyed by "surface:exchange_id" strings, so we must read the + # surface atoms off the override VALUES, not Map.keys/1. + primary_surfaces = + terms + |> Enum.map(& &1.primary.surface) + |> Enum.uniq() + + override_surfaces = + terms + |> Enum.flat_map(fn t -> t.overrides |> Map.values() |> Enum.map(& &1.surface) end) + |> Enum.uniq() + + assert :base in primary_surfaces + assert :exchange in override_surfaces, "expected at least one :exchange override across the unified surface" + assert :pro in override_surfaces, "expected at least one :pro override across the unified surface" + end + end + + describe "path helpers" do + test "base_dts_path ends with the expected file" do + p = Compile.base_dts_path() + assert String.ends_with?(p, "base/Exchange.d.ts") + end + + @tag :integration + test "exchange and pro globs return a plausible number of files" do + assert length(Compile.exchange_dts_paths()) > 50 + assert length(Compile.pro_dts_paths()) > 50 + end + end +end