# Xojo Migrate

> Migrate a Xojo desktop project from API 1.0 (which Xojo's own documentation calls "pre-API 2.0") to API 2.0: a rule-driven, confidence-tiered conversion of the source code, committed one category at a time, that fixes the index bases, sentinels and epoch shift a rename alone leaves wrong. Replaces deprecated APIs such as MsgBox, Dim, RecordSet, SQLSelect, Date, InStr, Mid, Len, Ubound, ListBox members, GetFolderItem and error-code checks. Covers more than a thousand deprecated and removed symbols with hundreds of vetted conversion rules. Invoke this skill only when the user asks for it by name: it rewrites a whole project across many commits, so never start it on inference. When a user wants a migration, tell them to run the skill themselves (/xojo:xojo-migrate in Claude Code, $xojo-migrate in Codex) and wait for them to do it. Questions about API 1.0 versus API 2.0, whether a symbol is deprecated, and what replaced it belong to the xojo skill instead, which holds the documentation and the deprecation indexes

- Skill: `scannedinc/xojo-migrate` (Agent Skill, multi-file: 37 files)
- Install (CLI): `npx skillmds@latest add scannedinc/xojo-migrate`
- Raw SKILL.md: https://api.skillmd.com/api/skills/scannedinc/xojo-migrate/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: scannedinc (https://skillmd.com/u/scannedinc)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/scannedinc/xojo-migrate

---


# Xojo API 1.0 → API 2.0 migration

**The two names for the old API.** This skill calls it API 1.0. Xojo's own documentation almost never names it at all, and where it does the term is "pre-API 2.0". The two mean the same generation, so search for either.

Rule-driven, confidence-tiered conversion of Xojo source code. This skill bundles a deprecation matrix (more than a thousand symbols, generated from Xojo's own deprecation docs and the IDE's deprecation database) and hundreds of reviewed conversion rules with find/replace regexes, caveats, and before/after examples, each one machine-checked against the rule that carries it.

Detection is IDE-first: when the sibling **xojo-ide** skill can reach a running IDE, the worklist comes from Xojo's own Analyze Project, which resolves receivers and types the way no regex can. The bundled scanner remains as the fallback—no IDE reachable, project will not open, or the user asks for it—and as the closing cross-platform check either way (phase 2 explains the split). The conversion rules, caveats and traps apply identically whichever path located the work.

The mindset that matters: **the dangerous bugs here are runtime bugs, not compile errors.** Several API 1.0 functions changed index base (1-based → 0-based), one changed its not-found sentinel (`InStr`'s 0 → `IndexOf`'s -1), and `Date`'s epoch moved 66 years. A rename that compiles can still be wrong. That is why rules carry confidence tiers and why the workflow below fixes *semantics before names*.

## What this is, and what it needs

**Requirements.** Desktop Xojo projects saved in **text format**, built with **Xojo 2021r3 or later** (when the `Desktop*` classes arrived), in a **git repository**, with **python3** on PATH for the bundled scripts. The **Xojo IDE is required**—this skill never compiles anything itself, so every checkpoint is an IDE compile: run through the xojo-ide skill when it can reach a running IDE, performed by the user otherwise. iOS, Web and Android surfaces are out of scope.

**Provenance, and what that means for trusting it.** The deprecation matrix is *derived* from Xojo's own published documentation—the deprecated-symbol indexes and the per-release deprecation tables—and from the deprecation database inside the Xojo IDE, so its coverage is a property of those sources rather than of anyone's memory. The conversion rules, caveats and traps are hand-written on top of it and reviewed against real migrations; they are the opinionated part. Where a mapping could not be verified against a documentation page, the row says so in its `note`. Treat a row's note as part of the answer, not decoration.

Xojo, Inc. is not affiliated with this skill and has not reviewed it, and "Xojo" is their trademark. No Xojo documentation is redistributed here; the **References** section links to it. This skill is MIT-licensed (`LICENSE`, beside this file) and comes with no warranty—it will happily hand you a wrong rename if you skip the receiver checks it keeps insisting on.

## Hard rules

1. **`high` describes the mapping, not the blast radius.** A `high` rule means *the replacement is the verified correct API 2.0 form*: the old name really does become that new name, with no index, sentinel, or epoch change hiding in it. It does **not** mean "safe to Replace All". No rule in this skill is authorized for a blind project-wide replace, because no regex here can tell code from a string literal, a call from a declaration, or one receiver type from another. Read the matched line before you change it. What the tier buys you is *how hard you have to look*: `high` needs a glance, `medium`/`low` need the receiver resolved, `manual` needs a rewrite.
2. **Member renames are type-blind, and this rule outranks rule 1.** `.Append` means `.Add` on an array but `.AddObject` on a Group2D; `.RemoveRow` is deprecated on ListBox but valid API 2.0 on RowSet; `.ColumnType` is deprecated on ListBox and *live* on RowSet. Before renaming any `.Member`—at **any** confidence—find the receiver's declared type and confirm the replacement applies. More than half of the `high` rules are anchored on a literal dot and rename a member; they are high because the mapping is right for the stated receiver, and they still need that receiver checked.
3. **A receiver must be an identifier—never a literal, never a parenthesised expression.** Xojo has no member access on either. Both of these are **syntax errors**, not stylistic choices:

   ```
   "0123456789".IndexOf(char)      ' literal receiver
   ("00" + Hex(red)).Right(2)      ' parenthesised-expression receiver
   ```

   A receiver is an identifier, or a chain of member accesses and calls off one—`s.Trim.Uppercase`, `f.Child("x").Name`, `dict.ExportXML(p).ToString` are all fine. This decides whether a whole class of conversions is *expressible at all*, so it outranks any rule's convenience.

   **What counts is why the parentheses are there, not that they are there.** Parentheses that *group an expression* block member access. Parentheses that belong to a call, a cast, or an index do not, and the result of any of those three is a perfectly good receiver:

   ```
   EncodeBase64(mb, 45).Split(delim)    ' OK -- global function result
   Dictionary(v.ObjectValue).KeyCount   ' OK -- cast result
   values(index).ReplaceLineEndings("") ' OK -- indexed access
   ("00" + Hex(red)).Right(2)           ' SYNTAX ERROR -- grouped expression
   ```

   Xojo's own documentation writes `GetType(d).GetProperties` throughout, so the call-result form is not a gray area. This matters because it is the shape you land on whenever a global→method rule skips a call for its *argument* rather than its receiver, and you then convert it by hand.

   Every global→method rule here captures an identifier receiver and silently skips everything else. For a skipped call there are exactly **two** outcomes: introduce a local variable, or leave the deprecated global in place. Hand-writing the method form is not a third option—it does not compile. `conversion-traps.md` §4 says which to choose and when.
4. **Fix `InStr`/`IdxField` result comparisons and index arithmetic BEFORE renaming the functions.** Renaming first hides the remaining wrong arithmetic. Details in `$SKILL/references/conversion-traps.md`.

   "Before" means *within the same edit*, not in an earlier commit. Splitting them leaves a tree where `InStr(...) >= 0` is API 1.0 code read with an API 2.0 sentinel—always true, compiles, wrong—which **Commit discipline** below forbids. Convert each site atomically, comparison and rename together, and commit the sites as one batch.
5. **Byte-variants before base names**: `LenB/MidB/InStrB/ReplaceB/SplitB` before `Len/Mid/InStr/Replace/Split`, or the base-name pass mangles the byte variants.
6. **Nothing compiles Xojo except the Xojo IDE.** There is no standalone compiler. When the xojo-ide skill can reach a running IDE, run the checkpoints yourself—`analyze` compiles the project's front end and reports errors and warnings with file, method and line. Run each checkpoint as one bracketed session—`analyze --project <path> --discard`, which opens fresh, analyzes, and closes—per **IDE session discipline**: the project never sits open in the IDE while you edit. When it cannot, every phase ends with the user compiling / running Analyze Project and reporting back the error and warning counts. Either way an analyze pass is not a runtime pass: never claim a conversion "works"; say it analyzed clean and awaits the runtime check, which is always the user's—the dangerous bugs here are runtime bugs.
7. **Match rule casing exactly** in replacements (Xojo identifiers are case-insensitive, but canonical casing keeps the code readable and consistent with the docs).
8. **Git is not optional, and one commit is not enough.** Do not begin editing outside a git repository. Commit at every checkpoint, one commit per category, so each batch can be reviewed and reverted on its own. A single commit at the end is a wall of renames nobody can review; a rule that over-matched is then indistinguishable from one that worked. See **Commit discipline** below.

## Preconditions (phase 0)

Before editing anything, confirm with the user:

- **Git repository, clean tree, migration branch.** This is a hard requirement, not a suggestion: several rules can over-match, and the recovery plan is `git revert`, not memory.

  ```
  git -C <project> rev-parse --is-inside-work-tree   # must print exactly: true
  git -C <project> status --porcelain                # must print nothing
  ```

  "Work tree" here means *the checked-out files you edit*, as opposed to the `.git` database, and has nothing to do with the `git worktree` command or with branches. The question being asked is "is there a checkout here I can edit and commit?"

  Check the *output* of the first command, not just its exit status: outside a repository it exits non-zero, but inside a **bare** repository (a `.git` database with no checked-out files, the kind a server hosts) it exits 0 and prints `false`. `true` is the only acceptable answer.

  - **Not a repo?** Offer `git init` plus an initial commit of the current state. Do not proceed until that baseline exists; without it there is nothing to diff the migration against.
  - **Dirty tree?** Stop and ask the user to commit or stash. Their in-progress work must not end up inside a conversion commit.
  - **Then branch:** note the current branch name first (`git branch --show-current`); that is the base you will diff against later. Then `git switch -c api2-migration`. The whole migration is reviewable as one branch and abandonable with one command.
  - **Binary/XML project?** The text-format save (below) is itself the first commit, before any conversion.
- **Text-format project.** Source must be `.xojo_code` / `.xojo_window` etc. If the project is binary (`.xojo_binary_project`) or XML, ask the user to File ▸ Save As... in "Xojo Project" (text) format first. `$SKILL/scripts/scan.py` detects this and prints the instruction.
- **Xojo version.** Desktop* control classes need Xojo 2021r3+. Ask which release they build with.
- **Deprecation warnings on.** They are off by default, stored **per project**, and Analyze Project says nothing about deprecations until they are on. With the IDE reachable, do it yourself, in this order: close the project (`python3 -m xojoctl close --save`), run `python3 $SKILL/scripts/analysis_warnings.py <project-dir> --enable`, reopen. The ordering is mandatory—the IDE rewrites the settings file when the project closes, so a patch made while it is open is silently undone. Otherwise ask the user: Project ▸ Analysis Warnings → check both "Item1 is deprecated" warnings and "Show API 2 Desktop control deprecations". Mechanism and caveats in `$SKILL/references/ide-vs-source.md`.
- **The project is hands-off while the migration runs.** Say this to the user up front: outside the steps the workflow explicitly hands them—the phase 1 converter run, plus the checkpoints and the runtime pass when you cannot reach the IDE—the project is not theirs to touch until the branch is handed back. On the IDE path the workflow opens and closes the project repeatedly (see **IDE session discipline**), so any IDE window it leaves open is transient and about to be closed without saving; edits the user makes in the IDE mid-run are silently discarded by the next cycle, and edits they make on disk land inside conversion commits where they do not belong. If they need to change something mid-migration, they say so; you stop at a clean checkpoint and hand the tree back.

## Commit discipline

**One commit per category.** Arrays, then ListBox, then Database, then Date, and so on, each its own commit, so the reviewer reads "what happened to the arrays" rather than 4,000 renamed lines. Within a category, `high` and `medium`/`low` work can share a commit; across categories, never. Splitting one category into several commits by hazard class is allowed and sometimes better—byte variants, sentinel rewrites and compound-receiver deferrals each read cleanest alone. The rule is a ceiling on commit scope, not a floor.

**Commit only after the checkpoint is confirmed.** The order is always: make the edits → Analyze Project (run it yourself through xojoctl when the IDE is reachable, per hard rule 6; ask the user otherwise) → read the result → commit. Never commit changes whose compile status nobody has checked.

**When the user declines the checkpoints.** Some users will opt out of compiling per category and ask you to run the whole migration through. That is their call to make, and it does not change the commit discipline—it changes what the `Compiles:` trailer says. Record the fact in **every** affected commit, in these words, rather than omitting the trailer:

```
Compiles: NOT VERIFIED -- user opted to skip the per-category IDE checkpoints
```

Silence reads as "checked and fine" to anyone reading `git log` later, which is precisely the claim you are not in a position to make. Say it once to the user up front as well: without the checkpoints, a rule that over-matched will surface at the end against the whole branch instead of against one category, and `git revert` of a single commit stops being a usable recovery.

**The two categories expected to break the build** are Date and error handling: the compile errors *are* the worklist. Do not commit a half-migrated Date category as if it were finished. Either carry the mechanical pass and its manual follow-up into one commit, or commit the mechanical step with the breakage stated plainly in the message body and land the fix in the very next commit.

**Message format.** Match the project's existing convention; read `git log` first. If there is none, use:

```
Xojo API 2.0: <category>, <what changed>

<n> occurrences across <n> files. Rules applied: c2r0, c2r5, ...
Skipped: <what and why>
Still manual: <what remains>

Compiles: analyze <e> errors, <w> warnings via xojoctl / analyze <e> errors, <w> warnings reported by <user> / expected errors, fixed in the next commit
```

Listing the rule ids matters: when a rename turns out wrong three commits later, the id is how you find every other place that rule touched.

**Never push, and never commit outside the migration branch.** Pushing is the user's call. At the end, hand them the branch and the commit list.

## IDE session discipline

**The project is open in the IDE only while the IDE is doing something for you, and closed before you edit anything on disk.** The IDE loads the whole project into memory and never watches the disk: while a project sits open, disk edits are invisible to its analyze, a single IDE-side save silently overwrites them, and the open window reads as an invitation for the user to keep working in an IDE whose in-memory state the workflow is about to discard. Closing before you edit makes all three impossible instead of merely unlikely.

Every IDE interaction is therefore a bracketed session, and edits happen only between sessions:

```
open → analyze → close → edit on disk → open → analyze → close → commit → next category
```

**One command runs the whole bracket.** `xojoctl analyze --project <path> --discard` is the session: it discard-closes a stale open copy of the project, opens it fresh from disk, analyzes, and closes without saving—verified rather than assumed, and exit 4 means the bracket broke and the project is still open, which is not a state to edit in. Use it for every checkpoint. The rules below govern the manual commands (`open`, `analyze`, `close`) when a step must run separately.

- **This costs nothing.** With the commands this workflow uses, making an open IDE see disk edits is already a close and a reopen (the xojo-ide skill's editing-and-reload reference), so the close/open pair is being paid at every checkpoint regardless. The discipline only moves the close to *before* the edits, so no moment exists in which an open IDE and newer disk files disagree—a reasoning even `xojoctl reload` does not change (this workflow deliberately does not use it): a reload happens after the edits, and the hazard is the open window during them.
- **A session may begin with the project already open.** The checkpoint command handles it: a stale open copy of the target is discard-closed and the project reopened fresh, so phase 0's reopen and the user's phase 1 converter run (once saved) hand off cleanly. What the discipline forbids is *editing* while the project sits open, not finding it open.
- **End an analyze session with `close --discard`.** The session made nothing worth saving, and the disk—where git is—is the migration's only source of truth; a `--save` here would let the IDE write its in-memory copy over it. The one deliberate `--save` in this workflow is phase 0's analysis-warnings step, which needs the IDE to write the settings record as the project closes.
- **Confirm the frontmost project first.** Xojo is single-instance and `xojoctl` acts on the frontmost open project. If anything else may be open in the IDE, run `projects` before `analyze` or `close`—an unchecked session can report on, or close, a project that is not yours.

**Report the counts, every time.** Each analyze returns an error count and a warning count; report both to the user at every session, beside the previous session's numbers—"analyze: 0 errors, 210 warnings (was 260: the ListBox category cleared 50)". The falling warning count is the migration's only live progress meter, and a checkpoint where it does not fall—or where errors appear outside the two categories expected to break the build—is a stop-and-look signal, not a footnote. Two caveats keep the numbers honest: analyze counts every warning type the project's settings enable, not only deprecations, so the finish line is *zero deprecation warnings* (phase 8), not zero warnings; and when the IDE is unreachable and the user runs Analyze Project, ask for both counts and track them the same way.

## The category boundary

Every category—and every burn-down batch, and every playbook sub-pass that commits alone—ends with the same four steps. Run them verbatim; nothing here is derived per category. (`$PROJ` is the `.xojo_project` manifest path and `$PROJDIR` its directory; `$MIG` is the migration workspace, `category-playbook.md`; `NN` is the next checkpoint number, `<slug>` the category.)

**1. One command block** — analyze, diff, oracle, as separate statements (not an `&&` chain: during the burn-down, analyze exits 1 with `project_errors`, which is input, not failure; exit 4 means the bracket broke—stop, `close --discard`, re-run):

```
python3 -m xojoctl analyze --project "$PROJ" --discard --json > "$MIG/cp-NN-<slug>.json"
python3 $SKILL/scripts/checkpoint.py "$MIG/cp-NN-<slug>.json" "$MIG/cp-<prev>.json"
python3 $SKILL/scripts/global_to_method.py "$PROJDIR" --oracle
```

**2. Read the differ top-down.** Its verdict line comes first. SUSPECT (identical error and warning counts) means the IDE may have analyzed a stale in-memory copy—re-run the bracketed analyze before believing anything else. STOP (NEW errors) means do not commit; a regression outside the two expected-to-break categories is a stop-and-look, not a footnote. Then check symbols **cleared** against the rules you just ran, and symbols **introduced** against `pass-hazards.md` §3—anything else introduced is over-match.

**3. Reconcile the oracle.** The oracle's dry run classifies every remaining deprecated global: each **would-convert** site is either this category's remaining work or a PARKED line in `$MIG/queues.md`; each **illegal-receiver** site is a DEFERRED line whose `#Pragma` marker is already placed. An orphan in either direction is a forgotten site—fix it before the commit, not in phase 8.

**4. Drain, then commit.** If this boundary closes a pass that PARKED entries were blocked on, drain them (playbook, "The drain step"), then commit with the body template below. Every numeric line is a paste from the differ or the applier; composing the body is one sentence of prose.

```
Xojo API 2.0: <category> -- <one-line what changed>

Rules applied (converted/matched):
  <the apply_rules.py per-rule summary, pasted verbatim>

Sites: <c> converted across <f> files; <p> parked (blocked on <pass>); <d> deferred (markers placed)
Skipped: <what and why, or none>
Still manual: <what remains, or none>

Checkpoint: <e> errors, <w> warnings (was <e0>/<w0>; cleared <symbols>, 0 new)
Oracle: reconciled -- <n> would-convert (all parked for <pass>), <m> illegal-receiver (all marked)

Compiles: analyze <e> errors, <w> warnings via xojoctl
```

The `Compiles:` trailer variants in **Commit discipline** (user-declined checkpoints, expected-breakage categories) apply here unchanged.

## Workflow

Read `$SKILL/references/ide-vs-source.md` before phase 1, `$SKILL/references/conversion-traps.md` before phases 3–6 (its §8, string literals, and §9, declarations, are what phase 4's per-match glance is checking for), and `$SKILL/references/category-playbook.md` before phase 3—the per-category pass orders, the regex-vs-receiver pipeline decision rule and the queues live there; do not re-derive them.

### 1. IDE converter first

Ask the user to run **Project ▸ Update Controls to API 2.0** in the IDE, then commit the result as the **first commit on the migration branch**. Have them save in the IDE and confirm the converter's diff is on disk (`git status` shows it) before any bracketed session runs—the checkpoint command discard-closes an open copy of the project first, which would destroy an unsaved converter run. It is a large, entirely IDE-generated diff; keeping it separate is what makes every later commit readable as your work rather than the converter's. If they can't or won't, note it; type renames move to phase 7.

### 2. Inventory

Set `SKILL` to the installed skill directory first (see **Data access** below); the scripts live there, not in the user's project.

Two ways to build the worklist, and the preference between them is not a coin flip. Use the IDE's analyzer whenever the xojo-ide skill can reach a running IDE; use the bundled scanner when it cannot, when the project will not open or analyze, or when the user asks for the scanner. They fail in different directions. The analyzer resolves receivers and types—`.ListCount` on a ListBox is a finding, `.ListCount` on the user's own class is silence, and receiverless calls, paren-less statement calls and continued lines are all visible to it, because it is the compiler. The scanner reads text and is type-blind. But the analyzer only compiles the platform the IDE is running on, so `#If TargetWindows` bodies are invisible to it on macOS; the scanner is the only inventory those branches get. An IDE-driven migration therefore still ends with one scanner pass (phase 8).

#### 2a. IDE analyze (preferred)

With the deprecation warnings on (phase 0), run one bracketed IDE session (**IDE session discipline**), saving the document as checkpoint zero and adding real file:line positions before the join:

```
MIG=<project>/.git/xojo-migrate && mkdir -p "$MIG"
python3 -m xojoctl analyze --project /path/to/Project.xojo_project --discard --json > "$MIG/cp-00-baseline.json"
python3 $SKILL/scripts/locate.py "$MIG/cp-00-baseline.json" | python3 $SKILL/scripts/worklist.py
```

`$MIG` is the migration workspace (`category-playbook.md`): `cp-00-baseline.json` is the file every boundary's differ chain starts from, and the final report diffs it against the last checkpoint. `locate.py` maps each diagnostic's method-relative line to an absolute file:line (using the document's own session record for the project root) so every site the worklist prints is directly openable; sites it reports ambiguous or unresolved are a manual queue, never a guess.

Report the analyze counts now: they are the baseline every later checkpoint's numbers fall from.

**Compile errors are input, not failure.** A freshly converted project routinely fails to build—the phase-1 converter renames control types and leaves member calls behind—so `analyze` exiting 1 with `outcome: project_errors` is the normal phase-2a state, and `worklist.py` accepts it: those errors are the `Removed` bucket and the converter's leftovers locating themselves. What `worklist.py` refuses is a document whose analysis never ran—a failed connection, a timeout, no project open—which carries an error object and an empty diagnostics list. Its refusal message is the one to act on: nothing has been learned about this project's deprecations, so fix the IDE connection and re-run, or take the scanner path in 2b. Also refuse to proceed whenever the document's `session.closed` is `false`, whatever the exit code—the analysis ran but the project is still open in the IDE (a clean analyze reports it as exit 4; a failing one keeps its own exit code), and editing now is how disk edits get overwritten. Close it (`close --discard`) first. What you must not do is record a failed analyze as "no deprecations found".

Each deprecation warning is a compiler-verified work site with method, line and the replacement named in the message ("Left is deprecated. You should use String.Left instead"). No receiver check is needed to trust the *finding*—the compiler resolved the receiver to produce it. Errors in the same output are the `Removed` bucket locating itself. One mechanical fact about those line numbers: they count within the named method's body, not within the file—which is why the pipeline runs `locate.py` first; it is the mapping from owner + method + line to a file line, done once, honestly (its ambiguous/unresolved sites are listed, never guessed).

**Do not work from the raw warnings.** The IDE's message reads like a complete instruction, and for a handful of symbols the rename it proposes is the part that compiles and is still wrong: `InStr`'s not-found sentinel moves from 0 to -1, several functions change index base, and `Date.TotalSeconds` → `SecondsFrom1970` shifts the epoch by 66 years. The IDE never mentions any of it. `worklist.py` joins every warning to the matrix and leads with the sites that need more than a rename, in four groups: **hand conversion required**, **read the caveat before renaming**, **mechanical rename**, and **the IDE converter handles this** (control type renames, phase 1). It reports rule ids for `lookup.py rule <id>`; it decides nothing, and where the join is ambiguous it says so instead of picking.

Two properties of the real messages are worth knowing, because they bound what the join can do. Member deprecations arrive with **no receiver**—"ListCount is deprecated. You should use RowCount instead"—so the join leans on the replacement to pick the row, and where several candidate classes remain it asks whether they at least agree on the member's new name: `ListBox.ListCount` and `PopupMenu.ListCount` both rename the site to `.RowCount`, so the edit is settled without knowing the receiver. When neither test settles it, the report says AMBIGUOUS and you confirm the receiver yourself. And a symbol the matrix does not cover is listed separately rather than dropped: the IDE found a deprecation the bundled data missed, which is worth knowing.

Then plan and execute phases 3–7 unchanged. The analyzer found the line; it did not make the rename safe.

Run the scanner as well when the up-front plan needs the shape of the job—its per-bucket counts (`Removed`, `No replacement`, and so on) remain the fastest overview to present before the first category.

#### 2b. The bundled scanner (fallback, and the closing cross-platform check)

```
python3 $SKILL/scripts/scan.py /path/to/project            # human-readable
python3 $SKILL/scripts/scan.py /path/to/project --format json
```

**Present the in-code count, not the raw hit count.** `scan.py` reports `N in code (M raw)`: the first number excludes layout metadata, `#tag Note` blocks, comments and string literals, while the second counts every textual match. Only the first is a worklist. The gap is routinely 3–4x—a window's `Left = 110` and `Text = "OK"` layout properties alone can produce hundreds of matches for `Left` and `Text`—and leading with the raw number sets an expectation for the whole job that the real work will not match. Quote the raw number only to explain why a symbol looks alarming and isn't.

**Never convert a symbol that is not compiled.** A deprecation is a property of code the compiler reads. A `#tag Note`, a comment and a string literal are not that, so a deprecated name inside one is not a deprecation and converting it fixes nothing. This is easy to get wrong from the *other* direction: `scan.py` already excludes these regions, so the temptation arises when a plain text search, a grep, or your own reading turns one up and it looks like a symbol the scanner missed. It did not miss it.

`#tag Note` blocks are the trap, because their content is often whole slabs of real, syntactically valid old code that someone parked there instead of deleting:

```
#tag Note, Name = Old drawing code
  #elseif TargetWin32 ///////////////////////////
  ...400 lines of archived code...
#tag EndNote
```

Editing that changes a comment, adds noise to a diff that is supposed to be pure renames, and reports as a fix that fixes nothing. Leave it. If a note's archived code is worth migrating it is worth deleting instead, and that is the user's call, not part of this migration.

Even the in-code number is an upper bound: member matches are type-blind, so a symbol whose receivers all turn out to be user classes or live API 2.0 controls can go to zero. Say "up to M sites to review", never "M conversions".

Present symbols per bucket, with those counts. The seven buckets, and what each one means for the plan:

| Bucket | Meaning |
|---|---|
| `Removed` | **Does not compile.** Gone from the framework. These are build errors that exist before conversion starts; lead with them. |
| `Source — global` / `member` / `type` | The conversion work. Member matches are type-blind leads, not a to-do list. |
| `IDE handles` | Control/class renames the IDE converter does (phase 1), plus their event renames. |
| `No replacement` | Still compiles, but Xojo documents no API 2.0 replacement; needs redesign, not renaming. |
| `Out of scope` | iOS / Web / Android / PDF surface. |

Lead with `Removed`, then `No replacement`: those two are the ones that change what the project can even do, and neither is fixed by any rule.

**Anything left unconverted gets a marker at the site.** This is a hard rule of the workflow, not a nicety, and it applies to *every* deferral—not just the `No replacement` bucket. Deprecated calls still compile, so nothing will ever remind anyone they were deliberate. Leave the API 1.0 call in place and mark it with Xojo's own directive, which surfaces in the IDE's Issues pane on every build:

```
#Pragma Warning "API 2.0: JSONItem.DecimalFormat has no documented replacement -- unresolved"
#Pragma Warning "API 2.0: InStr with a literal source -- needs a local variable"
#Pragma Warning "API 2.0: DrawPolygon -- DrawPath takes a path object, not this coordinate array"
```

A line in a commit message is not a durable record: three months later the question is "this is still API 1.0, why?", and the commit body is not where anyone looks. A `#Pragma Warning` answers it at the call site, every build. Use `#Pragma Error` instead only if the user wants the build to stop until it is resolved.

**One marker per method is enough when a method repeats the same deferral.** A validation loop calling `InStr("0123456789", c)` dozens of times across dozens of methods does not need one identical marker per call; it needs the reader to find out once, wherever they enter the method. Put a single marker at the top of the method and say how many sites it covers:

```
#Pragma Warning "API 2.0: 3x InStr with a literal source -- each needs a local variable"
```

The rule being enforced is *the deferral is discoverable from the code*, not *the marker count equals the site count*. What is never enough is recording it only in the final report: the report is not in the IDE and not in the file. Where `conversion-traps.md` §4 says to "list them in the final report", that is in addition to the marker, never instead of it.

**Parked is not a fourth state—it is a queue with a drain schedule.** Cross-category dependencies create sites that are "not due yet"—an `InStr` comparison waiting for the Mid commit's arithmetic, say. The two queues in `$MIG/queues.md` (`category-playbook.md`) keep them distinct: a DEFERRED site gets its `#Pragma Warning` and its queue line in the same edit, permanently; a PARKED site gets a queue line naming the pass it waits on, no marker, and is drained at the boundary that closes that pass. The boundary's oracle run reconciles both queues every category, and phase 8 reconciles queues, markers and report against each other. On one real migration two parked sites fell out of every tracked state and surfaced only in a closing dry-run—the report claimed two more deferrals than sites carried markers; the queue file plus the per-boundary oracle is what makes that class of slip impossible.

The three deferral categories that recur, all of which need this: compound receivers left as deprecated globals (hard rule 3), calls whose replacement takes a different *kind* of argument (`DrawPolygon`/`FillPolygon` → `DrawPath`/`FillPath`), and anything awaiting a design decision from the user.

**Not everything old is deprecated.** Some globals that look like obvious API 1.0 holdovers are still current, and the matrix's silence about them is the answer, not a gap: `Asc`, `Chr`, `Val`, `Str`, `Format`, `Hex`, `Abs`, `Min`, `Max`, `Round`, `CStr`. Do not convert them, and do not go hunting for a replacement when a user asks. Note the trap in the pair, though—the **byte variants `AscB` and `ChrB` *are* deprecated** (→ `String.AscByte` / `String.ChrByte`) even though their base names are fine. If `lookup.py symbol <Name>` returns nothing, the matrix does not cover the symbol; confirm against the xojo skill's deprecation indexes before declaring it current.

### 3. Plan the pass order

From the inventory, build the worklist honoring the ordering pitfalls:

1. Byte-variants before base names (hard rule 5).
2. Narrow patterns before sweeps: `As RecordSet → As RowSet` before any bare `RecordSet` rename.
3. `InStr`/`IdxField` comparison and index fixes before the renames (hard rule 4).
4. Method-form rules before global-form where both exist.
5. Category order roughly: strings → arrays → ListBox → Database → Date → error handling → globals → files → graphics → misc.

The playbook's category table fixes the rest—each category's pipeline (regex path or receiver-sensitive, decided by its census rule), its sub-pass split (cat0's six hazard-class commits are encoded there), and its hazards. The commit plan you show the user is the playbook's rows minus the categories with no hits.

#### When the baseline has build errors, the errors come first

A freshly converted project usually does not compile (phase 2a: compile errors are input), and a tree that does not compile can confirm nothing—every category checkpoint would run against a broken build. So when the baseline analyze reports errors, burn the error surface down to zero first, in its own commits: the errors are compiler-located work, mostly member renames the phase-1 converter leaves behind plus the `Removed` bucket. The playbook's **Pass E** makes the burn-down mechanical—the locate filter positions every error, grouping by message shape supplies the receiver type from the compiler's own words, one rename-map entry per (type, member) group drives `targeted_rename.py`, and the boundary reconciles renames-per-map-entry against errors-cleared-per-symbol. Running those control-member categories first also sidesteps the arrays→`.LastIndex` interaction in `pass-hazards.md` §3 instead of creating it. The step-5 category order resumes once analyze reports zero errors.

#### One pass creates what the next pass matches

A rule can *produce* a name that a later category's regex then matches, so that category's hits are partly your own output rather than the inventory's. The documented order causes one instance of this (arrays → ListBox `.LastIndex`) and handles two others. **Read `$SKILL/references/pass-hazards.md` §3 before running the ListBox or array categories**—it names all three and gives the end-of-category check that catches a new one.

**This ordering is also the commit plan.** Each category in step 5 becomes one commit, in that order. Show the user the list up front ("roughly 9 commits, in this order, and here is what each will contain") so they know what they are agreeing to review. Categories with no hits are dropped from the plan; say which, so a missing commit does not read as a missed step.

#### What the analyzer already settled, and what it did not

Phases 4 and 5 ask three questions per match that exist because a regex cannot answer them. On the **IDE path** the compiler has already answered them, and knowing which is which prevents two opposite mistakes—re-deriving what is known, and trusting what is not.

**Settled, for every site the analyzer reported.** It is real code: the analyzer compiles, so a match inside a comment or a string literal cannot appear (phase 4's first question). The receiver is that type, and the member really is deprecated on it: the compiler resolved the receiver in order to raise the warning at all (phase 5's whole job). And phase 5's warning that most matches in the tier are wrong describes *regex* matching—an analyzer finding does not carry that false-positive rate, so do not dismiss one as scanner noise.

**Not settled, and still yours.** Whether the replacement the IDE named is the API 2 destination: it is sometimes another member of the deprecated class, which compiles and gets flagged again next pass, so `worklist.py` prints the conflict and `ide-vs-source.md` documents the shape. Whether the rename is *semantically* safe: index bases, `InStr`'s sentinel and `Date`'s epoch are invisible to the compiler, and the rule's caveat governs exactly as it would for a scanner hit. Which matrix row a bare member warning belongs to, when neither the replacement nor the candidate rows' own agreement settles it—that is what an AMBIGUOUS group means, and it wants the receiver confirmed by hand. And anything outside the analyzed platform: `#If` branches for other targets were never looked at (phase 8).

Stated as one line: **the analyzer is authoritative about where a deprecation is—among the symbols its deprecation database knows—and merely helpful about what to replace it with.** The scanner path settles none of this and phases 4 and 5 apply to it unchanged.

### 4. Fast pass (`high` rules): one glance per match

For each `high` rule with hits, fetch it (`$SKILL/scripts/lookup.py rule <id>`) and walk its matches. Per match, three questions, all answerable from the one line you are looking at:

1. **Is it code?** Skip matches inside string literals and comments. No rule here can see the difference; you can.
2. **Does the rule's `caveat` name a hazard?** The caveats call out the two that recur: a *live API 2.0 collision* (the same member name is valid on another class) and a *declaration hazard* (the rule rewrites the identifier, so it would rewrite the user's own `Sub Speak(...)` too). A declaration hazard makes **each match ambiguous, not the rule inapplicable**—never skip the whole rule because the project defines the name, or you drop its true positives along with its false ones.
3. **Does the rule's `manual` note apply?** Compound-argument calls the regex deliberately skips must be hand-converted or explicitly deferred.

If all three are clear, apply it. This is a fast pass, not a blind one; most matches clear in a second.

**On the regex path, `apply_rules.py` is how the cleared 

…(truncated)
