@hozo/migration-audit
v0.4.0
Published
Measure how safely Hozo can lower a real React Native application.
Maintainers
Readme
@hozo/migration-audit
Migration analysis for real React Native applications. It runs Hozo's Web and Native lowerers over a checkout without installing anything into that application, respects platform file suffixes, and reports parse failures, diagnostics, React Native JSX left in Web output, and structural wrong-output invariants. It reads the checkout and writes nothing to it unless asked; see What it writes.
npx @hozo/migration-audit .
npx @hozo/migration-audit --root ../my-react-native-app --output hozo-audit.md
npx @hozo/migration-audit . --source app --source components --format json
npx @hozo/migration-audit . --details --output hozo-audit.md
npx @hozo/migration-audit . --css app/globals.css --preflight auto
npx @hozo/migration-audit . --primitive-source @acme/ui
npx @hozo/migration-audit . --source packages/app --include 'packages/**/*.{tsx,jsx,ts}' --exclude '**/*.test.*'
npx @hozo/migration-audit . --native-platform ios
npx @hozo/migration-audit . --font-availability hozo-font-availability.json
npx @hozo/migration-audit . --compare previous-audit.json --output changes.md
npx @hozo/migration-audit . --fail-on error --output hozo-audit.json
npx @hozo/migration-audit . --compare previous-audit.json --fail-on new-errors --output changes.md
npx @hozo/migration-audit . --fix # print the import moves as a diff
npx @hozo/migration-audit . --fix --write # apply themWithout --source, the tool scans nonempty src/ and app/ directories (both when present),
then falls back to the checkout root when neither contains JS/TS source. Repeat --source to select
explicit directories. Overlapping selections are deduplicated. Dependencies and generated
directories (node_modules, VCS metadata, .next, .nuxt, .expo, .turbo, .cache, dist,
build, coverage, .test-build, artifacts, temp) and symlinked directories are excluded.
.tsx, .jsx, .ts, .js, .mts and .mjs enter the authored inventory; declaration files
(.d.ts / .d.mts) do not. Repeat --include / --exclude for checkout-relative globs.
Includes follow the shared compiler walk's policy: explicit includes override gitignore,
but built-in generated/dependency exclusions still apply. --source must remain inside the
checkout and cannot select a directory symlink. Empty selections fail with a short message.
Explicit includes for a monorepo should be paired with --source . or the appropriate roots.
Known declaration/custom-excluded source files are listed separately; built-in excluded directory
contents and unsupported extensions are not enumerated, rather than reported as zero.
--fix: the import moves, written to source
With unloweredReactNativeJsx: 'warn' | 'error', the Web build moves the React Native APIs Hozo owns
(Platform, Dimensions, Keyboard, StyleSheet and the rest) from react-native to
@hozo/rn-compat, invisibly. --fix writes that move into the source, using the compiler's own pass,
so it moves exactly the imports the report's "Web React Native import decisions" counts as moved to
@hozo/rn-compat. Comments, aliases and type-only imports are kept. A second run changes nothing.
- Dry run by default. It prints a zero-context unified diff (
git apply --unidiff-zerotakes it) and a summary.--writeapplies it, and refuses files Git has no copy of (uncommitted or untracked), or a checkout that is not a Git repository, unless--forceis given. - Native-only files are left alone. The build makes the move for the Web only. In shared files
it changes nothing on Native:
@hozo/rn-compat'sreact-nativecondition hands back React Native's own objects, and a test fails if a moved API ever stops being one. - Components are not moved. The build also moves
PressableandTextInputto@hozo/core, but on Native those are Hozo's own components rather than React Native's, so writing that move into shared source would change Native.View,Textand the rest are lowered, not moved. - It says when
@hozo/rn-compatis missing frompackage.json. It does not edit it.
What it writes
This is a contract, not a description of the current code: a change that widens it is a breaking change and needs its own decision.
- Without
--fix --write, nothing in the checkout. The one file an ordinary run writes is the report, and only when--outputnames a path. It writes there, creating the directory if needed. - With
--fix --write, only import declarations in the selected source files. It writes the edits the dry run printed and nothing else. That meansreact-nativeimport specifiers moved to a@hozo/rn-compatimport, never a component, and never a Native-only file. - Never
package.json, a lockfile, configuration, or anything outside the selected sources. A missing@hozo/rn-compatis reported, not installed. - Never over work that has no other record.
--writerefuses when a file it would change has uncommitted changes or is untracked (an ignored file selected by--includecounts), and when the checkout is not a Git repository.--forceis the only way past that, and it is per run, not remembered.
The Markdown report opens with Next steps: the order to work in, taken from the evidence
below it and adding no measurement of its own. Files that fail to parse come first, then React
Native JSX left in Web output, errors, a missing @hozo/rn-compat, APIs still on React Native,
warnings grouped by code and counted by file, the adapter subsets to check, and, when the build
moved any import, the optional --fix above. Each step names
its count and up to three files; the full lists stay in the sections it points to.
Interactive terminals receive Markdown; piped stdout stays JSON. --output *.md selects
Markdown, other output paths select JSON, and explicit --format json|markdown always wins.
Use --help for all options and --expected-commit to make a corpus measurement reproducible.
The report deliberately treats its wrong-output count as a lower bound:
absence of a diagnostic is not proof that rendered behavior is correct.
The package does not clone repositories. A caller that owns a corpus should acquire and pin it,
then pass the checkout to this CLI. Hozo's own pnpm measure:bluesky command is one such runner.
It saves both a readable Markdown report and complete JSON evidence under artifacts/measurements/.
JSON schema version 3 retains every finding (message, backend, pipeline stage and location),
per-file outcomes, stage timings, tool versions, the actual loaded binding and its SHA-256,
checkout dirty status and an authored-source fingerprint. Summary samples remain capped at 12;
JSON findings are not capped. --details expands all findings in Markdown. Positions use UTF-16
code units, already converted by NAPI. Positions belonging to rewritten input without an
authored source map are explicitly unmapped, not plausible-looking line numbers. Finding
fingerprints are content hints, not unique identities or baseline matching guarantees.
Web assessment uses the shared module lowering pipeline, including Canvas and import diagnostics.
Native assessment is a compiler-only component/Canvas probe, not full Metro module preparation.
It includes the module-level CLASS_NOT_DRAWN_ON_NATIVE warning. That warning names, per file and
with the reason, a class React Native cannot draw in a class prop the compiler does not lower, such
as one handed to a pattern (handleClassName="before:block" on a BottomSheet).
The compiler selects syntax from each filename: .ts/.mts are non-JSX TypeScript,
.tsx is TSX, and .js/.jsx/.mjs accept JavaScript with JSX, not TypeScript or Flow.
This applies to both authored inventory and imported StyleX definitions. Valid .ts generic
arrows are no longer rejected by a TSX-only probe. Parser errors remain SOURCE_SYNTAX_ERROR;
invalid TSX is not retried as TS to hide errors. This is not TypeScript type-checking.
Each target records integrationEligibility: Web .tsx is semantic-module, other source
extensions are runtime-imports-only; Native non-TSX results are compiler-probe-only.
No extensions are renamed to claim integration support. Probe-only counts are not backend
shape mismatches. scope.tsxFiles retains its literal TSX meaning; authoredFiles is the
new denominator, with extension counts and a separate context-module count.
Parser-reported syntax errors and tool failures remain visible, with partial diagnostic evidence;
the audit does not replace TypeScript or a complete syntax/semantic validation pass. A failed
analysis cannot close the direct RN JSX boundary just because observed residue is zero.
Static CSS/theme is discovered using the same conventional names as Hozo's integrations, or
selected explicitly with --css. Imported CSS is resolved through the shared Tailwind loader.
Colors, spacing, paired dark tokens and project keyframes reach both compiler backends.
Theme resolution means Hozo's existing token extraction completed, not that every CSS/Tailwind
construct is supported or every theme expression converts to Native.
Entry and imported CSS are parsed structurally: executable @plugin / @config and remote stylesheet
imports are explicitly not assessed, never executed. No bundler/config JavaScript or app scripts
are loaded. Safe theme preparation is fresh on each invocation, so edited imports cannot reuse
a stale successful result. CSS paths and content hashes are recorded separately from authored files.
No CSS is an absent entry with a visibly defaulted builtin theme. Broken CSS is invalid;
executable configuration is unsupported. Both yield partial context, with builtin tokens only
as a labelled probe; they cannot close the RN JSX boundary as an authoritative project verdict.
An explicitly missing/unreadable CSS entry is an input error. --preflight auto|true|false is a
reset assumption supplied to both backends, not proof of the application's bundler configuration.
Auto (the default) uses the compiler's Tailwind candidate facts over selected authored JS/TS;
the shared conservative token scan can count utility-shaped comments or unused strings.
Graph-only sources do not contribute to the selected-scope reset assumption. --primitive-source is
repeatable and extends the trusted defaults; discovery never trusts arbitrary component names.
Cross-file StyleX definitions/reexport chains use the compiler's shared module registry in
memory, with separate Web/iOS/Android resolver-owned edges, never another utility engine.
Only graph-requested context files are read outside the authored selection; their hashes and
purpose are retained without inflating files, source bytes or authored signal counts.
Static resolution handles relative imports, directory indexes, ESM .js spellings for TS,
and exact/single-wildcard paths in root JSONC tsconfig.json with local or installed package extends.
The alias fact is scoped to these settings, not all TypeScript/bundler configuration.
The shared compiler resolves explicit package subpaths (including omitted .json), a package's
static tsconfig field or default tsconfig.json, with lookup bounded by the checkout. Internal
package links are accepted only when their physical targets also stay inside it. No install is
performed; missing packages/settings are unresolved, not absent/defaulted success. Both selected
manifests and configuration files enter configurationInputs with hashes, without entering authored
file/byte counts. Base URLs and inherited path origins remain attached to their declaring config;
an effective child base URL takes precedence when resolving paths. Settings are read fresh per audit.
This follows the static subset of TypeScript config inheritance,
not a second application/bundler resolver. The report records checkout-static-json-extends-v1
so previous resolver policies cannot silently provide comparable baseline evidence.
Multiple extends, configuration templates, package exports/custom resolver hooks and bundler aliases
remain explicitly unassessed. Nearest installed-but-broken configs are not guessed into an outer
package, and lookup never escapes to the audit tool's own dependencies or global installs.
No configuration JS, tsconfig plugins, application imports or scripts are executed.
Nonselected but admitted sources can supply graph facts (including excluded authored files);
this does not turn them into migration targets. Unresolved edges are retained per platform.
Default platform preference is .web then unsuffixed for Web, and .ios / .android then
.native then unsuffixed for Native. Explicit filenames remain explicit. This static model is
not a certification of a particular bundler's resolution; custom module suffix policies are not
applied. Shared/native component probes use Android by default; --native-platform ios selects
iOS instead. Explicit .ios / .android filenames always use their own graph. Both native
graphs are prepared, but a shared file is counted only once, for the selected probe platform.
Production builds, runtime behavior and entry-point graph reachability are not assessed.
contextStatus: prepared describes successful theme/reset/static graph preparation,
not that all project facts or unresolved graph edges have answers. Unrecorded generated RN
expressions and complete member compatibility remain explicit analysis limitations, not
requirements for completing the bounded migration-audit work in
#790.
Explicit static font registration facts
--font-availability <file.json> reads the serializable registry returned by
createFontAvailability() from @hozo/typography/fonts. Export JSON from the same manifest
used by the application's integrations, rather than maintaining a separate font inventory:
import { writeFileSync } from 'node:fs'
import { createFontAvailability } from '@hozo/typography/fonts'
// Run this explicitly in your own setup, with your existing font manifest.
writeFileSync('hozo-font-availability.json', JSON.stringify(createFontAvailability(fonts)))The audit itself never imports that setup or an application font loader. Library callers can
instead pass fontAvailability: createFontAvailability(fonts) directly, or fontAvailabilityFile;
the two inputs are mutually exclusive. Data is validated and snapshotted once per audit.
Missing, malformed or executable explicitly selected files are input errors, not a silently
successful default. File paths and physical link targets must stay inside the checkout.
Normalized facts appear in projectFacts.fonts; file paths/content hashes appear in fontInputs,
outside authored file counts. Duplicate IDs/ambiguous platform names, invalid weights/platforms
and contradictory externally managed registrations are rejected rather than guessed.
The canonical compiler analysis uses the existing static font diagnostic, not a second
audit font checker. It checks literal generated family/weight/style declarations for managed
families on Web and the selected Native platform; explicit .ios/.android filenames win over
the default probe platform. Missing platform registrations and variants produce
FONT_FAMILY_NOT_REGISTERED / FONT_VARIANT_NOT_REGISTERED warnings with backend and coordinate
confidence. Unknown families, external platforms, dynamic declarations and host-owned styles
remain unassessed. No input leaves fonts unresolved, even if CSS contains @font-face.
These are supplied registration facts, not proof that font assets exist, are bundled or load on
a device. No asset bytes are opened, remote URLs fetched or registration code executed.
static-font-availability-v1 records this analysis policy. Font facts/input hashes and the policy
participate in baseline comparability; changing/removing them cannot silently resolve warnings.
Older reports without this evidence need a fresh baseline against the new policy.
The schema records corpus.sourceDirectories and omits application-specific metrics
from authoredSignals. Library callers can supply fileSignals: { metricName: (source, file) => boolean }
to measureRealApp; these counts appear separately in corpusSignals and the Markdown report.
measureRealApp and runCli are now asynchronous; library callers must await them. Options
include css, preflight and primitiveSources (an array of explicitly trusted additions).
Library options also include include, exclude (arrays of globs), nativePlatform,
fontAvailability and fontAvailabilityFile.
The pinned Bluesky runner retains its historical filesUsingAlfAtoms lexical heuristic through
this extension. Bluesky's checkout pin, verification command and historical reports are preserved;
that heuristic is not presented as a generic detector of an application's design system.
Binding-aware React Native source inventory
reactNativeUsage counts compiler-resolved ESM references, not import spellings or a
compatibility score. Each JSON file record retains the complete authored bindings and
references with UTF-16 spans. Aliases and lexical shadowing are resolved; explicit type
imports, value imports used only as types, unused values, component values, static/dynamic
member paths, local exports, direct reexports and side-effect imports stay distinct.
Reference counts include opening and closing JSX tag occurrences, not component/call counts.
Parse/binding failures and unassessed files stay visible.
CommonJS require, dynamic imports, TS import-equals, indirect wrapper modules and data-flow
through subsequent aliases are outside this ESM inventory. Moving an import, resolving its
package, removing production RNW dependencies and runtime/member compatibility are separate
claims, not a renamed migration-ready score.
Actual Web import rewrite journal
reactNativeImportDecisions summarizes the shared compiler lowerer's actual import-specifier
decisions. Complete per-file outcomes are in targets.web.reactNativeImports, joined by
bindingIndex to authored source usage. The audit owns no import replacement table and
does not infer these decisions by re-reading emitted code. Only completed targets enter
the successful headline counts; partial/failed journals remain in JSON with their status.
retained-react-native describes an import declaration, not a remaining runtime
reference: JSX lowering may already have removed its uses while leaving the import for
the bundler. type-only does not certify erasure. Namespace/default imports and imports
with attributes are deliberately retained; policy allow leaves rewriting not assessed.
Forwarding/side-effect edges, semantic-reference dispositions, member compatibility,
package resolution, production dependency removal and Native import rewriting remain
unassessed by this journal. Native analysis is still a compiler-only probe.
Actual Web JSX tag journal
reactNativeReferenceDecisions summarizes exact tag emissions from the Web backend.
targets.web.reactNativeReferences retains each authored bindingIndex/referenceIndex
outcome in JSON. Import/Canvas edits remap unchanged source runs only; generated text is
not guessed back to an authored identifier. Opening/closing names count separately, not
as component instances. Void elements can consume authored closing tags.
Preserved tag spelling is not a remaining RN dependency: an import may have moved to Hozo.
Non-JSX APIs, factories, type references, prop/handler expressions and tags without an exact
backend emission remain not-assessed, not implicitly retained or unsupported. Only completed
journals enter successful tag counts; partial/failed evidence stays visible in JSON.
Member compatibility, Native module rewriting, production builds and runtime still require
separate evidence. The audit does not reimplement backend policy or parse emitted code to
invent reference decisions.
Actual Web non-JSX values and review inventory
reactNativeValueDecisions separately summarizes non-JSX runtime references with validated
source-run evidence across actual module edits and an actual import-origin decision. Per-file
targets.web.reactNativeValues retains authored binding/reference indices, final emitted UTF-16
spans, reasons and explicit scope. unchangedModuleReferences and backendCopiedReferences
distinguish untouched module source from actual backend-carried prop/child fragments and DOM
style/spread normalizer values. Nested replacements, discarded void children and changed class
name fragments cannot acquire copied identity. Emitted onPress/responder handler values receive
records too: the function value stays in its authored scope, outside the interactive helper or
the sibling disabled-link callback. Dropped handlers, synthesized callback syntax and other
expressions without emission records stay unknown, not retained; generated expression scope is
copied-runs-only.
Type references
are excluded separately. Completed-file counts include unknowns; failed/partial/unassessed
journals stay in JSON without padding successful headline counts or inventory rows.
The generic inventory groups authored import/member/access and observed origin outcomes, not adapter support guesses. Each row has occurrence counts, distinct-file counts and up to 12 sample files; JSON keeps all individual outcomes regardless of sampling. Retained RN-backed values are a migration review queue, moved values still require member-contract verification, and dynamic/namespace access needs manual review. The audit does not certify member compatibility, package installation, third-party RNW dependence, production dependency removal or runtime.
Reviewed member contracts and next actions
Completed Web value outcomes also carry the compiler's memberContract; the report does not keep
its own ownership/support table. Inventory rows retain the status, declaration-subset limitations,
next action and implementation/test paths. Markdown displays these separately from import origin.
reviewedAdapterReferences, retainedGuidanceReferences and unreviewedMemberReferences partition
the same completed-file non-JSX runtime references, not all RN APIs or a compatibility percentage.
The initial catalogue reviews 18 Platform/StyleSheet/Keyboard/Dimensions members and gives next actions for 10 retained RN members. No-op Keyboard notifications, placeholder Platform.Version and object-only StyleSheet assumptions are explicit. Only direct named-import static members with actual final origin evidence are reviewed; aliases work, while dynamic/namespace accesses, whole values, missing entries and untraced expressions stay unassessed. Missing review means unknown, not unsupported. JSON keeps per-reference reviews; the 12-file sample cap does not cap counts.
These are reviewed declarations and migration guidance, not checks of call arguments, writes,
data flow, installed adapter versions, Native or runtime behavior. memberCompatibility remains
not-assessed. Source paths refer to the compiler's Hozo review, not the audited checkout.
Markdown evidence links point to current main, not a frozen installed-tool source revision.
Partial/failed journals do not contribute positive reviews. No app code/config is executed.
Baseline comparison
Save a JSON audit, then pass it to --compare. The current audit stays complete and gains a
comparison field; Markdown includes the summary and up to 12 records per category, with
--details expanding all finding outcomes. Display limits never cap JSON counts. Library
callers can use compareReports(current, baseline) without running either application.
Comparison is informational by default. It does not fail a command for findings or claim an
improvement percentage. Malformed/unreadable baseline input fails with a short message.
Explicit --fail-on policies can turn findings into a CI gate, as described below.
- Unique compiler subjects match within the same relative file, backend and diagnostic code. Ordinary line movement is continued, not added. Message/severity changes retain both records.
- Duplicate subjects in edited files, missing authored anchors and possible cross-file moves/clones stay unassessed. Exact duplicate records can continue when source is unchanged. Removed files are inventory changes, not proven diagnostic resolutions.
- Repository identities, source selection, effective reset/theme/trusted sources, stylesheet,
alias configuration and graph-only context input hashes must agree. Different settings or
schemas are non-comparable, not lower-error successes. Partial context, failed probes and
target/journal contract changes are explicit. Relocated checkouts need the same
--repositoryidentity; newly recorded stylesheet paths are checkout-relative like graph/config inputs. Older absolute-path CSS records are not guessed into a new location. No app configuration is executed to reconstruct missing inputs. - File-level Web RN boundaries compare the compiler's actual import/tag/value journal category counts, joined to authored usage. Unknowns/types remain visible. These are not matched cross-edit runtime references, member certification or production dependency-removal evidence. Member-review prose/evidence edits alone are not rewrite changes; reviews stay in the audit's separate member guidance inventory.
- Toolchain/source provenance differences are displayed independently. Two reports cannot attribute a count change to a tool upgrade rather than an app edit. Binding install paths are not tool identity; binary hashes and recorded versions remain evidence.
comparable describes this static observation comparison, not that all application behavior,
fonts, indirect RN references, production dependencies or devices were assessed. JSON retains
all ambiguous/partial records and before/after boundaries regardless of Markdown sample limits.
Reports record the compiler's compiler-stylex-reference-context-v1 required-context policy
and per-target stylexContext evidence. An actual import-binding inventory can prove that a
file does not require StyleX context. Consumers instead require conservative reference and
reexport candidates, matching source/dependency hashes, target platform and recorded resolver
edges. Missing, recovered, stale or contradictory evidence is unassessed, not a successful
empty graph. Runtime imports and all unresolved alias/graph observations remain in JSON;
this policy does not certify them or suppress setup warnings.
An unresolved alias preset no longer blocks every file if the compiler proves the missing
settings unnecessary for that file's static lowering. CSS/theme preparation remains a global
requirement, and effective input/settings equality still applies. Edited authored StyleX
dependencies leave affected consumers unassessed; unrelated files can still be compared.
If any file is unassessed the aggregate comparison is partial, so new-errors stays blocked
with newErrors: null. The new policy cannot inherit an old-policy baseline: take a fresh
baseline. Legacy and unknown policy reports retain conservative completeness handling.
The Markdown required-context table is a static analysis observation, not a compatibility score.
Explicit CI failure policies
--fail-on none|error|new-errors controls diagnostic exits. The default is none.
| Policy | Exit behavior |
|---|---|
| none | Findings are informational, including authored syntax errors. |
| error | Fail for any current error finding across Source, Web or Native. Warnings do not fail. |
| new-errors | Require --compare and a comparable result; fail for added errors or a matched finding escalating from non-error to error. Continued errors and error-message-only changes do not fail. |
Input errors and tool/analysis failures are nonzero in every mode. A compiler exception
(ANALYSIS_FAILED) or incomplete file analysis cannot be grandfathered by a baseline. Authored
syntax rejection (SOURCE_SYNTAX_ERROR) is a diagnostic, not a crashed compiler; it stays
informational in none, fails error, and prevents a complete new-errors comparison.
Partial/non-comparable baselines are blocked, not zero new errors. This includes missing
project facts, ambiguous/moved subjects, removed files, changed scope/settings and failed probes.
For such a project, use error for the current known errors or keep none for observation;
do not clear unknowns or weaken the baseline's evidence to get a green result. Even a passed
policy is not migration readiness, runtime correctness or a full compatibility assessment.
The CLI writes the complete JSON/Markdown report before returning a policy failure, so CI
can upload evidence on failed steps. Invalid arguments/baseline input fail before analysis or
output. Exit 0 means the selected policy passed; 1 means a failed/blocked policy or input/tool
failure. A failed/blocked policy also emits a brief stderr explanation without polluting JSON stdout.
failurePolicy version 1 records mode, status, exit code, reasons and uncapped finding indices.
errorFindings and analysisFailures index current findings; newErrorFindings index the
comparison's findings.added or findings.changed array. newErrors: null means not assessed,
not zero. Counts describe backend finding occurrences, not distinct source defects.
Library callers can use evaluateFailurePolicy(report, mode) after measureRealApp/compareReports.
runCli records the policy and returns the report without changing process.exitCode; only the
executable applies the exit. This keeps embedded audit calls independent of the caller's process.
Part of Hozo, a universal UI framework for React Native, compiled by Rust, with native semantics on every platform, first-class accessibility, and diagnostics for humans and AI agents alike. Most applications install @hozo/core and one build integration; see Getting started. Source and issues: github.com/iray-tno/hozo.
