feat: cross-port metadata sources (four CLIs, one config) + migration-chain replayability (#313) - #315
Merged
Merged
Conversation
…oning gate (#313) Design for #313, reshaped by review and a two-arm challenge from the obvious version. Two findings changed it. First, the gate the reporter needs is not the gate this project already designed. verifyReplay exists, is exported, and has no CLI caller because the 2026-05-31 design retained replay only as an optional integrity aid that compares a replayed database against the committed snapshot. Comparing against the snapshot is unpassable for three supported adoption paths: baseline --from-db writes the whole introspected schema against an empty chain, migrate.scope deliberately carries another owner's tables into the snapshot, and no migration ever emits CREATE SCHEMA. Asserting only that the chain APPLIES from empty catches the reported bug, passes the first two classes, and leaves one true positive that CREATE SCHEMA IF NOT EXISTS fixes. So: one subverb, two tiers. Second, the scratch-database provisioning the obvious design reached for is unnecessary and dangerous. The prior design already specified an in-process engine tier (PGlite for postgres, :memory: libsql for sqlite). That needs no CREATEDB, survives connection poolers and managed Postgres, cannot collide between parallel CI jobs, and has no database to accidentally drop — where a derived scratch name could truncate at Postgres's 63-byte identifier limit back onto the very database it was about to DROP. Also rules that IF EXISTS applies to forward drops only: with it on a create-table down, a rollback whose object is already gone would no-op and still delete its ledger row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…heck The prior text cited sqlite.ts:150-152 and :226-227 as evidence that those change kinds trigger a table rebuild. sqlite.ts:144-156 is changeTable(), a change-to-table-name mapper, not a rebuild list — the citation was a misreading of grep context. The conclusion holds on better evidence: renderUpNative THROWS for add-check/drop-check/add-fk/drop-fk (sqlite.ts:225-235, 'should have been handled by recreate bundler') because SQLite constraints are create-time-only and inline. Separately, drop-column DOES emit natively on sqlite (:222), so its exclusion rests only on SQLite lacking DROP COLUMN IF EXISTS. Two different reasons, both structural; the earlier text merged them into one wrong one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A challenge of the spec's three rulings, plus checks run to settle every factual split it surfaced, convicted the spec on four points and narrowed a fifth. 1. postgres.ts:431 removed from the change list. renderRestoreView is reached only from :178/:179, both in the DOWN renderer, so guarding it violated the forward-only rule in the same section that stated it. 2. drop-fk/drop-check are now guarded on Postgres, and the old rationale is struck as backwards. renderRecreate builds the replacement table from the EXPECTED descriptor and never references the dropped constraint, so SQLite is already replay-safe; Postgres is the only dialect that can fail on an absent constraint, and guarding it makes the two agree rather than diverge. 3. drop-column stays excluded — the one genuine dialect limit, since sqlite emits it natively with no IF EXISTS form. 4. --strict becomes the --replay-snapshot subverb. verify already owns --lax on a different axis (ADR-0023 attribute strictness), and --strict beside it would read as that flag's opposite. 5. The baseline-skip clause is dropped as unimplementable. Its only candidate signal, recordBaseline/BASELINE_NAME, has no production caller, and it would land in the target database's ledger while the gate runs against a fresh in-process database with no ledger. Documented as a limitation instead. 6. The 'three unpassable paths' argument is narrowed to one. scopedDiffInputs narrows BOTH sides — out-of-scope names merge into unmanagedNames — so a scoped project passes under the reporter's literal formulation, and @Schema is a true positive rather than an obstacle. Also records sqlite.ts:275 as a known pre-existing deviation, left alone: its create-view down already emits IF EXISTS while the Postgres twin is bare. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight tasks over the spec: IF EXISTS on forward drops, CREATE SCHEMA IF NOT EXISTS, the in-process replay engine, scope threading into verifyReplay, the two verify subverbs, the emit-time provenance guard, and docs. Two places deliberately name a file to copy rather than inlining code: the Kysely adapter wiring for PGlite, and the migrate test harness for the guard. Guessing either from memory would put wrong code in a plan an implementer is told to follow verbatim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reproduction quoted a real table name from the bug report. This repository is public and the rule is to genericize adopter and project identifiers on sight, so the error text now uses a neutral name. The behaviour it documents is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prior plan was reviewed as not ready to execute. Thirteen findings came
from that review and seven more from re-verifying it against the code; all are
now folded in, and every claim below was read at HEAD rather than remembered.
Would have produced wrong code:
- `--replay-snapshot` shipped dead. Task 5 gated on `flags.replay` and Task 6
never widened it. The condition is now written once, in Task 5, as
`(flags.replay || flags.replaySnapshot)`, and Task 6 adds behavioural tests
instead of three flag-parse assertions.
- The claim that `drop-check` is unreachable is false — `diff/index.ts:579`
and `:592` both push it and two tests assert the emitted statement. The
paragraph is deleted, the code comment is corrected as part of the task, and
`drop-check` gets a forward-drop assertion the plan previously forbade.
- Task 4's fixture could not pass three ways: `{kind:"int"}` is not a
`SqlType` (`{kind:"integer";bits:32|64}`), `GovernedScope` names are
`<schema>.<name>`, and a bare `INTEGER PRIMARY KEY` reports `notnull=0` on
sqlite. `excludeFromSnapshot` also returns a `ScopedExpectedSchema`, so the
fix takes `.snapshot`. Every `as never` is gone.
- `pg-constraint-backed-index-285.test.ts` needs two edits, not one: `:143`
goes red and `:142` goes VACUOUSLY green, since `IF EXISTS` stops its
negative regex matching for a reason unrelated to what it tests.
- A new `--allow` token touches four files, pinned by
`allow-tokens-pinned.test.ts`. Ruled: add `dropUnmanaged` to `AllowOptions`
rather than a second parallel token path, which is the drift that pin exists
to prevent.
- The provenance guard had no snapshot and no fail-open rule. The live site is
named, the loader mirrors migrate's own spelling, and a missing snapshot
fails OPEN.
Would have stalled:
- PGlite is not `pg`-compatible. The ~20-line kysely pool shim is now in the
plan, EXECUTED against 0.3.16 and 0.5.5: DDL, schemas, CHECK, transactions,
rollback, advisory locks, and the reporter's own error. `:memory:` libsql is
verified working and isolated between instances.
- The engine's home is settled: `migrate-ts` (cli depends on it, so cli would
be a cycle), with PGlite as an OPTIONAL peer — 22 MB of WASM must not reach
every `meta gen` adopter — plus a `build:binary` `--external`.
- The dialect precedence without `--db` is stated, and the comment that
forbids reading migrate's config is amended rather than contradicted.
- `governed` is derived offline via `scopeExpectedSchema`, and only for a
scoped project, so an unscoped comparison is unchanged.
- "Do not replay twice" is restated as what it is: the second `applyPending`
is a ledger no-op, not an API change.
- The leak scan uses the project hook, not an invented pattern.
Also: Task 1 now fixes its own churn so no later task inherits a red suite;
Task 2 collects from `create-view` too, since the spec says "the first
object"; zero-migrations is detected from `ApplyPendingResult.pending`
because `discoverMigrations` is module-private; and the task DAG is explicit.
The missing test that mattered most now exists: the RED-first regression runs
`emit` -> `writeMigration` -> `applyPending`, so it is red before the emitter
fix rather than green regardless of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…replay `meta migrate` writes a bare `DROP TABLE "x"` when x is present in the live database and absent from metadata — even when no migration in the chain ever created x, which is what happens when another tool owns the table. Replaying that chain against an empty database dies with `table "x" does not exist` (#313). The reporter's chain was broken for three months. Every FORWARD drop now carries `IF EXISTS`, in both dialects: `drop-table`, `drop-view` (plain and CASCADE), `drop-index` (both the plain arm and #285's constraint-backed `ALTER TABLE ... DROP CONSTRAINT`), `drop-fk` and `drop-check`. Three exclusions, each deliberate and now pinned by a test rather than remembered: - DOWN statements stay bare. `rollbackTo` runs down.sql and the ledger delete in ONE transaction, so a guarded down would no-op and still record the rollback as done. Rollback is where a loud failure earns its keep. - The sqlite recreate-and-copy rebuild's `DROP TABLE`, and d1-cascade's, stay bare: each drops a table the same recipe just INSERT…SELECTed from, where IF EXISTS converts a caught corruption into a silent one. - `drop-column` stays unguarded — sqlite has no `DROP COLUMN IF EXISTS`, so guarding Postgres alone would make the same declared change behave differently per dialect. The emit-time provenance guard covers it instead. `drop-fk`/`drop-check` are Postgres-only and that is NOT a dialect split: sqlite emits no standalone statement for either kind (renderUpNative throws), because its constraints are create-time-only and inline, so the change folds into a recreate that rebuilds from the EXPECTED descriptor and never names the dropped constraint. Sqlite was already replay-safe there; this makes the two dialects agree. Sqlite's forward `drop-view`/`replace-view` were already guarded, so the sqlite change is two lines. Also corrects a false comment: `emit/postgres.ts` claimed add-check/drop-check were "declared but NOT yet produced by the diff". `diff/index.ts:579` and `:592` both push drop-check, an evolved `field.enum @values` is a live producer, and two tests already asserted the emitted statement. `emit/d1.ts` renders through renderSqlite, so D1's committed migrations change too — independently correct, and D1 keeps the apply-pending refusal it has. Test churn: eight assertions pinned the exact bare statement and are updated to the guarded form. One needed more than a token swap — pg-constraint-backed-index-285's NEGATIVE assertion (`not.toMatch(/DROP INDEX "?work_item…/)`) would have gone VACUOUSLY green, since IF EXISTS stops it matching for a reason unrelated to #285 and it would keep passing if #285 fully regressed. Re-anchored to `(IF EXISTS )?` and verified by emitting the regressed shape and watching the old regex fail to catch it while the new one does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… virgin database `CREATE SCHEMA` was emitted nowhere in either emitter — only by the ledger's own setup. A chain containing `CREATE TABLE "reporting"."x"` therefore could never apply to an empty database: the schema does not exist, and no migration ever creates it. Every `@schema` project's chain was unreplayable, and the first `apply-pending` against a fresh CI database died (#313). `renderPostgres` now prepends one `CREATE SCHEMA IF NOT EXISTS "<s>";` per distinct non-default schema the migration creates an object in. VIEWS count, not only tables. The spec says "the first OBJECT", and a first migration that creates only a view in a non-default schema fails identically. A `create-view` carries the schema in two places, so the collector reads `c.schema ?? c.view.schema` — the same precedence `renderCreateView(c.view, c.schema, …)` already applies. `IF NOT EXISTS` because a later migration in the same chain, or an operator, may have created it already. Sorted, so output stays deterministic for the committed snapshot and the golden tests. A drop-only migration emits nothing, so a migration that removes the last object from a schema does not resurrect it. The down deliberately does NOT `DROP SCHEMA`: the schema may hold objects this tool does not own and cannot restore. That is asserted, not assumed. Postgres-only, and not a dialect split — sqlite has no schema namespacing at all and rejects a declared schema outright (`emit-sqlite-schema-rejected`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thing `openReplayEngine(dialect)` hands back an empty database and a disposer: `:memory:` libsql for sqlite, PGlite — real Postgres compiled to WASM — for postgres. The #313 gate needs to apply a whole committed chain from nothing, and this is where that nothing comes from. Why not a scratch database on the user's server, which an earlier design chose: it needs CREATEDB, breaks behind a connection pooler, is restricted on managed Postgres, collides between parallel CI jobs sharing one server, and — because Postgres truncates identifiers at 63 bytes — a long enough target database derives a scratch name that truncates back ONTO the target, putting a `DROP DATABASE IF EXISTS` next to the real one. In-process has none of those failure modes and nothing to clean up. PGlite is NOT pg-compatible: it exposes query/exec/close, while kysely's PostgresDialect wants a pg.Pool (connect() → a client with query()/release(), plus end()). `pgliteAsPool` is that adapter. PGlite is a single session, so every connect() returns the same instance — correct for a strictly sequential replay, and what makes a session advisory lock taken on one kysely connection visible to the next. Verified against 0.3.16 and 0.5.5, and pinned by the tests: schema namespacing, a CHECK constraint, transactions, transaction ROLLBACK, pg_advisory_lock/unlock (applyPending takes one on postgres), engine isolation between two instances, idempotent dispose, and — the signal the whole gate rests on — `DROP TABLE "theirs"` rejecting with `table "theirs" does not exist`, the reporter's own error, on both dialects. It also proves `introspect` works against PGlite (information_schema, pg_catalog, pg_get_viewdef), which is what the `--replay-snapshot` tier needs and the only remaining unknown in that design. Both drivers are OPTIONAL peers imported lazily, with install hints. PGlite is ~22 MB of WASM and must not reach the node_modules of every adopter who only runs `meta gen`; `cli`'s `build:binary` gets a matching `--external` so the standalone binary does not embed it either. Peer ranges are bounded, so check-peer-ranges stays green (28 ranges, all bounded). It lives in `migrate-ts` rather than `cli` because migrate-ts's own tests need it and `cli` depends on migrate-ts — the other direction would be a cycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine shipped one commit ago used `:memory:`, as the design specified. Under `@libsql/kysely-libsql` that gives every CONNECTION its own database, so a table created inside a transaction is gone the moment that transaction's connection is released. `applyPending` runs every migration file inside a transaction. So the engine would have replayed a whole chain into a series of throwaway databases: migration 2 could not see migration 1's tables, the introspection afterwards saw an empty database, and `--replay` would have reported success having proved nothing. A gate that cannot fail is worse than no gate. It is now a throwaway sqlite file in a private `mkdtemp` directory, removed on dispose — which is what `test/integrity/replay.test.ts` has always used. Rejected on the way there, both measured rather than assumed: `file::memory:?cache=shared` fixes visibility and breaks isolation instead (two engines in one process land in the same database), and libsql refuses the named `?mode=memory&cache=shared` form with `URL_PARAM_NOT_SUPPORTED`. Found by `verifyReplay` reporting `mine` as missing from a chain that plainly creates it — the scoped-replay test written for the NEXT task, which happened to be the first thing to drive a real chain through `applyPending`. The engine's own suite had nine passing cases and none of them could see this: every sqlite case ran its DDL outside a transaction. It now asserts, on BOTH dialects, that a tx-created table survives its transaction, is visible to a second transaction the way migration 2 sees migration 1's, and is present in the introspected snapshot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A project declaring `migrate.scope` carries the OTHER owner's tables into its committed snapshot on purpose (`carryForwardOutOfScope`), and its chain — also on purpose — never creates them. `verifyReplay` compared the replayed database against that snapshot whole, so every such project saw its neighbour's tables reported as missing and could never use the check at all. `VerifyReplayArgs` gains an optional `governed: GovernedScope`, applied through the existing `excludeFromSnapshot` rather than a second hand-rolled copy of the scope contract. Excluded from the SNAPSHOT side only: the replayed database never had those tables either, so there is nothing to suppress on the actual side. Omitting it leaves the comparison byte-for-byte what it was, which is what every unscoped project gets. `excludeFromSnapshot` returns a `ScopedExpectedSchema`, not a `SchemaSnapshot` — the fix takes `.snapshot`. Three cases, and the last two are what keep the first honest: with `governed` the out-of-scope table is not reported; WITHOUT it the same fixture reports drift, so the pass demonstrably comes from the scope threading and not from a trivially-green fixture; and an EMPTY `outOfScope` still reports drift, so the new field cannot become a way to suppress a real difference. Names are qualified `<schema>.<name>` with an absent schema normalized to the Postgres default, so the fixture says `public.theirs` — sqlite objects land under that same constant prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two new verify subverbs. `--replay` replays the committed migration chain into an empty throwaway database and asserts it APPLIES — the #313 gate. `--replay-snapshot` additionally asserts the result equals the committed snapshot, finally wiring `verifyReplay`, which has been built and exported with no CLI caller since the 2026-05-31 design retained it as "the optional verify --replay integrity aid". They are two tiers rather than one gate because the populations differ. A project adopted via `migrate baseline --from-db` passes the first trivially — its chain is empty, so there is nothing to fail — and CANNOT pass the second by construction, since its snapshot is the whole introspected database. The reporter's failure was an APPLY error, so the weaker assertion is the one that answers the bug, and it is immune to that class. The second tier does not try to detect baseline adoption: the only candidate signal has no production caller and would live in the target database's ledger while the gate runs against a fresh engine with no ledger, so the failure message names it instead. Both flags feed `anyExplicit`, or `meta verify --replay` would also run the template gate as the bare-verify default. Both select ONE gate — the composed condition is `flags.replay || flags.replaySnapshot`, written once, because naming only `flags.replay` there is exactly how `--replay-snapshot` would parse cleanly and reach nothing. Neither needs `--db`; the engine is local and disposable. That leaves no URL to infer a dialect from, so the precedence is stated: `--dialect` wins, else migrate's own resolved `migrate.dialect`, else refuse with exit 2 naming `--dialect` — guessing would replay a postgres chain through sqlite. Reading migrate's dialect required amending #292's `EMPTY_MIGRATE_FLAGS` note, which said verify consumes only `outDir`; that restriction was about the drift gate, whose dialect comes from the live URL. Flyway and d1 are refused, mirroring `apply-pending`. Zero committed migrations is not a silent pass, and neither is a missing snapshot: both report and return 0. `discoverMigrations` is module-private, so the empty-chain signal is `ApplyPendingResult.pending` — every migration is pending against a fresh engine, so an empty list means the directory held none. A scoped project's `governed` is derived offline from `scopeExpectedSchema`, and only when `migrate.scope` is actually declared, so an unscoped comparison is unchanged. Tests drive `verifyCommand` end to end against real projects on disk, not just the parser. Three are built specifically so they cannot pass for the wrong reason: a broken chain fails under `--replay-snapshot` ALONE (the dead-flag regression); a chain that applies cleanly but grows a table the snapshot never recorded passes `--replay` and fails `--replay-snapshot`, which is tier 2's entire reason to exist; and the d1 refusal runs under `--skip-schema`, because without it the D1 schema gate also returns 2 and the assertion would hold whether or not the replay gate refused. The migrate-ts side adds the regression the earlier plan was missing: a chain built by `emit` → `writeMigration` → `applyPending`, on both dialects, plus the non-default-schema case. Confirmed RED by reverting the emitter fixes — all three fail — rather than assumed. A hand-written-SQL test cannot do this: it stays green no matter what the emitter writes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The replay gates make a broken chain survivable and detectable. This stops it being written. The live migrate path diffs metadata against introspection and never reads the committed snapshot, so a table another tool owns reads as "in the DB, not in the model" and is proposed for a DROP. The migration that results cannot replay against a database where that object never existed — which is how the reported chain stayed broken for three months while every `meta migrate` reported success. `drift/classify.ts` has always stated the doctrine (objects present in the DB but not the snapshot "must never be treated as actionable drift or auto-dropped"); this is the first place it is enforced where it mattered. `meta migrate --from-db` now collects every `drop-table`/`drop-view` whose qualified name is absent from the committed snapshot and, without `--allow drop-unmanaged`, refuses with exit 2 naming each object. It does NOT false-fire on brownfield projects, and the reason is structural: both mechanisms ADD to the snapshot. A `baseline --from-db` snapshot contains the foreign table, and a scoped project carries its out-of-scope entries forward. The guard fires precisely when nothing ever claimed the object. Both are pinned as tests, because a guard that broke every adopted project would look identical in code review. It FAILS OPEN on a missing or unreadable snapshot — refusing there would break the first `meta migrate` of every greenfield project, which has no snapshot by definition. And it lives on the live path only, which is not an omission: the offline path diffs metadata against the snapshot, so it can only ever propose dropping something the snapshot HAS. Names come from `qualifiedDbName` and nothing else. Three independent sets already have to agree on that spelling — the diff's identity maps, the `@unmanaged` exclusion set, the out-of-scope set — and a fourth encoding of "absent schema means public" would silently un-guard whatever it disagreed about. A new `--allow` token touches four files, three of them pinned together by `allow-tokens-pinned.test.ts`: `ALLOW_TOKENS` (cli, the validator), `AllowTokenEnum` (sdk, which validates `migrate.allow` in config.json), `ALLOW_TOKEN_MAP` (cli, what actually GRANTS), and now `AllowOptions.dropUnmanaged`. Ruled deliberately: `dropUnmanaged` joins `AllowOptions` even though `diff()` never reads it, because the alternative — a second token list and a second parse path for one token — is exactly the drift that pin exists to prevent. The field documents that exception rather than leaving it a puzzle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…promise `docs/features/migrations-and-drift.md` and `meta migrate --help` both promised that `apply-pending` "is the way to provision a fresh or CI database". That is true only of a chain that BUILDS the schema, and nothing checked — which is the promise #313 broke. Both are now scoped to that population and point at `meta verify --replay` as the way to know you are in it; a project adopted with `baseline --from-db` is named as the case that is not. Adds an adopter-facing section for the two replay tiers: what each asserts, why they are two rather than one, that they provision nothing (PGlite in-process / a throwaway temp file) and need no `--db`, that PGlite is an optional peer, how the dialect resolves without a URL, the exit-code convention, and — stated plainly rather than left to be discovered — that `--replay-snapshot` cannot pass for a baseline-adopted project. It also documents the remediation, since a gate whose failure has no documented exit gets suppressed, and applied migrations are checksum-immutable so hand-editing a committed up.sql is not it. Documents `--allow drop-unmanaged` with the real refusal text, and why it does not fire for brownfield projects. Documents the emitter changes with their deliberate exclusions, so the forward-only rule is written down rather than remembered. CHANGELOG leads with the new refusal, because it is the one change here that can fail an existing project's `meta migrate`; the emitter changes and the two subverbs follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e IF EXISTS change `view-lifecycle-pg` lives in the separate `integration-tests` package, which neither `bun test packages/migrate-ts` nor `packages/cli` runs, so the #313 forward-drop change left it red without any of the suites run alongside that change noticing. Only the ts-slow lane covers it. One assertion, on the CASCADE branch of `renderDropView` — a FORWARD drop, so `DROP VIEW IF EXISTS … CASCADE;` is the correct new text. The banner and dependent-destroyed assertions around it are unchanged, and the test still applies to a real Postgres and re-diffs to convergence. A workspace-wide sweep for the same shape found nothing else: every other bare `DROP …` assertion is a DOWN statement, the sqlite recreate-and-copy rebuild's drop, or hand-written SQL in a write-migration test — all three deliberately unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pply result `migrate.dialect` resolves to undefined when neither a flag nor the config supplies one, so the replay gate's "no dialect" refusal is a reachable branch rather than dead code — and nothing exercised it. With no `--db` there is no URL to infer from, and guessing would replay a postgres chain through sqlite. The new case runs under `--skip-schema` and carries a control that names the dialect on the command line, so the exit 2 is demonstrably the refusal and not the fixture. Also replaces a bare `let applied;` with an explicit `ApplyPendingResult` — an untyped `let` is an implicit evolving `any`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A two-arm challenge of the cross-language question settled on native-surface- first with the port-neutral config as the fallback — not the phase-1 design's 'all five CLIs parse config.json'. Twelve factual splits between the arms were resolved against the code; three changed the design: - Every port's loader ALREADY accepts a set of sources (Java :1541/:994, C# :334, Python :102), so this is CLI plumbing, not engine work — which retires the resolved-manifest shape entirely. - Java's shipped <filters> grammar collides with scope on the same characters: its '*' crosses '::' (with a TODO in GeneratorUtil conceding the separator handling is broken) and its '@' matches one segment — respectively scope's '**' and '*', inverted. Both are output filters, so they compete rather than layer. scope is therefore split out into its own decision and does NOT ship. - config.json carries TypeScript-owned keys (pending_in_git, confidence_thresholds, extract, migrate) behind a .strict() schema, so the ports read a declared NEUTRAL SUBSET and ignore the rest. Also records what is deliberately NOT a contract: resolved file ORDER, which already differs per port (Java sorts by basename, C# by full path, TS walks depth-first) and which the loader discards anyway. The writer gap is resolved Node-side as 'meta init --config-only' rather than four port writers, since migrate/verify --db are Node-only under ADR-0015.
Pins the resolved file SET — not its order, which already differs by port and is not a contract. TypeScript is the reference and ships the first runner; the other three follow in this changeset.
Fix round 1 finding (Important): the corpus asserted "matched case-insensitively" as a semantic but no case exercised an uppercase or mixed-case extension, so a port implementing ext === ".json" would pass every case and only diverge on a real project. Adds a case mixing .JSON/.YAML/.Yml alongside a lowercase control file and an uppercase unsupported extension, to also catch case-folding that over-matches.
Neutral subset only (schema_version + sources); unknown top-level keys are ignored so a TypeScript-owned key never becomes a four-port change. Adds resolve_sources/resolve_collection mirroring the TS reference (sdk/src/sources.ts, collection.ts): relative path sources resolve against the config's own directory, a declared sources list replaces the default entirely, and the default metaobjects/ directory is the only source allowed to be silently absent.
…ystem resolve_sources previously interleaved kind validation with per-spec path resolution, one spec at a time, so which error code came back for a multi-spec list containing both an unsupported kind and an unresolved path depended on declaration order. TypeScript's orderedPathSpecs deliberately validates every spec's kind across the whole list before any stat() call (sources.ts:75-79) precisely to keep that order-independent; Python now matches, verified empirically against the shipped TS reference in both declaration orders. Also tightens two error-path tests to assert .code, not just the exception type, and adds a corpus pair (both declaration orders) pinning the precedence as a cross-port contract so the C# and Java ports (which mirror this Python design) can't independently diverge on it.
…mes a location Ladder: explicit arg > metaobjects.config.yaml > .metaobjects/config.json > default dir, implemented as resolve_metadata_location() in cli.py. Gated by the shared source-resolution corpus (19 cases) plus two CLI-level tests: the neutral-config fallback itself, and a relative explicit <metadata_dir> arg (the plan's original ladder resolved a relative explicit path one directory too deep by joining it onto its own already-absolute parent; fixed by resolving the argument to an absolute path first). The conformance runner honors the corpus's resolveFrom key, invoking resolution from a subdirectory while still comparing expectFiles against the project root. Also documents in the corpus README that the specific error code for a malformed .metaobjects/config.json is deliberately not part of the cross-port contract (TypeScript's collection.ts has no try/catch there and emits no MetaObjects code at all), same as file order.
…codegen
Fix round 1/5, two Critical findings.
1. resolve_metadata_location() existed and was unit-tested but no command
handler called it, so `metaobjects gen`/`verify --codegen` still errored
on no positional <metadata_dir> + no metaobjects.config.yaml instead of
falling back to .metaobjects/config.json. _cmd_gen_config and
_verify_codegen_config now route that case to new
_cmd_gen_neutral_fallback / _verify_codegen_neutral_fallback helpers
(added _load_root_from_paths, loading via MetaDataLoader.from_uris since
the ladder can resolve to several directories or individual files that a
single from_directory call can't express). --out is required at this rung,
exactly as it is in explicit-<metadata_dir> flag mode; the two byte-
identical rungs (explicit arg, metaobjects.config.yaml) are untouched.
Extracted _diff_report() out of _verify_codegen so both entry points
report drift identically. Added three end-to-end tests driving `main()`
through the .metaobjects/config.json and bare-default-directory rungs for
both gen and verify --codegen.
2. test_explicit_relative_metadata_dir_resolves_against_cwd used a single-
segment relative argument ("model"), which cannot distinguish the fixed
ladder from the original defect: Path("model").resolve().parent happens
to land back at the project root, so the extra join silently cancels out.
Changed to a multi-segment path ("sub/model"), which the buggy formulation
resolves to a nonexistent .../sub/sub/model. Verified both directions by
temporarily reinstating the buggy formulation (confirmed FAIL) and
restoring the fix (confirmed PASS) before committing.
Adds MetaObjects.Config.NeutralConfig (the schema_version + sources neutral subset of .metaobjects/config.json, ignoring every TS-owned key) and SourceResolver (declared-source-set -> deduplicated file list, plus the full default-directory ladder), gated by the shared 19-case fixtures/source-resolution-conformance/cases.json corpus. Kind validation runs in two explicit passes across the whole declared set before any filesystem access, matching sources.ts's orderedPathSpecs - interleaving validate-then-resolve per spec would make which error fires depend on declaration order, which two corpus cases pin against. Directory expansion reuses the loader's own DirectorySource rather than a second extension-filter/sort implementation. The CLI's positional <metadataDir> becomes optional on gen/docs/verify, falling back through the same resolver: a single declared source hands the loader that source's own root (never the literal default name), and more than one declared source is refused outright rather than silently picked from - this port's loader takes one directory, not a source set. Reachability is proven by spawning the built CLI assembly as a subprocess rather than only unit-testing the resolver function, since a resolver nothing calls is invisible to a normal test run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds com.metaobjects.config.NeutralConfig + SourceResolver (metadata module) reading the neutral subset of .metaobjects/config.json (schema_version + sources; TS-owned keys ignored) and resolving a declared source set to a de-duplicated file list, kind-validated in one pass ahead of any filesystem access so the winning error code never depends on declaration order. Gated by the shared fixtures/source-resolution-conformance/cases.json corpus (19 cases). Wires the resolver into AbstractMetaDataMojo.createLoader via resolveNeutralSourcesIfPomIsSilent(): whole-concern precedence — a pom naming <sourceDir> or <sources> owns the concern outright and the neutral file is never consulted. <filters> is untouched; scope stays TypeScript-only. Reachability is proven by a mojo-level test driving createLoader end-to-end (not just the resolver in isolation), across the neutral-config, default-directory, and no-collection-found arms. Kotlin needs no separate change: it has no CLI entry point of its own and runs through this same Maven plugin.
Spans Java, C# and the shared corpus (Task 5 fix round 1; reopens
Task 4). NeutralConfig's `sources` guard in both ports checked
`isJsonArray`/`ValueKind == Array` only when present, so a
present-but-wrong-typed `sources` (e.g. a bare object instead of an
array) fell through as an empty list and silently degraded to the
default `metaobjects/` directory with no diagnostic - reproduced
against a stale default-dir file sitting next to the author's
intended source. Both ports now raise (ERR_BAD_ATTR_VALUE) whenever
`sources` is present, non-null, and not an array; `sources: null`
keeps behaving as absent, unchanged.
Adds a corpus case for this (`sources-must-be-an-array-not-an-object`)
exercising both ports' new guard. TypeScript and Python were checked
empirically rather than assumed: TS's reference raises a raw ZodError
with no error code at all, Python already raised
ERR_COLLECTION_NOT_FOUND - three distinct outcomes across four ports,
confirming they cannot agree on a single code here. Rather than
change TS or Python behavior to force agreement, `expectError` in the
corpus schema now accepts JSON `true` ("must raise, code
intentionally unpinned") alongside the existing exact-code string
form; only each port's test-runner corpus-interpretation code was
updated to understand it, not resolveCollection/NeutralConfig/
neutral_config.py/config.ts themselves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes .metaobjects/config.json and nothing else, so a Maven- or pip-rooted project can declare its sources for the Node CLI without acquiring a TypeScript scaffold it will not use.
Records the precedence ladder, the neutral-subset rule, why scope stays Node-only, and that file order is deliberately not a contract.
"What is deferred" claimed cross-port sources reading was still unbuilt, directly contradicting the "Port support" paragraph 590 lines earlier that this branch just corrected. Reworded the bullet to describe what is actually still deferred in that area (scope/migrate.scope staying Node-CLI-only), and added the fixtures/source-resolution-conformance/ corpus to "Verified by", which previously named only the scope corpus.
… one case
NeutralConfig.read() exempted a JSON-null `sources` from the "must be an
array" raise, so `sources: null` silently fell back to the default
`metaobjects/` directory instead of raising like every other wrong-typed
`sources` value. The fix is the general rule (a present `sources` key that
is not an array raises), not a null-specific carve-out.
Also: reject a `sources` entry whose value is not a non-empty string —
previously a bare number was silently stringified, so `{"path": 123}` would
load a directory literally named "123" instead of failing on the typo'd
config. Ports the TypeScript loader's `_pending/`-at-any-depth exclusion
(previously TS-only) into both DirectorySource.expand() and
SourceResolver's own directory walk, so a draft entity under `_pending/` is
invisible to `mvn metaobjects:generate` the same way it already is to the
Node CLI.
Stale "18 of 19 cases" test comments reworded to describe the corpus
structurally instead of by count, since the count had already drifted
(actual was 20).
…he one case
NeutralConfig.Read() had the same null-exemption gap as Java: a present
`sources: null` read as absent and fell back to the default directory. Fixed
as the general rule, collapsing the two separate `TryGetProperty("sources")`
calls into one guarded block in the process.
Also: reject a non-string `sources` entry value (previously silently
stringified via GetRawText(), so a bare number would load a directory named
after its digits); accept `schema_version: 1.0` by comparing as a double
instead of GetInt32(), which threw a raw, uncoded FormatException on any
float-looking literal — the other three ports already accept it as equal to
1; and port the `_pending/`-at-any-depth exclusion into DirectorySource
(shared by both the loader and SourceResolver), so a draft entity is
invisible to `dotnet meta gen` the same way it already is to the Node CLI.
`dotnet meta`'s CLI: a single declared `sources` entry that resolves to a
FILE (rather than a directory) now refuses with a diagnostic naming the
actual limit — the loader takes only a directory — instead of failing deep
inside DirectorySource with an opaque, uncoded ERR_UNKNOWN. Mirrors the
existing clear refusal for a multi-entry `sources`.
Stale "18 of 19 cases" test comment reworded to describe the corpus
structurally instead of by count, since the count had already drifted.
…; wire docs into the ladder
`sources` entry values were checked for key count (`len(s) == 1`) but never
for TYPE or emptiness — a non-string value (e.g. `{"path": 123}`) reached
`Path()` downstream and raised an uncaught TypeError instead of the coded
ParseError every other malformed shape raises, and an empty or
whitespace-only `path` resolved to the config-holding directory itself
(loading the whole project tree, including node_modules-equivalent content)
rather than failing on the typo'd config. `sources: null` already raised
correctly here (`.get("sources", [])`'s default only applies when the key
is ABSENT), so this port only needed the value-level check.
Ports the `_pending/`-at-any-depth exclusion into both the loader's
DirectorySource and the resolver's own walk, matching TypeScript.
`metaobjects docs` declared `metadata_dir` as a REQUIRED positional, so the
neutral `.metaobjects/config.json` `sources` rung this feature adds (and the
port's own `metaobjects.config.yaml` rung) was unreachable from it even
though `gen` and `verify --codegen` could already reach both. Made it
optional and routed it through the same ladder `gen` uses.
The conformance runner now asserts the corpus is non-empty, mirroring the
TS runner's guard — `@pytest.mark.parametrize` over an empty list reports a
SKIP, not a failure, so a corpus that silently lost its cases would
previously report green with nothing checked.
…ix could over-fit to The corpus had exactly one malformed-config case (a bare object instead of an array for `sources`) — every fix that followed addressed that ONE shape rather than the general "a present sources key that is not a valid array of single-key string-valued specs raises" rule, which is how a JSON-null `sources` and a non-string entry value both shipped as separate, independently-discovered defects. Adds five cases: `sources: null`, an empty `path`, a `sources` entry with two keys, an unsupported `schema_version`, and a non-string entry value. The two decoy-bearing cases (`sources-null-is-an-error-not-the-default`, `a-non-string-source-value-is-an-error`) follow `sources-must-be-an-array-not-an-object`'s pattern: a stale file sits where a wrongly-permissive port would silently resolve to it instead of raising, so the case can tell "raised correctly" apart from "raised because the decoy directory doesn't exist either." Verified empirically against each port's pre-fix code that every case that pins a NEW behavior actually fails without the corresponding fix, and passes with it. A whitespace-only `path` is deliberately NOT in this shared corpus: the TypeScript reference (`z.string().min(1)`) rejects only a fully-empty path, not a whitespace-only one, and the reference is out of scope to change here — Python/C#/Java pin that stricter behavior in their own port-specific tests instead, where it can't be mistaken for a cross-port promise.
…arseable existing config writeConfigFile()'s catch-and-overwrite-with-defaults branch is safe on the full-scaffold path only because the caller already required --force to reach it (the exists-guard earlier in init() throws otherwise). --config-only calls writeConfigFile() directly, bypassing that guard entirely, so a JVM adopter's config carrying a key this CLI's strict schema doesn't recognize (written by a newer `meta`, or a typo) would run the documented-as-safe `--config-only` and silently lose their declared sources. Adds the same --force requirement inside the catch itself, which is a no-op on the full-scaffold path (force is already guaranteed there) and closes the gap on --config-only.
…rectory-source limit CONFORMANCE.md never gained a row for the corpus this feature shipped with — still said "20 shared conformance corpora" and had no source-resolution-conformance entry in the totals table or a fixture-to-doc mapping section. Added both, following the existing scope-conformance section's shape (its closest sibling). docs/features/metadata-sources.md and the CHANGELOG's "one declaration serves every port" line both overstated C#: `dotnet meta`'s loader accepts only a single directory source, so a multi-entry `sources` or a single-FILE entry doesn't fully "serve" it the way the other three ports are served. Documented the limit and its workaround (an explicit <metadataDir> argument) in both places.
- Java and Python SourceResolver both re-walked directories with their own copy of the extension filter + `_pending/` exclusion + sort, duplicating logic the loader's own DirectorySource already implements — the same duplication class this feature's design explicitly warns against. Both now delegate to DirectorySource, matching the C# port (which already did). - Python cli.py: extract `_resolve_metadata_location_or_print_error`, collapsing three copies of the same try/except ParseError → print → return 1 block (docs, gen's neutral fallback, verify --codegen's neutral fallback). - TS init.ts: `writeConfigFile` had two copies of the fresh-config write; factored into a `writeFresh` closure and restructured the surrounding try/catch with early returns instead of nested nested nested if branches. No behavior change: all four language suites and the shared source-resolution-conformance corpus still pass.
…atus (#313) migrate-ts's suite exited 99 on 805 pass / 0 fail, turning two `ci-local.sh --only ts` gates red with no failing test to point at. Root cause is not teardown and not unhandled errors, which were the two standing theories. PGlite is Postgres compiled to WASM, and Emscripten propagates the WASM program's internal exit status into process.exitCode: it becomes 99 on the FIRST QUERY and stays there. Measured directly -- start undefined, after one `select 1` it is 99, close does not clear it. The shipped CLI was never affected because bin/meta.ts ends with process.exit(code), which overrides it. Anything that does not force its own exit inherits it, which is both `bun test` and any embedder calling openReplayEngine programmatically -- so this is a library defect, not a test-harness quirk. openPglite now captures the caller's exit code and restores it on dispose. Two details are load-bearing and each cost a failed fix: - `?? 0`, because assigning `undefined` to process.exitCode is a NO-OP under Bun (set 99, assign undefined, it stays 99; assign 0 and it clears). The pristine value IS undefined, so restoring it literally ran and changed nothing. - the `finally`, because disposable() calls db.destroy() first, which drives the pool's end(), which already closed PGlite -- so the second close throws `PGlite is closed` and a trailing statement never runs. The regression test spawns a CHILD PROCESS and asserts its exit code. Two in-process shapes were written first and both proved nothing: comparing before/after passes vacuously once any earlier test has opened an engine (99 === 99), and pinning a clean baseline first captures 0 rather than the pristine undefined, so it never exercises the `?? 0` -- it passed against the broken implementation.
…ing exclusion behind an option Java and Python's DirectorySource silently returned zero files for a `sources` path that is itself a symlink to a directory, or skipped a symlinked subdirectory partway through a walked tree — TypeScript and C# already followed symlinks this way. Java's `Files.walk` now passes FOLLOW_LINKS; a symlink cycle raises rather than hanging. Python's `rglob`-based walk never recursed through a symlinked subdirectory at all (`**` does not by design), so it is replaced with a manual recursive walk that follows symlinks and detects cycles itself (SymlinkLoopError), preserving each symlinked directory's own name in the reported path — never collapsing it to the target's real path, matching every other port. Getting this required also fixing a related, more subtle divergence: Python's resolve_sources deduplicated files via Path.resolve(), which (unlike Java's normalize()/C#'s GetFullPath()/TS's path.resolve()) follows symlinks, so it would have silently reported a resolved-through-a-symlink file under its target's real name instead of the declared name — replaced with a lexical-only absolute-path normalizer. Separately, `_pending/` exclusion (the TypeScript pending/promote workflow's concept) was baked unconditionally into Java's, C#'s and Python's loader-level DirectorySource — an API change for any runtime embedder calling `new DirectorySource(dir)` directly, since TypeScript's own loader-level DirectorySource has no such concept at all. Each port's DirectorySource now takes an `excludePending`/`exclude_pending`/`ExcludePending` option defaulting OFF; the CLI-facing SourceResolver (Java, Python) and DirectorySource.cs + SourceResolver.cs (C#) turn it ON explicitly, the one place each CLI opts in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rden the shared runners Two new cases (a `sources` path that is itself a symlink to a directory; a symlinked subdirectory partway through a walked tree) via a new `symlinks` key in the shared corpus schema — a map of linkPath -> targetPath, project-root-relative, materialized after `tree`. Every runner now honors it. Verified each case fails against pre-fix behavior before committing (git diff'd the production fix out, confirmed red, restored it). Also, four defensive gaps in the runners themselves, all found while extending this corpus: - `overlapping-sources-yield-each-file-once` could not fail in the Java/C#/ Python runners because all three compare the resolved set only, silently collapsing a duplicate emission — each now also asserts the RAW resolved count against the expected count. - Java's JUnit4 `Parameterized` runner reports green on a zero-case corpus (a bad path or a JSON bug would run zero tests, not fail) — TS and Python already guard this; a new standalone `SourceResolutionCorpusNotEmptyTest` closes the same gap for Java, which the parameterized class's own `@Test` methods cannot (they run once per row, so zero rows means zero executions of the guard too). - The TS runner defaulted a case missing BOTH `expectFiles` and `expectError` to expecting zero files, rather than failing loudly on the malformed case. - Java's case loader read a literally-absent "config" key the same as an explicit `"config": null` — Python's dict indexing and C#'s JsonElement.GetProperty both throw on the former; Java's Gson `get()` did not, so a future case that forgot the key would silently read as "no config" while the other three runners crash on the same file. The README's "none of those are in the corpus yet" note (bad JSON / unsupported schema_version / a malformed sources entry shape) is corrected: five of the `expectError: true` cases now cover schema_version and entry-shape; genuine malformed JSON syntax still is not, and can't be until the case schema grows a raw-text config variant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `--config-only` branch returned above the `--print-only` guard the full-scaffold path checks further down, so `meta init --config-only --print-only` — a documented dry run — silently wrote the real .metaobjects/config.json. `--print-only` now wins outright for that branch too. `--docs-only --print-only` has the same shape (writeAgentContext has no print-only awareness either) but is not fixed here: its write set is derived dynamically from assemble()/planScaffold() rather than a fixed list, so skipping the writes while still reporting what WOULD be written is more than a trivial change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…CTION_NOT_FOUND ResolveMetadataDirOrExit ran before the `outDir is null` usage check in both RunGen and RunDocs, so `dotnet meta gen`/`docs` with neither a metadataDir nor metadata to resolve printed the ladder's own "error: ERR_COLLECTION_NOT_FOUND" instead of the actionable "usage: ..." line — confusing on the common first-run case where both are missing at once. The outDir check, being an unconditional CLI-usage requirement, now runs first in both commands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on rejects a non-integral float
resolveNeutralSourcesIfPomIsSilent handed MetaDataLoader.processSources a bare
Path::toString() for each resolved source. processSources decides how to wrap
a bare source string by sniffing `s.indexOf(':') < 0` — an absolute path is
not guaranteed colon-free (a Windows drive letter is the common case; a colon
is also legal in a plain Unix directory name, which is how the regression test
reproduces this without a Windows machine), so a path containing one skipped
the wrapping entirely and was handed raw to URIHelper.toURI(), which throws.
Since these are always fully-resolved absolute filesystem paths, there is
nothing to sniff: each is now prefixed "model:file:" explicitly, the same
shape processSources' own fallback branch already builds.
Separately, NeutralConfig's `schema_version` check used Gson's
JsonPrimitive#getAsInt(), which truncates a non-integral BigDecimal instead of
raising — so `schema_version: 1.5` silently read as 1 and passed. Compared as
a double instead (matching C#'s NeutralConfig.cs, which already does this and
already has the regression test this change mirrors for Java); `1.0` is still
accepted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…icate No command handler ever called resolve_metadata_location with a non-None `explicit` — rung 1 (the positional metadata_dir) is served by the pre-existing, independently-correct `_load_root(args.metadata_dir)` in every command handler (a plain MetaDataLoader.from_directory call, with no resolve-then-rejoin path of its own to regress). The dead branch duplicated rung 1 via resolve_sources instead, reachable from nothing, so it could silently drift from the real path with no test noticing — e.g. it would have picked up source_resolver.py's CLI-facing `_pending` exclusion while `_load_root`'s loader-level walk does not. Removed rather than wired: replumbing every metadataDir-taking command onto resolve_sources's from_uris-based load instead of from_directory is a materially larger, riskier change than this ladder needs. Its now-pointless regression test (pinning a relative-path defect that cannot occur in the code path actually reachable from the CLI) is removed with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…avior change Documents the symlink-following fix (Java/Python) and, per the un-flagged gap the review caught, the Java/Maven behavior change: a <loader> naming neither <sourceDir> nor <sources>, with nothing else to resolve, now fails the build (ERR_COLLECTION_NOT_FOUND) instead of silently loading an empty model and passing — deliberate and tested, but previously undisclosed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent bodies of work, sectioned below. They share a branch because #313
landed first and the source-resolution work was built on top; they touch different
packages and can be reviewed separately.
1. Cross-port metadata
sources(28 commits)All four CLI surfaces now resolve metadata from one port-neutral file. Previously
only the Node
metaCLI readsourcesfrom.metaobjects/config.json; Java/Kotlin,C# and Python each took their metadata location their own way.
Design:
docs/superpowers/specs/2026-08-19-cross-port-metadata-sources-design.mdPrecedence is a ladder, first match wins: an explicit CLI argument → the port's
native surface (a pom's
<sourceDir>/<sources>, Python'smetadatakey) →sourcesin.metaobjects/config.json→ the built-in defaultmetaobjects/directory. A config that exists but is malformed raises at its own rung and never
falls through.
The non-TypeScript ports read a NEUTRAL SUBSET —
schema_versionandsources—and ignore unknown top-level keys, because that file also carries TypeScript-owned
keys (
pending_in_git,confidence_thresholds,extract,migrate) behind a.strict()schema. Without this, every future TS-only key becomes a four-port change.scope/migrate.scopedeliberately did NOT ship. Java has shipped a<filters>grammar for years in which*crosses the::separator and@matchesone segment — respectively
scope's**and*, inverted — plus!-prefixexclusion and a
.[attr]predicatescopecannot express, andGeneratorUtil'screateRegexFromGlobcarries aTODOconceding its separator handling is wrong. Bothare output filters, so they compete rather than layer. Reconciling them changes
behaviour for existing Java consumers and is its own decision.
New shared corpus:
fixtures/source-resolution-conformance/(27 cases), run byall four ports. It gates the resolved file SET, the relative-path base, the extension
set, and each error condition.
Two things are deliberately not cross-port contracts, and both are documented:
resolved file ORDER (it already differs per port, and the loader discards caller
order), and the specific error CODE for a malformed config (TypeScript propagates
a raw parser error, so pinning one would mean changing the reference implementation —
the corpus expresses this with
expectError: true).Also:
meta init --config-onlywrites.metaobjects/config.jsonand nothingelse, so a Maven- or pip-rooted project can declare its sources for the Node CLI —
which owns
migrateandverify --dbunder ADR-0015 — without acquiring aTypeScript scaffold it will never use.
Behaviour changes worth calling out for review
<loader>namingneither
<sourceDir>nor<sources>used to produce an empty model and pass, andnow raises
ERR_COLLECTION_NOT_FOUND. Disclosed in the CHANGELOG.uses
stat, notlstat, deliberately) and C#. Java previously resolved asymlinked source root to zero files, silently, exit 0.
2. Migration-chain replayability, #313 (16 commits)
meta verify --replayand--replay-snapshot: the committed migration chain mustapply to an empty database, and — in the second tier — must produce the schema the
committed snapshot records. The replay engine provisions nothing: PGlite in-process
for postgres, a throwaway temp file for sqlite.
Also here: a forward drop tolerates an absent object so a chain can replay; a chain
creates the schema it needs so it applies to a virgin database; and the CLI refuses to
drop an object the committed snapshot never managed.
Verification
expectFilesentry turns all four runners red, each failing on exactly thecorrupted case; reverting turns them green again.
scripts/ci-local.sh --only tsgreen (all 7 gates);--only python,--only csharp,--only javaeach exit 0.cli615,sdk275, C# 933 + 52, Java 1471.Reviewed by a full-branch code review and a code-simplifier pass, both of which
found real defects that are fixed here — including two silent cross-port divergences
the corpus was structurally unable to see (a wrong-typed
sourcesvalue degrading tostale metadata in two ports, and an empty
pathresolving to the entire projecttree in three). Each fix ships with the corpus case that would have caught it.
🤖 Generated with Claude Code