ArcGIS JS SDK → Honua JS SDK migration punch list

This is an honest accounting of where the Honua JS SDK's two public-facing claims stand:

  1. "Parity with the ArcGIS JS SDK" — the symbol surface app authors can import and call against.
  2. "Automated app conversion" — the npm program is @honua/honua-migrate. @honua/sdk-js/migration forwards to that package. src/migration/codemod.ts is the in-repo suite, not the npm program. It rewrites ArcGIS imports and constructors to Honua compat shims.

It is intentionally written so a reader can tell what is shipped, what is partial, and what is not started. None of the entries below are aspirational — they are concrete work items.


What is true today

Parity surface (src/esri-compat/)

The compat module ships shims for the constructors enumerated in src/migration/codemod.ts::SUPPORTED_ARCGIS_MODULE_KIND_BY_PATH. Every kind in that map has a row in parity-matrix.ts, and every row is asserted by test/migration-parity-matrix.test.ts. The shims back onto real Honua surfaces in src/core/ (HonuaClient, HonuaFeatureService, HonuaImageService, HonuaMapService, …) rather than being throwaway no-ops. Specifically:

The MapViewLayerViewCompat returned from view.whenLayerView() now carries filter / effect / visible state and forwards filter predicates through queryFeatures / queryFeatureCount / queryObjectIds, merging where, objectIds, geometry, spatialRelationship, and timeExtent with any per-call options (layer-view filter ∧ caller-provided options).

Sketch and Editor now accept ArcGIS-shaped snappingOptions (enabled, distance, featureEnabled, selfEnabled, featureSources) and map them onto the renderer-neutral SnappingConfig from @honua/sdk-js/contract (snappingOptionsToSnappingConfig). The contract snapping engine (SnapIndex + resolveSnapCandidate) resolves vertex / edge / feature candidates deterministically over a packed-grid spatial index, invalidates on optimistic edit apply/rollback (createSnapIndexEditSessionHooks), and the MapLibre runtime binding (bindEditSketchSnapping in @honua/sdk-js/runtime) wires pointer-move, snap/unsnap events, and a default indicator layer. selfEnabled is stored for parity but self-snapping needs the host to index the active sketch source; 3D snapping is out of scope with the rest of the SceneView surface.

Automated app conversion (src/migration/codemod.ts)

src/migration/codemod.ts is the in-repo suite, not the npm program. @honua/sdk-js/migration forwards to @honua/honua-migrate. The suite is a real AST rewriter built on the TypeScript Compiler API (it does not use regex). It:

Everything in this section is covered by tests under test/ (migration-codemod.test.ts is 75 cases; migration-report.test.ts, migration-gating.test.ts, and migration-parity-matrix.test.ts hold the surface stable).


What the claim does not yet cover

Parity gaps

  1. 3D / SceneView. SceneView exists as a compat class but it shares the 2D MapView behavior under the hood. Anything that requires a WebGL/CesiumJS path (environment, viewingMode: "global", goTo with altitude, Ground, ElevationLayer, scene layers, Camera, qualityProfile) is not implemented. The codemod sets the scene-3d-detected blocking flag when it sees such imports, so the gate fails closed.

  2. Scene layers. SceneLayer, BuildingSceneLayer, IntegratedMeshLayer, PointCloudLayer, MeshLayer, ElevationLayer, VoxelLayer have no shims. These all need server-side glTF/I3S support (honua-server ticket).

  3. Geoprocessor / NetworkAnalyst (beyond RouteTask). Locator is no longer in this bucket: LocatorCompat shims addressToLocations, addressesToLocations, locationToAddress, and suggestLocations onto the provider-pluggable geocoding contract in src/geocoding/, and LocatorSearchSourceCompat is the Search widget's address backend — client-side, with no honua-server surface. The caller supplies the endpoint (Nominatim / Photon / Pelias / a Honua GeocodeServer), so the codemod rewrites the constructor but cannot choose the provider; that one wiring step stays assisted, and @arcgis/core/rest/locator function imports are rewritten with a TODO for the URL-to-LocatorCompat swap. Provider capability misses (no suggest on Nominatim) throw HonuaCapabilityNotSupportedError instead of degrading to a forward geocode. Batch geocoding is client-side — one provider request per address, not a server batch endpoint. Still unshimmed: service-area, OD-cost-matrix, closest-facility, and non-network geoprocessing, which do need their own surfaces under src/core/ and matching compat classes.

  4. Query argument depth. Shipped (Task E). The codemod now deep-transforms new Query({...}) argument literals into the Honua QueryFeaturesRequest shape: renames start → resultOffset, num → resultRecordCount, outSpatialReference → outSr, spatialRelationship → spatialRel; splits geometry: { type, ...rest } into a sibling geometryType field with the matching esriGeometry* enum value; and lowercases outStatistics[i].statisticType against the STATISTIC_TYPE_MAP keyset (count/sum/min/max/avg/stddev/var). Divergent / dynamic shapes emit a manual TODO with a precise reason — specifically: timeExtent (no Honua field; use extraParams.time), quantizationParameters (server-side optimization with no Honua equivalent), relationParam (no Honua support), geometry without a string-literal type discriminator, and outStatistics items with a non-literal statisticType or an unknown enum value. The shim QueryCompat accepts either ArcGIS or Honua property spellings (spatialRelationship / spatialRel, outSpatialReference / outSr, num / resultRecordCount, start / resultOffset) so codemod output and hand-written callers both validate.

  5. Popup actions / popup templates with actions, outFields: ["*"] expansions, and fieldInfos formatters. These rewrite cleanly only for the simple case; arrow-function field-info format callbacks fall through to manual TODO.

  6. Widget UI behavior. The widget shims accept the same constructor options ArcGIS does. A shim that constructs a HonuaWidgetHost renders after the app registers the web-component kit. A shim that does not construct a host stays state-model-only. The current lists are in widget kit registration. Visual parity (icons, ARIA, CSS class names that downstream apps style against) is not byte-identical; apps that rely on calcite-action---prefixed selectors will need style work.

  7. Typed-surface divergences from the emulated ArcGIS shape. The shims are structurally compatible with the ArcGIS surface they emulate except where this list says otherwise. The audit of array-valued properties (#1013) settled as follows.

    Array mutability — fixed, no divergence. ArcGIS declares these as mutable arrays and the shims now do too: FeatureFilterCompat.objectIds, GeoJSONLayerCompat.outFields, GeoJSONLayerCompat.fields, ImageryLayerCompat.bandIds, WFSLayerCompat.outFields. Each is copied out of the caller's input on construct/update, so layer.outFields.push("TYPE") is honored (the shim reads the array it stored) without aliasing the array the caller passed in. The corresponding *Options input properties stay ReadonlyArray on purpose: an input that accepts a readonly array is strictly more permissive than ArcGIS's, so it is not a divergence.

    FeatureFilterCompat.objectIds element type — deliberate, kept. ArcGIS declares number[]; the shim declares Array<number | string>. Honua's non-Esri sources (OGC API Features, GeoJSON, STAC) carry string feature ids, and narrowing the property to number would make the shim unable to hold ids it has to round-trip through queryFeatures / queryObjectIds. Because (number | string)[] is not assignable to number[], handing a compat filter straight to an un-migrated ArcGIS construct still does not typecheck — so the shim exposes FeatureFilterCompat.toEsriProperties(), which projects onto the exact ArcGIS FeatureFilterProperties shape and throws on an id ArcGIS cannot represent rather than silently dropping it and returning a filter that selects a different feature set. The codemod does not create that handoff on its own any more; see codemod gap 7.

  8. reactiveUtils.watch semantics (partial). Compat watch is property-name-based and synchronous. ArcGIS watch accepts an accessor function and returns a WatchHandle with pause() / resume(). Single-property accessors — reactiveUtils.watch(() => view.scale, h) where view is a tracked compat instance — are now rewritten by the codemod to reactiveUtils.watch(view, "scale", h). Multi-property accessors (() => [view.scale, view.zoom]) and complex bodies still fall through to a manual TODO. pause() / resume() on the handle is still unsupported.

Codemod gaps

These are the load-bearing pieces required to turn "rewrites known constructors" into "automated app conversion":

  1. Event-name remap (Task D, partial). The codemod now rewrites well-known divergent event names on .on() and .watch() calls in files that have at least one ArcGIS import being touched. The dictionary is in ARCGIS_TO_COMPAT_EVENT_REMAP in src/migration/codemod.ts and currently covers layerview-create*, layerview-destroy, visibility-change, extent-change, rotation-change, scale-change, zoom-change, center-change, spatial-reference-change, padding-change, constraints-change, highlight-options-change, basemap-change, ground-change, portal-item-change, active-basemap-change, camera-change, quality-profile-change, viewing-mode-change, and refresh. Receiver-aware scoping is now in place: the rewrite only fires when the receiver is an Identifier whose binding came from new XCompat(...) where X was a tracked ArcGIS import (via resolveAssignedIdentifierForNewExpression). Receivers that don't trace back to a tracked compat instance (e.g., someEmitter.on("layerview-create", h)) are left alone even when the file has ArcGIS imports. What is still missing: pointer/keyboard event handlers (click, pointer-down, key-down, …) which compat doesn't emit yet.
  2. Dynamic / CJS imports (Task F, partial). Dynamic ESM imports (import("@arcgis/core/...")) are rewritten through the isArcGisDynamicImportCall pass and have test coverage for SceneView, esriConfig, esriRequest, and IdentityManager. CommonJS require("@arcgis/core/...") is now auto-rewritten for the honua-compat target when constructor options are safe: the codemod emits const { XCompat } = require("@honua/sdk-esri-compat"); and rewrites new X(opts) to new XCompat(opts) in .cjs files and .js files containing CommonJS markers. The original const X = require("@arcgis/core/...") declaration is now pruned when no other references to X remain in the file (constructor calls are the only references in the typical case; the codemod scans for non-constructor uses and preserves the require if it sees any). The esri-leaflet target still emits a manual TODO for CJS require constructors.
  3. Module re-exports beyond the registered set. The codemod already follows localArcGisReExports for one level. Deeper barrel-file chains (./esri.ts → ./layers/index.ts → …) aren't followed.
  4. view.map.add(layer) argument-shape transformations. When a new FeatureLayer({ ... }) is rewritten, its option literal is passed through verbatim. We do not translate ArcGIS-shaped properties whose Honua-side names differ (e.g., renderer.field casing, popupTemplate.content block shapes). The shim accepts the ArcGIS shape at runtime, but the report should flag known-divergent property values so app authors can confirm intent.
  5. WebMap JSON → MapLibre style derivation (honua-maplibre target). Partially shipped. src/map/webmap-maplibre.ts exposes webmapJsonToMapLibreStyle(webmap, options), a pure converter that delegates the heavy lifting to the existing src/webmap/ pipeline — parseWebMap orchestrates convertBasemap, convertOperationalLayer, convertRenderer, convertPopupInfo, convertLabelingInfo, and convertExtent/convertInitialViewpoint — and returns a HonuaStyleSpecification ready for map.setStyle(...) plus a structured manualGaps: WebMapMapLibreManualGap[] array. manualGaps is the single source of truth for unsupported constructs: every entry carries { kind, reason, path?, layerId?, expression?, context? } and the kind taxonomy covers arcade-expression (popup expressionInfos and complex Arcade label expressions), unsupported-renderer (heatmap, dotDensity, and any other renderer the converter doesn't handle), scene-3d (top-level ground / camera / viewingMode, ArcGISSceneServiceLayer, elevationInfo, heightInfo, sceneProperties), dashboard-reference, experience-builder-reference, custom-widget-reference (applicationProperties.viewing.widgetsOnScreen and the top-level widgets array used by host shells), plus pass-throughs for unsupported-symbol, unsupported-layer-type, unsupported-feature-collection, and unsupported-webmap-version. The migration codemod now wires this converter into its honua-maplibre constructor-rewriting pass: any new WebMap({ ... }) call whose argument is a static WebMap JSON object literal (top-level operationalLayers and/or baseMap, fully literal values, no portalItem) is rewritten to webmapJsonToMapLibreStyle({ ... }) from @honua/sdk-js/map, and the helper's manualGaps array is propagated into the migration report as per-gap web-map manual TODOs (Arcade, unsupported renderer, scene/3D, dashboard shell, …). What still requires manual review: dynamic / portal-loaded WebMaps (new WebMap({ portalItem: { id: someId } }), WebMap.load(), identifier references, computed properties, spreads) still emit a web-map manual TODO with no rewrite — they need a runtime fetch the codemod cannot synthesize. The gap surface is also intentionally honest about its ceiling, since Arcade execution, scene/3D rendering, Dashboard widget hosts, Experience Builder pages, and custom-widget plugins all require runtime support the JS SDK cannot synthesize from JSON alone. Each emitted gap is a // TODO anchor for an app author, not a promise of automated migration.
  6. Compat → ArcGIS seams (detected, not bridged). Shipped as detection (#1012). A file can hold an in-scope producer whose only consumer is out of scope — the shape the OSS-corpus deep lane hit in lujoh/owls_of_bavaria, where a FeatureFilter feeds an @arcgis/core/layers/support/FeatureEffect the codemod does not migrate. Rewriting only the producer leaves a compat value in an un-migrated ArcGIS constructor's hands: a seam that does not typecheck, previously scored as a clean auto-migration. The codemod now detects the handoff syntactically — new X() directly in an out-of-scope ArcGIS consumer's argument, a local bound to one, or a local function that returns one, plus assignment onto an out-of-scope instance's property — holds the rewrite back, and emits a manual TODO naming both sides. Seam call sites are counted in manualCallSites and broken out as metrics.seamCallSites / JsMigrationReport.seamCallSites; they are never counted as auto-migrated. What is not done: the codemod does not bridge the seam (no inserted toEsriProperties() call, no consumer rewrite), and the analysis is file-local and bounded by design — a value that crosses a module boundary or moves through a data structure it cannot follow is not reported.
  7. End-to-end demo conversion. Shipped at examples/geoservices-source-app/ + test/migration-e2e.test.ts. The sample is a hand-written parcel viewer exercising FeatureLayer (with outFields + popupTemplate), Map, MapView, an untouched view.on("click", …) handler, and an event-name-remapped layer.on("layerview-create", …) handler. The e2e test copies it into a tempdir, runs the codemod with target: "honua-compat", asserts every expected rewrite landed and the readiness gate is "ready", and then runs tsc --noEmit against the migrated source with paths resolving @honua/sdk-esri-compat to the workspace src/esri-compat/ entry. The CI job runs this harness immediately after the unit test step. Remaining work: extend the sample to additional layer kinds (Imagery / VectorTile / WMS / WFS / GeoJSON) so the harness exercises the full surface, not just the parcel viewer slice.

What's required to make the claim defensibly true

In rough effort order:

Bucket Effort Notes
Codemod event-name remap (Task D) 1–2 days Pure JS-side work; needs an event-name dictionary plus AST pass.
Codemod dynamic-import + CJS (Task F) 1–2 days Mirror the static-ESM path; care needed for awaited dynamic imports' destructuring.
Query argument deep-transform (Task E) 3–5 days Shipped: codemod renames start/num/outSpatialReference/spatialRelationship, splits geometry into geometry+geometryType, normalizes outStatistics[i].statisticType against STATISTIC_TYPE_MAP, and emits precise manual TODOs for timeExtent, quantizationParameters, relationParam, dynamic statisticType, and geometry without a literal type. QueryCompat accepts both ArcGIS and Honua property spellings.
Locator compat (Task C, geocoding half) 1 week Shipped: LocatorCompat + LocatorSearchSourceCompat ride the provider-pluggable geocoding contract, so no HonuaGeocodeService was needed. The provider endpoint is caller-configured, which keeps the wiring step assisted.
Geoprocessor compat (Task C, remainder) 1–2 weeks Requires a HonuaGeoprocessService surface (server work); service-area / OD-cost-matrix / closest-facility likewise.
Scene-layer compat (Task A-rest) 4–8 weeks Requires I3S/glTF pipeline on honua-server; not pure-JS work.
E2E demo conversion harness (Task I) 1 week Shipped: examples/geoservices-source-app/ + test/migration-e2e.test.ts, wired into the JS SDK CI workflow after the unit-test step.

With Tasks D/F/E and the geocoding half of Task C now shipped, "automated app conversion" reaches deterministic constructor rewrite + event-name remap + dynamic/CJS imports + Query argument deep-transform + Locator/Search address backends, plus a manual-TODO report for everything else. The remaining rows (Geoprocessor/NetworkAnalyst and Scene-layer compat) still gate the unqualified "parity with ArcGIS JS SDK" claim — that should remain qualified as "deterministic 2D parity; scene/3D and advanced tasks remain assisted migration" until they land.