Porting a large software system is usually described as a rewrite. In practice it is a mapping problem first: before anyone writes a line of new code, someone has to answer "what does the source system actually contain, and where does our target already cover it?"
This article walks through a real case — World-Office (WO), an open-source document-editing suite moving from an OnlyOffice-derived C++ engine to a Rust/WebAssembly (WASM) stack — where two tools did most of that mapping work: CodeGraphContext (CGC), which turns a codebase into a queryable graph, and Agentflow, which dispatches declarative tasks to LLM workers in isolated git worktrees (isolated checkouts) with exact acceptance gates.
World-Office (WO) wants to reach OnlyOffice's (OO's) editing parity without inheriting
OnlyOffice's architecture. OnlyOffice's boundary is a JavaScript SDK (sdk-all.js) that
exposes nearly a thousand (970) asc_* methods; the UI polls them on every selection
change. A naive port would re-expose all thousand-odd names as WASM exports and recreate
the poll storm inside Rust. Before anyone could decide whether that was acceptable, the
team needed measurements, not opinions:
asc_* names exist, and how many does the document-editor UI actually call?Answering these by hand across the 38,208 functions under DesktopEditor/ is not feasible.
The team indexed the source with CGC and let Agentflow turn the questions into ten mapping
tasks (ORM-01 … ORM-10).
CodeGraphContext is an MCP (Model Context Protocol) server and CLI that parses a repository with tree-sitter (a parser generator) and loads the result into a graph database (Neo4j or FalkorDB). Nodes include classes, functions, files and modules; edges include calls, inheritance, containment and imports. Once indexed, the whole codebase becomes a Cypher-queryable structure (Cypher is Neo4j's query language).
For the World-Office campaign the reference repository was Euro-Office's C++ core, indexed
under /home/weiss/git/Euro-Office/core:
cgc index /home/weiss/git/Euro-Office/core
The resulting graph held 3,227 classes and 38,208 functions under DesktopEditor/.
From there, a single Cypher query produced the engine-internals gap list that would
otherwise have taken weeks of manual traversal:
MATCH (c:Class) WHERE c.path CONTAINS "/DesktopEditor/"
WITH split(split(c.path, "/DesktopEditor/")[1], "/")[0] AS a1,
split(split(c.path, "/DesktopEditor/")[1], "/")[1] AS a2,
count(c) AS classes
RETURN a1, a2, classes
ORDER BY a1 DESC, classes DESC
The same approach extracted the 970 unique asc_* SDK names by regex over the unminified
word/sdk-all.js bundle pulled from the running OnlyOffice reference container, and
counted UI call sites across the 18 word controllers. Every figure in the port plan is
traceable to a command like this — the Method sections of the reports quote them
verbatim.
CGC's value here is not "AI magic"; it is measured scope. Before the campaign, nobody knew whether the gap was 20 engine capabilities or 200. The graph answered: 167 areas, of which 19 are severity-3 (engine-critical missing), collapsing to six capabilities (images, shaping, text layout, rasterizer, metafile decode, boolean ops/JS host).
Agentflow is a Rust orchestrator that runs declarative tasks in parallel, each in its own throwaway git worktree (an isolated checkout). A task is JSON: an id, a scope (glob list), an acceptance command that must exit zero, and an optional dependency list. Workers are LLM providers routed by UCB1 trust scores (a multi-armed-bandit routing heuristic). Verification happens before merge — a task whose gate fails is archived as a rejected branch rather than merged.
The World-Office campaign defined ten tasks (ORM-01 … ORM-10) in declarations resembling
config/tasks.json, each scoped to plan/oo-port/ and carrying an exact acceptance gate.
A typical gate looks like this (ORM-01's):
node -e 'const a=require("./plan/oo-port/wasm-api.json");
if(!Array.isArray(a))process.exit(1);
if(a.length<900)process.exit(2);
...'
test "$(wc -l < plan/oo-port/wasm-api.md)" -ge 150
That gate is the contract: it checks row counts, required fields, group diversity, and
report size. The 900-row floor is deliberately loose (the campaign's figure is 970).
The LLM worker cannot be marked complete on prose alone — it has to produce a JSON
ledger with 970 rows and a markdown report of at least 150 lines, or the branch is
rejected and retried (up to max_attempts, with earlier failures fed back into the retry
prompt).
The ten tasks ran in parallel where their scopes did not overlap, each writing a
human-readable report (.md) plus a machine-readable ledger (.json):
| task | question | headline result |
|---|---|---|
| ORM-01 | Which asc_* must the WASM boundary expose? | 970 unique names; only 3 of WO's 35 WASM exports map to an asc_* row |
| ORM-02 | How do engine→UI events reach editor.js? | 386 events; 15 push frames covering ~85% of UI value |
| ORM-03 | What payloads cross the boundary? | 87 asc_C* classes → ~25 serde (Rust serialisation) structs |
| ORM-04 | Which DesktopEditor internals are missing? | 19 engine-critical, collapsing to 6 capabilities |
| ORM-05 | Can WO read/write DOCX like OO? | 193 rows: 32 full / 46 partial / 115 missing |
| ORM-06 | Can WO read/write XLSX like OO? | 118 families: 2 full / 42 partial / 74 missing |
| ORM-07 | Can WO read/write PPTX like OO? | 134 rows: 26 full / 39 partial / 69 missing |
| ORM-08 | What about legacy .doc/.xls/.ppt? | .doc unreadable today; read-only policy adopted |
| ORM-09 | Can editor.js host the OO UI? | 217 components: 60 full / 81 partial / 61 missing / 15 merged |
| ORM-10 | How do the nine maps become one plan? | architecture decision + four phases |
The consolidated port plan (PORT-PLAN.md) records the architecture decision the
campaign was chartered to make: WO does not port OO's API shape one-to-one. It keeps its own
WASM boundary — push events, typed property blobs, typed structural ops — and treats
OO's asc_* surface as a compatibility target served by that boundary.
The rationale is measurable. Of the 970 rows, only 74 are actually driven by the document-editor UI; 896 are never called by it at all. 401 of the 970 are engine-query getters that exist because OO keeps editing state in JavaScript and the UI polls it on every selection change; 510 are data-class member accessors that cross the boundary inside struct payloads (ORM-03) rather than as standalone exports. A Rust port inverts that — state lives in Rust — so most query rows collapse into a small number of pushed state frames plus a few getters.
Porting the 401 polled getters would recreate the poll storm inside WASM with none of the benefits, and porting all 970 names would bloat the boundary with 510 accessors that already travel inside typed structs — and with 896 rows that no part of the document-editor UI ever calls.
The plan then decomposes into four phases: boundary channel (events, property blobs, session), engine and converter parity, UI surface via a data-driven dialog engine, and the long tail with an explicit never-build list for legacy writers.
The headline decision is backed by six sub-decisions, each traceable to a source map:
WsEvent enum
plus a set_event_sink export replaces the 101 asc_on* discovery rows and the
401 poll-prone query rows. editor.js consumes frames exactly like its existing
ctx-menu/wo-command plumbing; unknown events survive as WsEvent::Other { name }.
The UI registers through register_callback/unregister_callback, which covers the
hottest row of the whole surface (asc_registerCallback, 190 call sites across all
18 word controllers).set_property(kind, json) / get_property(kind) pair over ~25 serde
structs in a wo-renderer-wasm::props module covers the asc_C* family. Serde JSON
with camelCase keys reproduces the asc_getX/asc_putX wire schema the OO UI is
written against, so there is no translation layer.asc_addImage, asc_AddMath, asc_setRestriction, table/list/section edits) route
through the existing apply_structure_op/apply_op exports with typed op enums,
preserving WO's CRDT model instead of importing OO's single-writer JavaScript state.#[wasm_bindgen(js_name)] aliases only (ORM-01). Rust stays
snake_case; aliases are added for rows the boundary already covers (e.g.
insert_text → asc_enterText) and for future hot rows — never as 970 new exports.render_page,
layout_document, render_laid_out_page and handle_* as explicit exports. The OO
invisible call graph is deliberately not emulated.raw_ppr, raw_rpr, raw_sect_pr,
PptxExtraPart, extra_parts) is the fidelity strategy for untouched parts; three
named typed engines (fields, style resolution, numbering) plus per-app module additions
close the rows where byte preservation is not enough.| phase | name | headline work | exit criteria |
|---|---|---|---|
| 1 | Boundary channel | events, property blobs, session exports, js_name aliases | 74 UI-caller rows mapped or named as divergence; wasm-api.json rows gain wo_export_name |
| 2 | Engine and converter parity | images, shaping, text layout, rasterizer; DOCX/XLSX/PPTX typed engines | sev3 count in engine-internals.json = 0; named gap tests pass |
| 3 | UI surface | data-driven dialog engine, three prerequisite widgets, dialog backlog | 61 missing ORM-09 rows hosted, merged deliberately, or named as blocked |
| 4 | Long tail and legacy | DOCX/XLSX/PPTX tails, minimal .doc text read, collaboration and protection | never-build list respected; every wo_missing row resolves to done/divergence/blocked |
The three OOXML maps (ORM-05/06/07) and the legacy-binary map (ORM-08) frame the converter strategy:
w:hyperlink w:anchor, and sdt metadata round-trip
are pinned by named gap tests.chart_bridge, table,
hyperlink, pivot_file, rich_text, page, cf_styles, external) feeding the
existing engines; the rest rides extra_parts passthrough.GroupShape recursion, theme resolution, text bodies with
bullets, table borders and transitions are the named gaps; PptxExtraPart preserves
bytes for everything else..doc is unreadable today. The policy is explicit and never changes: never build
.doc/.xls/.ppt writers. A future .doc read lands as in-memory doc→docx
assembly on the CFB substrate, with loud F-402 reporting for unsupported members and
metadata filled from SummaryInformation.The plan ships with ten recorded risks; the most consequential for the architecture:
| # | risk | mitigation |
|---|---|---|
| 1 | Poll-storm regression from naive asc_* shimming | D1 push frames; getters only for what the 15 frames cannot carry |
| 3 | Severity-3 engine capabilities larger than estimated (6 capabilities, 19 rows) | Phase 2 ordered by payoff-per-diff (images first); each capability independently gated |
| 4 | .doc zero coverage surprises stakeholders | loud F-402 reporting; minimal text read gated in Phase 4 |
| 5 | Scope creep from the 970-name surface | D4 aliases only; per-row wo_missing ledger; leakage deferred |
| 8 | Dialog engine becomes a second framework | data-driven builder; Backbone explicitly rejected |
| 10 | CRDT op model vs OO single-writer state | typed op enums reviewed against apply_structure_op before aliasing |
The full table (all ten risks) lives in PORT-PLAN.md.
The two tools are complementary, and neither alone would have produced this.
CGC supplied measurement. It turned "how big is the gap?" into a query result: class/function counts per area, exact SDK method inventories, UI call-site attribution. Without the graph, the port plan would have been sized by gut feel, and gut feel on a codebase of this size tends to underestimate.
Agentflow supplied discipline. It forced each question to become a task with an acceptance gate that a machine could check. The LLM workers did the reading and the writing, but the gates (row counts, required fields, report size) are what made the output trustworthy enough to base an architecture decision on.
Failed attempts were archived, not destroyed, so no completed work was lost; retry prompts carried the earlier errors forward.
Together they closed a loop that is usually broken: measurement informs planning,
planning becomes gated work, gated work produces a machine-checkable ledger, and the
ledger becomes the living contract (wo_export_name/wo_missing fields that later
phases update as exports land).
Every figure in this article traces to a command you can re-run:
# Index the source (once)
cgc index /home/weiss/git/Euro-Office/core
# Engine-internals gap list (areas, classes per area)
# (Cypher query shown in "Step one")
# SDK surface (970 unique asc_* names)
grep -oE 'asc_[A-Za-z0-9_]+' /tmp/sdk-word.js | sort -u | wc -l
# Event inventory (386 rows) and the 15 push frames
# see plan/oo-port/wasm-events.json and wasm-events.md §5
# UI call-site attribution across the 18 word controllers
# see plan/oo-port/wasm-api.md §2
# Acceptance gate for a mapping task (ORM-01 example)
node -e 'const a=require("./plan/oo-port/wasm-api.json");
if(!Array.isArray(a))process.exit(1);
if(a.length<900)process.exit(2);'
test "$(wc -l < plan/oo-port/wasm-api.md)" -ge 150
The machine-readable companions (wasm-api.json, wasm-events.json,
wasm-data-classes.json, engine-internals.json, ooxml-*.json, ui-webapp.json)
are the living contracts: later phases update wo_export_name/wo_missing fields as
exports land, so the maps never go stale.
If you are facing a port or a migration, the cheapest thing you can do first is index the source system and ask it counting questions.
The second cheapest is to write your acceptance criteria as executable gates before you let any agent touch the code. The World-Office case shows that a ten-task campaign, run over a few days on a single machine, can turn a vague "we need OnlyOffice parity" into a 970-row contract, a four-phase plan, and a recorded architecture decision — with every number traceable to a command you can re-run.
What worked. Three things carried the campaign. First, indexing the source before
planning: once the graph could answer counting questions, every later decision had a
number behind it. Second, writing acceptance gates before letting an agent touch the
code: the row-count and report-size floors turned LLM output into something a reviewer
could trust without re-reading the source. Third, recording a "never-build" list up
front: the explicit refusal to write .doc/.xls/.ppt writers (and to port all
970 names) is what kept the plan finite.
What we would improve. The bundle-leakage problem (~301 spreadsheet/PDF/plugin rows
inside sdk-all.js) cost a full mapping task to disentangle; indexing only the
word-specific bundle earlier would have saved it. The 15 merged-into-editor.js
components in ORM-09 show that "merged" is a fuzzy status — a sharper definition
would have made the scoreboard easier to read. And the gate floors (900 rows, 150
lines) are loose by design; tightening them once the first clean run lands would catch
regressions in later campaigns.
Outlook. The plan is deliberately front-loaded: Phase 1 unblocks every controller
port, Phase 2 closes the six severity-3 capabilities, and only then does the UI
surface (Phase 3) and the long tail (Phase 4) get built. Beyond the four phases, the
natural extensions are real-time collaboration (co-authoring identity and roles already
modelled as serde types), content-control and document protection, and a .doc reader
landing as in-memory doc→docx assembly. The wo_missing ledger in each JSON map is
the mechanism that keeps all of this honest: every row must end as done, declared
divergence, or named blocked-on, and nothing is allowed to silently disappear.