Skip to content

feat: cross-port metadata sources (four CLIs, one config) + migration-chain replayability (#313) - #315

Merged
dmealing merged 44 commits into
mainfrom
feat/migrate-chain-replayability
Aug 19, 2026
Merged

feat: cross-port metadata sources (four CLIs, one config) + migration-chain replayability (#313)#315
dmealing merged 44 commits into
mainfrom
feat/migrate-chain-replayability

Conversation

@dmealing

Copy link
Copy Markdown
Member

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 meta CLI read sources from .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.md

Precedence is a ladder, first match wins: an explicit CLI argument → the port's
native surface (a pom's <sourceDir>/<sources>, Python's metadata key) →
sources in .metaobjects/config.json → the built-in default metaobjects/
directory. A config that exists but is malformed raises at its own rung and never
falls through.

The non-TypeScript ports read a NEUTRAL SUBSETschema_version and sources
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.scope deliberately did NOT ship. Java has shipped a
<filters> grammar for years in which * crosses the :: separator and @ matches
one segment — respectively scope's ** and *, inverted — plus !-prefix
exclusion and a .[attr] predicate scope cannot express, and GeneratorUtil's
createRegexFromGlob carries a TODO conceding its separator handling is wrong. Both
are 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 by
all 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-only writes .metaobjects/config.json and nothing
else, so a Maven- or pip-rooted project can declare its sources for the Node CLI —
which owns migrate and verify --db under ADR-0015 — without acquiring a
TypeScript scaffold it will never use.

Behaviour changes worth calling out for review

  • The Java mojo now fails a build that previously succeeded: a <loader> naming
    neither <sourceDir> nor <sources> used to produce an empty model and pass, and
    now raises ERR_COLLECTION_NOT_FOUND. Disclosed in the CHANGELOG.
  • Java and Python now follow symlinked directories, matching TypeScript (which
    uses stat, not lstat, deliberately) and C#. Java previously resolved a
    symlinked source root to zero files, silently, exit 0.

2. Migration-chain replayability, #313 (16 commits)

meta verify --replay and --replay-snapshot: the committed migration chain must
apply 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

  • Shared corpus 27/27 green on all four runners (TypeScript, Python, C#, Java).
  • The corpus was proved by breaking it, not by its silence: corrupting one
    expectFiles entry turns all four runners red, each failing on exactly the
    corrupted case; reverting turns them green again.
  • scripts/ci-local.sh --only ts green (all 7 gates); --only python, --only csharp, --only java each exit 0.
  • Python 1695, TypeScript cli 615, sdk 275, 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 sources value degrading to
stale metadata in two ports, and an empty path resolving to the entire project
tree in three). Each fix ships with the corpus case that would have caught it.

🤖 Generated with Claude Code

dmealing and others added 30 commits August 19, 2026 19:49
…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).
dmealing and others added 14 commits August 19, 2026 19:49
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant