Metadata-Version: 2.4
Name: saxml4adt
Version: 0.1.0
Summary: Query a FileMaker Save-as-XML export: cross-references, readable scripts, impact analysis
Author-email: Michael Wallace <michael.wallace@empoweredds.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/mw777eds/SaXML4ADT
Project-URL: Repository, https://github.com/mw777eds/SaXML4ADT
Keywords: filemaker,save-as-xml,cli,cross-reference
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: lxml>=5.0.0
Requires-Dist: typer>=0.9.0
Requires-Dist: keyring>=24.0
Requires-Dist: rich>=13.0
Provides-Extra: dev
Requires-Dist: pytest>=7.4.0; extra == "dev"
Dynamic: license-file

# SaXML4ADT

**Save-as-XML for Agentic Development Toolkit.** A command-line query store over a
FileMaker **Save a Copy as XML** export: exact cross-references, readable scripts,
impact analysis, a freshness model, and a web console — built so an agent doing
FileMaker development with [`fm-cli`](https://github.com/claris/adt) can answer
"what will this touch?" in milliseconds, without taking FileMaker's schema lock,
and can tell when its answer is out of date.

Read-only. `fm-cli` stays the only writer. See [`docs/USE-CASES.md`](docs/USE-CASES.md)
for the use cases this was built against and the FileMaker quirks found on the way.

The idea of a queryable store over FileMaker's own structural export came from
[Nuosis/FM2WEB_CLI](https://github.com/Nuosis/FM2WEB_CLI) (a DDR toolkit); SaXML4ADT
is a from-scratch implementation around Save-as-XML and shares no code with it.

## Prerequisites

| | |
|---|---|
| Python 3.10+ | `pipx` recommended |
| An ADT project | a folder with `adt.json` naming the file — the ADT toolkit's own `adt init`, not this tool's `saxml4adt init` (see *Install* below) — and [`fm-cli`](https://github.com/claris/adt) on `PATH` with its keychain credentials for the file (`fm --file=… --username=… --store-credentials`) |
| FileMaker Server with the **Data API** on | and an account with the `fmrest` extended privilege (the same account `adt.json` names is fine). Not hosted? See *Working without a server* below |
| Any coding agent | Claude Code gets the freshness hook (`saxml4adt init`); agents without hooks (Codex, …) run fm-cli through `saxml4adt fm …`, which marks for them — `saxml4adt init --agents` writes that protocol into `AGENTS.md` |

## Install

```bash
pipx install git+https://github.com/mw777eds/SaXML4ADT        # or: pip install git+…
cd ~/projects/my-adt-project                                   # the folder with adt.json (or an empty folder)
saxml4adt serve                                                # opens the browser on the Setup screen
```

The Setup screen does the rest: server / file / account (saved to `adt.json`, password to the
credential store), readiness checks, and buttons for *Set up this project*, *Install into the
FileMaker file* and *Export and build* — the console appears when the first build lands. The same
steps from the terminal:

```bash
saxml4adt init                                                 # hook + .gitignore + readiness check
saxml4adt install                                              # once per FileMaker file (see below)
saxml4adt export --save                                        # first time: asks for the password, stores it
saxml4adt build
saxml4adt serve
```

In an empty folder (no `adt.json` yet), `saxml4adt init --target fmnet://fms.example.com/MyFile --username admin`
writes one before the rest of `init` runs — the same thing `saxml4adt serve`'s Setup screen does from a form.

`init` writes the hook into `.claude/settings.json`, adds the export folder and
the store to `.gitignore` (they are client data), and reports what is missing.
`install` uses fm-cli to add one table (`SaXML4ADT_Export`), one layout of the
same name, and three scripts in a `SaXML4ADT` folder — the machinery that runs
Save a Copy as XML on the server and hands the files back through the Data API.
Everything it adds is in [`saxml4adt/ops/install.ndjson`](saxml4adt/ops/install.ndjson)
for review. Put the server's **full host name** in `adt.json`
(`fmnet://fms.example.com/File`) so the Data API certificate matches; a short
alias can be mapped in `~/.config/saxml4adt/hosts.json` (`{"fms": "fms.example.com"}`).

Credentials go in the OS credential store (macOS Keychain as a `saxml4adt: host/file`
item; `keyring` elsewhere), or in `SAXML4ADT_FM_PASSWORD` for CI.
`saxml4adt credentials` shows whether one is stored; `--forget` removes it and `--username NEW --save`
stores the next one. To change the account fm-cli edits as, update `username` in `adt.json`
(or run `adt connect`) and `fm --forget-credentials` / `--store-credentials` its own entry —
SaXML4ADT never reads or writes FileMaker's keychain items.

**Renamed from `saxml`.** Earlier builds installed a `saxml` command; it is gone. An agent whose
`saxml …` calls start failing with `command not found` mid-session is seeing the rename, not a
broken install — switch to `saxml4adt` (same subcommands) and re-run `saxml4adt init` so the
hook and .gitignore entries carry the new name.

## Help

`saxml4adt help` lists topics, `saxml4adt help scripts` the commands in one, `saxml4adt help steps` one
command with its options and examples (`--json` for agents). `saxml4adt <command> --help` still works.
`saxml4adt help concepts` lists the mental models behind the store (marked vs unmarked, freshness,
the hook loop, …); `saxml4adt help concepts marked-vs-unmarked` prints one.

## The loop

```
saxml4adt export   →   saxml4adt build   →   query / serve
       ▲                                          │
       └── agent edits with fm → hook marks dirty ─┘
```

* **export** runs the server-side Save a Copy as XML and pulls every catalog file
  into `XML/SaveAsXML/<File>/` (`--catalogs ScriptCatalog,LayoutCatalog` for a
  partial export, merged into the folder). `--transport ssh` rsyncs the server's
  Documents folder instead, for hosts you can reach. `pull` downloads an export
  the server already produced (after `export --keep`) without running it again.
  A `note` in the output when fewer files loaded than expected usually means
  `Summary.xml` — every structural catalog still loaded; it's not FileMaker
  skipping an empty one, and SaXML4ADT never reads `Summary.xml` anyway.
* **build** loads the export into `saxml4adt.sqlite` **in place**: the build
  history, the change log and the dirty marks survive, and every object that
  changed since the previous build is reconciled against the marks (`--fresh`
  wipes). Seconds, even for a 70 MB export.
* **serve** opens the console: a map of catalogs, which objects are marked
  dirty / changed / changed-without-a-mark, drill-down to any object, the op
  that caused each mark, history by account, and the query log. It follows the
  store live. The server runs in the background — the terminal is free.
  Only one tab opens per project: running `serve` again while it's already up
  prints the existing URL and does not start a second server or open another
  tab; `serve --restart` stops and restarts it in place (same port) without
  opening a tab either — the console page you already have open detects the
  restart and reconnects on its own. `serve --stop` (or the title menu, which
  also closes that tab) ends it, and it quits by itself after 12 hours without
  a visitor.
* Every query prints JSON with `meta.stale` — the open marks the result touches —
  and warns; `--strict` exits 3 on a stale answer, `--max-age 30m` exits 4 on an
  old export, and `status` reports catalogs whose source files are gone or newer
  than the build.

## What it answers that `fm` cannot

| | |
|---|---|
| `saxml4adt steps <script>` | every step with **0-based index and 1-based line**, options **decoded by name** — including the 51 step types `fm` exposes only as numbered slots (the XML's `position="N"` equals fm's slot index), and calcs `fm` renders as `<Function Missing>` |
| `saxml4adt function <name>` | custom function bodies (`fm` cannot read them) |
| `saxml4adt broken-refs` | dangling Field/Script/Layout/TO references, including ones on layout objects that FileMaker's own *problems* list omits |
| `saxml4adt themes` / `styles <theme>` | every theme in the file and its named styles — the only names `create:layout` / `addObjects` will accept |
| `saxml4adt duplicates script` | name collisions `fm` cannot address individually |
| `saxml4adt taborder <layout>` | the tab order as FileMaker stores it — per-object positions, gaps left by deleted objects, and objects that have **no position at all** (everything `fm` adds), which is why fm-added fields land unpredictably; `fm` has no tab-order surface |

## What it answers faster

`usages Table::field` · `refs-to layout X` · `callers` / `callees` / `triggers` ·
`describe layout|script|to|field` · `objects <layout>` (with parent ids and
enclosing path) · `variables --problems` / `variable $$name` (assignments vs reads, case-insensitive like FileMaker, spelling drift) · `search` (FTS5 over
steps, calcs, comments) · `path TO-A TO-B` · `relations --suspicious` ·
`steps-of-type "Send Mail" --where "No dialog=Off"` · `fields-on <layout>` ·
`portals` · `unreferenced` (never "unused" — read its caveat).

## Freshness: an export is a snapshot

```bash
saxml4adt describe layout Settings                       # read
fm --file=… ops.ndjson                               # write with fm-cli
saxml4adt invalidate --from-ops ops.ndjson --as agent-2  # mark what the batch touched
saxml4adt describe layout Settings                       # -> meta.stale warns; --strict exits 3
saxml4adt refresh ~/exports/MyFile/SaveAsXML --catalogs LayoutCatalog   # re-ingest only that catalog
```

* `invalidate <kind> <name>` marks one object; `--from-ops` marks every write
  target in an fm-cli batch; `invalidate catalog ScriptCatalog` marks a whole catalog.
* `ops.ndjson` (also what `impact` preflights, below) is fm-cli's own batch format — one write per
  line, e.g. `{"op":"create:field","table":"PRF__Preferences","name":"smtpAddress","type":"text"}`.
* `--max-age 2h` refuses to answer from an export older than that (exit 4).
* `refresh --catalogs A,B` replaces only those catalogs from a new (possibly
  partial) export and clears their marks. FileMaker can produce a partial export:
  the *Save a Copy as XML* script step takes options JSON
  `{"catalogs_included":["ScriptCatalog"],"split_catalogs":true}`.
* `hooks/saxml4adt-invalidate-after-fm.sh` is a Claude Code `PostToolUse` hook that
  runs `invalidate --from-ops` automatically after any `fm …` command, so the
  discipline does not depend on the agent remembering.
* Hook discipline that is still on the agent: **write ops to a file** (ops on
  stdin/heredoc are invisible to the hook, which then marks every writable
  catalog dirty), and **export `SAXML4ADT_ACTOR`** so marks and `touched --by` can
  tell agents apart. The hook skips `--dry-run` and batches fm rolled back.
* Every object row records `modified_by`, `modified_at` and its `modifications`
  counter from the XML, so with an agent-specific FileMaker account a re-export
  also shows what that account touched.

## History and drift

Every object row carries the XML's own `modifications` counter and
`modified_by`/`modified_at`. When a later export is ingested (`build` or
`refresh`), SaXML4ADT diffs those against the previous state and records what the
export *showed* changed — then reconciles it against what agents *said* changed:

```bash
saxml4adt refresh XML/SaveAsXML --catalogs ScriptCatalog   # prints drift: changes / unmarked / marked_unchanged
saxml4adt drift            # last refresh: predicted, changed-but-unmarked, marked-but-unchanged
saxml4adt history --by agent-bot --since 2026-08-28   # observed changes over time, with who and whether it was marked
saxml4adt touched --by agent-bot                      # straight from the export's stamps, no history needed
saxml4adt builds
```

**`unmarked_change` is the alarm this whole model exists for**: three agents and a
person share one file, and the failure that actually happens is a peer writing to
a file you own without your knowing. `unmarked` changes mean someone edited without telling the store (an agent that
skipped `invalidate`, or a person in FileMaker Pro). `marked_unchanged` means a
mark was a false alarm or the write never landed. Give each agent its own
FileMaker account and the export becomes its audit trail.

### Every version, without keeping the XML

Each build also records what *changed inside* every object — attribute before/after, script
steps as line hunks — in `change_log`. Nothing else is kept, so the store stays small, yet any
version since the store's second build can be reconstructed by replaying the deltas backwards:

```bash
saxml4adt history --kind script --object "Nightly Sync"   # every build that changed it, with the deltas
saxml4adt show script "Nightly Sync" --at 3               # the script as build 3's export showed it
saxml4adt diff script "Nightly Sync" --from 3 --to 5      # attribute before/after + unified diff of steps
saxml4adt diff field "Contacts::email" --from 2           # …to current
```

Works for every kind (`layout`, `layout_object`, `field`, `table_occurrence`, `value_list`,
`custom_function`, …); the console's object panel shows the same timeline.

Web-viewer persistent stores (`ADT [<app>]` and friends, under `PersistentStoreCatalog`) get the
same treatment: each build records the payload's sha1, size, modification count, account and
timestamp — never the payload — so a redeploy shows up as an ordinary `change_log` row (old/new
sha1 + size) via `history`/`show`/`diff`, same as any other object.
`saxml4adt status` also compares each `ADT [<app>]` entry's recorded sha1 against
`webviewer-apps/<app>/dist/index.html` on disk and reports it under a `webviewer` block, so a stale
deploy is visible without opening FileMaker.

## Web console

```bash
saxml4adt serve                             # background server + browser; `serve --stop` ends it, 12 h idle timeout
saxml4adt serve --restart                   # stop + start in place, same port, no new tab (the open tab reconnects)
saxml4adt serve --foreground --port 8770    # in this terminal instead (Ctrl-C stops it)
saxml4adt export-web console.html           # self-contained snapshot with the data embedded
```

**Home is a review feed**: "since your last visit" — the builds you have not seen, then
one row per changed object with a pill (✓ marked / ⚠ unmarked / a twin-export notice), the
kind and name, one line of what changed (step hunks, moved objects, the attributes), and who
touched it when. The name opens the object; the what-changed line opens it on the panel that
shows that change. *Mark all reviewed* advances your last-seen build (kept per store in this
browser), and *show all builds →* is History. The catalog map moved one click away, to the
**Catalogs** tab and the rail.

From there: drill-down to objects with their marks, observed changes, steps, layout objects
and references; a history view with the per-refresh drift bars and who-touched-what; the
query log. Marks can be made from the page when served live.

Every interaction is one of three verbs, and no click loses your place: **select**
answers in place (clicking an object in a layout preview outlines it and fills an
inspector directly below — bounds, enclosure breadcrumb, theme style and local
overrides, field binding, last change; ⌥-click adds more; in a script, click a step
and shift-click another for a range), **peek** opens over the view and Esc restores it
exactly, and **navigate** is the only thing that reroutes. Every list that names an object
— references, where-used, a layout's objects, a variable's scripts — peeks first: a
slide-over brief with the facts, its last change and its reference counts, and one
**Open →** when you actually want to go there.

An **investigation trail** runs under the header: every deliberate navigation leaves a chip
(kind + name), and clicking one returns to that stop with its scroll position intact — an
agent's stop lands there too, in amber. Back means the previous chip; Home starts a fresh
trail. It is session-local and sits above the browser's own Back, which keeps working.

The pane boundaries are **drag handles** (rail | list | detail); widths persist per browser
and a double-click resets one. **☰** collapses the catalog rail to a one-column icon strip.
`docs/CONSOLE-IA.md` is the full contract, including every hash parameter a deep link can
carry.

### Pins and agent navigation

**📌 Pin** on any selection adds an entry to your pins — layout objects, a range of
script steps, a calc fragment — instead of replacing the last one. The header button
shows a live count and peeks the list. Say "look at my pins" and the agent reads all of
them in one pull with the `pinned_items` MCP tool; reading grays them into a short
history rather than deleting them, so they stay re-askable until you clear them. Nothing
is ever pasted into your prompt.

The other direction: an agent can put something on your screen with `console_show`,
which drives the open tab to a view and shows a toast saying what it is showing and why.
Your previous view and scroll are saved first, and **Esc** — or the `↩ Esc returns you`
chip that outlives the toast — puts you back.

## MCP server

`saxml4adt mcp` speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio
(JSON-RPC 2.0, newline-delimited), so Claude Desktop, Claude Code, Cursor and any other MCP client
can query the store directly — without shelling out to `saxml4adt`. It is hand-rolled (stdlib +
the rest of this package only; no `mcp` package dependency) and read-only except for one tool,
`mark_dirty`, which writes to the store's own freshness ledger (never to the FileMaker file —
`fm-cli` stays the only writer of that).

```bash
saxml4adt mcp --print-config          # Claude Desktop snippet + the `claude mcp add` line, with an absolute --db path
saxml4adt mcp --db ~/projects/my-adt-project/saxml4adt.sqlite   # run it (a client normally launches this itself)
```

`--print-config` fills in an absolute path so the config works regardless of the client's working
directory:

```bash
claude mcp add saxml4adt -- saxml4adt mcp --db /Users/you/projects/my-adt-project/saxml4adt.sqlite
```

or, in `claude_desktop_config.json`:

```json
{"mcpServers": {"saxml4adt": {"command": "saxml4adt", "args": ["mcp", "--db", "/Users/you/projects/my-adt-project/saxml4adt.sqlite"]}}}
```

**Tools** — one per read command that matters for an agent mid-task, each taking the same option
names as its CLI counterpart and returning the same `{meta, …}` envelope (so `meta.stale` still
warns when a result touches a mark made since the export): `overview`, `status`, `search`,
`object` (= `describe`), `referenced_by` (= `refs-to`, impact analysis), `script` (= `steps`),
`layout_objects` (= `objects`), `layout_html`, `styling`, `style_suggestions`, `variables`,
`history`, `show_at` (= `show`), `diff`, `blame`, `perf`, `conventions_check` (= `conventions`),
`unreferenced`, `query_log`, and `mark_dirty` (= `invalidate`, the one write tool). Each result
comes back as `content: [{type: "text", text: <json>}]` plus a matching `structuredContent`.

Two more talk to the console rather than the export:

- **`pinned_items`** — the items the user pinned in the console for you to look at, each with a
  ready-to-read text block and the structured data behind it. Reading marks them read (they stay
  in the console, grayed); `{"include_read": true}` brings them back.
- **`console_show(to, note)`** — put something on the user's screen: drives their open console tab
  to a view, announces itself in a toast, and leaves Esc pointing back at where they were. Reports
  whether a tab actually applied it, and returns the full URL for when none is open. Needs the
  console to be running (`saxml4adt serve`) and to be launched from the project folder.

**Resources**: `saxml4adt://overview` (the same JSON as the `overview` tool) and
`saxml4adt://layout/<name>.html` — one per layout, the same rendered standalone-HTML preview
`layout-html` writes to disk, with dummy sample data.

The server connects to the store lazily, so `initialize` / `tools/list` still answer before a
build exists; a tool call against a missing store comes back as that one call's error, not a dead
connection. Nothing but JSON-RPC ever reaches stdout — diagnostics go to stderr.

## Layout snapshots (macOS)

`saxml4adt snapshot` captures real FileMaker Pro layouts as PNGs — ground truth to compare
against `layout-html`'s renders. Once per run it raises the file's document window to the front
of FileMaker's own window list (see the requirements below — this step matters), then switches
the live layout with AppleScript, captures the document window with Quartz + `screencapture`
(works on an occluded window, steals no focus), and records the layout FileMaker actually loaded,
since an `OnLayoutLoad` trigger can redirect.

```bash
saxml4adt snapshot "Contact Detail"                          # one layout -> ./Snapshots/Contact Detail.png
saxml4adt snapshot --all --out Snapshots/Empowered_Beginning --compare   # every layout + a side-by-side compare.html
saxml4adt snapshot "Contact Detail" "Invoice Detail" --file Empowered_Contacts --delay 2
```

Requirements:

- macOS, with FileMaker Pro running and the target file already open (not just hosted — open in
  this copy of Pro).
- Screen Recording permission for whichever app runs `saxml4adt` (Terminal, iTerm, etc.) — System
  Settings → Privacy & Security → Screen Recording.
- The `fmextscriptaccess` extended privilege on the account signed in to the file, for AppleScript
  layout control. Granting it does not take effect on an already-open file — close and reopen it
  first.
- **`go to layout` and `get name of current layout of database "X"` both act on that database's
  FRONT window — not necessarily the document window a user would recognize.** A hidden card
  window (this package's own "ADT MCP Server Connector" window is a real example) can be
  frontmost instead; when that happens, every switch and readback silently happens *there* while
  the visible document window never moves, so every captured PNG comes back byte-identical
  regardless of which layout was requested (`current layout` even reads back as matching, since
  it is honestly reporting the connector window's layout — the mismatch only shows up as pixels
  that never change). `snapshot` handles this itself: before the sweep it raises the document
  window it found via Quartz to the front of FileMaker's own window list (`go to window`, or a
  System Events `AXRaise` fallback if that verb isn't in FileMaker's dictionary — the fallback
  needs Accessibility automation permission granted to the host running this command), so every
  later switch/readback in the run actually targets it. If you ever see a run come back with every
  PNG hashing identically, this raise is what to check first.
- A modal FileMaker dialog (a `Show Custom Dialog` step, the "Summarize" field dialog, …) blocking
  a layout switch is also handled automatically — it is detected, the app is activated and Escape
  is sent, and the switch is retried once before that one layout is given up on and the sweep
  moves on (see `warning`/`dialog_layouts` in the JSON summary). One bad layout — a dialog that
  doesn't clear, a timed-out AppleEvent, anything else — is always isolated to its own row; it
  never aborts the rest of the sweep.

`--compare` also renders each captured layout with `layout-html --sample dummy` into the output
directory and writes `<out>/compare.html`: the FileMaker PNG and the HTML render side by side per
layout, for eyeballing fidelity. It is a plain local file — nothing gets published anywhere.

The JSON summary reports, per layout, `{layout, actual, png, width, height, warning?}`, plus a
top-level `dialog_layouts` list of any layouts that hit a modal dialog. `actual` and `warning`
only differ from the requested name when an `OnLayoutLoad` trigger redirected the view; `--all`
skips layout names starting with `.` and keeps going past a failed switch, so one bad layout does
not abort the run.

## Working without a server

A file open in FileMaker Pro (not hosted): **File → Save a Copy as XML…** (split
catalogs, include details) into `XML/SaveAsXML/<File>/`, then `saxml4adt build`.
Everything except `export` works the same.

## Preflight an fm-cli batch

```bash
saxml4adt impact ops.ndjson
```

For each op: does the target exist in the export, is its name ambiguous, what
references would a delete break, and do the table / occurrence / theme names it
mentions resolve — all before `fm` opens the file and takes the schema lock.

## Verify a write

```bash
saxml4adt build before/ --db a.sqlite && saxml4adt build after/ --db b.sqlite
saxml4adt diff-stores a.sqlite b.sqlite --script "Do Thing" --assert-only-changed 118   # exit 6 if anything else moved
```

## Conventions

`saxml4adt conventions` checks every table, table occurrence, field, script, variable and custom
function name against a naming-conventions file — `saxml4adt.conventions.json`, next to
`saxml4adt.sqlite` in the project folder:

```json
{
  "version": 1,
  "tokens": { "code": "[A-Z]{3}", "Code": "[A-Z][a-z]{2}", "lcode": "[a-z]{3}", "Name": "[A-Z][A-Za-z0-9]*", "name": "[a-z][A-Za-z0-9]*", "NAME": "[A-Z][A-Z0-9_]*" },
  "rules": {
    "table":            { "pattern": "{code}__{Name}",        "example": "CNT__Contacts" },
    "table_occurrence": { "base": "{code}__{Name}", "related": "{lcode}_{code}__{Name}", "example": "cnt_ADR__Addresses" },
    "field":            { "pattern": "{name}",  "example": "firstName" },
    "field_global":     { "pattern": "{name}_g", "example": "sessionId_g" },
    "field_key":        { "pattern": "_{name}", "example": "_contactId" },
    "script":           { "pattern": "{Name}", "example": "Contact_Create" },
    "variable":         { "pattern": "${name}", "example": "$contactId" },
    "variable_global":  { "pattern": "$${NAME}", "example": "$$CURRENT_USER" },
    "custom_function":  { "pattern": "{name}", "example": "trimAll" }
  },
  "ignore": ["^zz_", "^Global$"]
}
```

Each `{token}` in a pattern substitutes the named regex from `tokens`; every other character is
literal (a `$` in `variable`/`variable_global` matches a literal `$`). A rule may give several
named alternatives instead of one `pattern` — `table_occurrence` above passes a name that matches
either `base` (a table occurrence standing in for its own base table) or `related` (one added for
a specific relationship context, prefixed with the other table's `lcode`) — and a name passes the
rule if *any* alternative fully matches. `ignore` is a list of regexes checked against a name
before it is checked at all (so `zz_Archive__Old` or a stock `Global` table are never flagged).

`field_global` covers fields with FileMaker's *Global storage* on; `field_key` covers fields whose
name looks like a key (`/^_/` or `/id$/i`, case-insensitive) **and** that are actually used on
either side of a relationship — a `customerNotes` field ending in nothing key-shaped stays a plain
`field` even if it happens to hold an ID-like value.

```bash
saxml4adt conventions --infer                  # guess one from the majority pattern already in the store
saxml4adt conventions --infer --write          # save the guess as saxml4adt.conventions.json (--force to overwrite)
saxml4adt conventions                          # check every name — the default once the file exists
saxml4adt conventions --kind field -f table    # one rule kind only
saxml4adt conventions --strict                 # exit 1 if anything violates
```

`--infer` classifies every existing name into a shape (`AAA__Xxx`, `aaa_AAA__Xxx`, `xxx`, `Xxx`,
`xxx_g`, `_xxx`, `XXX`, `Xxx_Xxx`, `xxx_xxx`, or `other`), picks the majority shape per rule kind,
and reports a `confidence` (the share of names that actually match it) plus the top-5 shape
histogram — review those before `--write`ing an inferred file over a real one. `--check` (the
default once a file exists and `--infer` is not given) reports every name that does not match, with
`expected` (the alternatives it was checked against) and, where trivially derivable — camelCasing a
field, uppercasing a global variable — a `suggestion`; `table` and `table_occurrence` violations get
no suggestion, since picking the right 3-letter code is a human call.

## Multi-file projects

A solution split across several `.fmp12` files — a UI file plus one or more data files wired
together with external data sources — builds into **one store holding all of them**, with
references resolved across the file boundary: a field in a data file used only by layouts in the
UI file no longer reads as unreferenced. `saxml4adt build --file A --file B` (repeatable) or a
`saxml4adt.project.json` listing `"files"` in the project root drives which files are ingested;
`saxml4adt files` lists what the store holds — objects per file, last build, and any unresolved
external data source. Every query that names an object takes `--file <name>` to narrow it to one
file; left off, the answer covers the whole project — or, once the store holds more than one file
and one is marked **primary**, the primary file, with `--all-files` (alias `--all`) restoring
store-wide scope for that one call. Single-file projects, and multi-file ones with no primary set,
are unaffected — `file_id 1` everywhere is exactly the old behavior, byte-identical.

Mark the development file with `saxml4adt files --set-primary Empowered_Beginning` (or a
`"primary"` key in `saxml4adt.project.json`, which wins when both are set; `--clear-primary`
removes the store's own mark). Once a primary is set, fm-cli write commands
(`variable --rename --apply`, `variables --fix-spelling --apply`, `uninstall`) refuse to target
any other file unless you pass `--file <that file>` explicitly — the primary is the file you are
developing; the rest are treated read-only from here. `export`/`install`/`uninstall --dry-run`
still work per-file regardless, since pulling or installing the export machinery on an old file is
harmless.

`build` also guards against **copy-twins**: a file whose export shares more than 20% of its object
uuids with a different file already in the store — the same file present twice under different
names, e.g. an old export left next to a newer one — is held for confirmation (or, without a
terminal, refused with exit 3) unless `--allow-twin` (alias `--yes`) is passed; `saxml4adt files`
reports any such pair it finds, with the overlap percentage.

Full design in [`docs/MULTI-FILE.md`](docs/MULTI-FILE.md).

## Boundaries

* Read-only. `fm-cli` remains the only writer.
* Structure only. A table can be wired everywhere and hold zero records; this
  tool cannot see data, whether layout objects render, or business facts.
* `unreferenced` ≠ unused. Manual-by-design scripts, external callers (Data API,
  WebDirect), and by-name runtime addressing are invisible here; targets that
  *could* be addressed dynamically are reported `UNDECIDABLE`.

## Known FileMaker limits (found while building this)

* **Save-as-XML can omit objects fm-cli appended to an existing layout** —
  the live layout has them, the export does not. `saxml4adt verify-layouts`
  cross-checks every layout's object count against the live file through fm-cli.
* **fm-cli writes a blank modifier name** on everything it creates or updates,
  and the first export overwrites that blank on *layouts* with the export session
  (`SaXML Export - <account> <pid>`). Details: [`docs/bug-reports/`](docs/bug-reports/).

## Platforms

macOS and Windows (FileMaker Pro's platforms). macOS is what this is developed on; Windows uses the
same code paths — credentials via Windows Credential Manager (`keyring`), the hook as
`saxml4adt hook --as "…"`, and the container transport, which needs no ssh. Windows has had no
hands-on run yet: if something breaks, `saxml4adt init` prints the readiness check to include in an
issue. The `--transport ssh` path assumes a Linux FileMaker Server Documents path (`--remote-docs`
to override).

## Releasing

Tag a version and push it: `git tag v0.1.0 && git push origin v0.1.0`. The `publish` workflow builds
the wheel and uploads it to PyPI through trusted publishing (project `saxml4adt`, environment
`pypi`) — no tokens in the repo. After the first release, `pipx install saxml4adt` is the install line.

## Development

```bash
git clone https://github.com/mw777eds/SaXML4ADT && cd SaXML4ADT
python -m pip install -e '.[dev]'
python -m pytest -q                                   # 11 unit tests on the committed fixture
SAXML4ADT_TEST_EXPORT=/path/to/an/export python -m pytest -q     # + 6 tests against a real export
```

Layout: `saxml4adt/export.py` (find and repair the XML), `calc.py` (tokenize
calcs), `steps.py` (decode script steps), `ingest.py` (build the store),
`queries.py` (every question), `conventions.py` (naming-conventions check + infer),
`cli.py` (typer app: commands, export transports, hook, init, install),
`web.py` + `web/index.html` (console), `mcp.py` (MCP stdio server).
