diff --git a/AGENTS.md b/AGENTS.md index 1e010f5ed..3bb8bc101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (384 fixtures; 29 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (395 fixtures; 29 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8374bd3a7..15737cc7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,15 @@ has a section per port, written for a coding agent to apply to the config by han ADR-0034 Amendment 9, which numbers the release 1.1.0 (Maven 8.1.0) by the maintainer's explicit decision. The entries are under **Removed** and **Changed**. +**Breaking, Java and Kotlin: commit `.metaobjects/.gen-state/.hashes.json`.** `mvn +metaobjects:generate` now records what it writes in the same committed hash manifest the other +ports use, and decides overwrites from it instead of from the `GENERATED` header token alone. +An edited generated file is refused by name (it was overwritten), and a file no generator emits +any more is deleted. The first run adopts your existing generated files and tells you to commit +the manifest; commit it in every module. Migration: +[jvm-codegen-hash-manifest.md](docs/features/migrations/jvm-codegen-hash-manifest.md). The +entry is under **Changed**. + **What 1.1.0 is.** The reporting release (FR-044): declare dimensions, measures, segments and an `object.report`, and get a SQL view, a served read-only list route in every port, a generated TanStack list hook, and a Cube data model, with `@spine` for rows that have no facts and @@ -59,7 +68,20 @@ number. The first landed on 1.0.x: 439 lines of handler code retired. The second and it is a SQL reduction only: in an adopter, hand-written SQL went from 75 lines to 15 (-80%). Total hand-written lines did not fall, so do not read it as a total line reduction. -**Four diffs to expect when you upgrade.** +**Breaking, TypeScript, Python and C#: a bare `extends` no longer binds an object in another +package.** It now fails with `ERR_UNRESOLVED_SUPER`, as Java already did and ADR-0042 requires. A +bare name binds the declaring file's own package, then the root. Qualify the reference: +`extends: other::Base`. The entry is under **Changed** (ADR-0059). + +**Five diffs to expect when you upgrade.** + +- **References are written fully qualified (ADR-0059).** `meta export` and canonical + serialization write every reference as `pkg::Name`, a root-level target referenced from a + package as `::Name`, and a model that spans packages with a `package` on each top-level object. + Exported JSON now loads back to the same model. The neutral template data's `targetRef` carries + the same canonical value (`rel product -> shop::Product`), so a Mustache template that prints it + changes. Documentation pages and error messages still quote a reference as you wrote it. `meta + fmt` still writes references as you wrote them. Regenerate, then review the diff. - **An ejected generator needs a resync (G11).** An owned copy keeps the gate and the options it was copied with, so it does not receive the new report behaviour until you resync it. The @@ -111,6 +133,18 @@ generator catalog and the Atlas evaluation are 1.2 work. ### Added +- **`handEdits: keep | overwrite` on every generator entry, in every port.** `keep`, the + default, is the behaviour so far: an edited generated file is refused, and so is an edited + file no generator emits any more. `overwrite` replaces that generator's edited files with + fresh output and re-baselines their manifest records, and the orphan sweep deletes an + edited file no generator emits any more when every generator writing into its directory + overwrites. Each discarded file is named on its own line, and a file the manifest has no + record of is still refused. `verify --codegen` follows the setting: under `overwrite` an edit + is drift that `gen` fixes, and the remedy says so. Set it with `{ ...entityFile(), handEdits: + "overwrite" }` (TypeScript), a `{ name: entity, handEdits: overwrite }` entry in + `metaobjects.config.yaml` (Python), `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)` + (C#) or `overwrite` on a `` (Java, Kotlin). + `--baseline=fresh` stays as the one-run form; `docs/features/cli.md` sets the two side by side. - **Python: `templates` in `metaobjects.config.yaml`.** The template directory was a flag only; `gen`, `verify --codegen` and `verify --templates` read the key, and a `--templates` that contradicts it is refused. @@ -840,6 +874,61 @@ generator catalog and the Atlas evaluation are 1.2 work. ### Changed +**References mean the same thing in YAML and JSON, in every port (ADR-0059).** Every source now +keeps references as written, and one resolver per port binds them after the whole model has +loaded, then rewrites each to its canonical spelling before the tree is frozen. So: + +- `::Name` and `..::Name` work in canonical JSON as well as YAML. `ERR_RELATIVE_REF_IN_CANONICAL` + is retired, and the conformance fixture that asserted it is removed. +- A bare `extends` resolves in its own file's package, then at the root. It no longer binds an + object in another package (TypeScript, Python, C#; Java already refused it): qualify it. +- A loaded model's reference values are canonical (`pkg::Name`, or `::Name` for a root-level + target from a package). Code that reads a reference attribute and compares it to an object's + short name must compare the target instead: `refTarget(node, attr)` (TypeScript) returns the + object the loader bound, and `lookupCanonical(root, value)` resolves a canonical value exactly + (a bare canonical `Name` is the root-level object). `MetaRoot.findObject` binds a qualified + value exactly, but a bare name still matches the first object of that short name in any + package, so do not pass it a reference value. +- TypeScript `RenderContext.resolveValueObjectName` takes the field (`resolveValueObjectName(field)`) + and reads the value object its `@objectRef` is bound to; `fieldDeclaringPackage` is removed. An + ejected `entity`, `drizzle-schema`, `zod-validators`, `inferred-types` or `projection-decl` + template needs a resync. +- The string payload resolvers kept for owned generator copies resolve a canonical value + exactly: Java `SpringNaming.resolveObjectRef` / `resolveValueObjectRef` and the generators' + `resolveValueObject`, Kotlin `KotlinGenUtil.resolveObjectRef` / `resolveValueObjectRef`, C# + `ValueObjectNames.ResolveValueObject` and `PayloadFieldTree.Build(root, name)`, and Python + `resolve_payload_vo`. `pkg::Name` is exact, and `::Name` or a bare `Name` is the root-level + object. The referrer-package argument is ignored. A bare name no longer binds an object in + the referrer's package, and C# no longer falls back to a short-name scan, so pass a qualified + value or read the bound target (`payloadTarget`, `PayloadShapeOf`, `bound_payload_vo`). +- Two conformance gates run in every port: each fixture loads to the same model from JSON and + from YAML, and each `expected.json` loads and serializes back to itself. +- TypeScript `referenceCandidatesFor` / `resolveRelationshipReference` and Python + `reference_candidates_for` / `resolve_relationship_reference` take the target object, not a + name: pass `refTarget(rel, "objectRef")` (`ref_target` in Python). The name form matched by + short name. + +**Java and Kotlin: codegen decides overwrites from the committed hash manifest (ADR-0060).** +**Commit `.metaobjects/.gen-state/.hashes.json` in every module that runs the plugin.** +`GeneratedFileWriter` records each file it writes (module-relative path to SHA-256), the same +file as TypeScript, Python and C#. A file that still matches its record is +overwritten; an edited one is refused by name with a warning, where 8.0.x overwrote it; a file +with no record is adopted when it carries the `GENERATED` token and refused when it does not. +The first run on an 8.0.x project adopts every generated file, deletes nothing and prints one +notice. After a full run, a recorded file no generator emitted is deleted when untouched and +refused when edited, and `mvn metaobjects:verify` reports it with the matching remedy. Two +executions in one module share the manifest without touching each other's files: each entry +names its owner (the generator's registry id and its output directory, not the Maven +execution), a run deletes only its own generators' orphans and those of generators the module +no longer configures, even in a shared output directory, and one file two generators +emit with different content fails the goal; two modules have two. **New build-time check:** +two executions that configure the same generator into the same `` (in the main +build or any profile) now fail `generate` and `verify` before anything is written, naming +both executions; give each its own `` or merge them into one execution. User templates, api-docs pages and the `META-INF/services` registration are not +recorded, so they are never deleted. A refusal stays a warning on the JVM. To keep the 8.0.x +outcome for output nobody edits, set `overwrite` on that ``. +Migration: [jvm-codegen-hash-manifest.md](docs/features/migrations/jvm-codegen-hash-manifest.md). + **One physical-naming setting, read by every port (ADR-0058).** **This changes generated column names in C#, Java, Kotlin's runtime and Python unless you add one line.** Column and table names that the metadata does not give (`@column`, `@table`, `@view`, a source's `name`) now come from one project setting, `naming` in @@ -1270,6 +1359,99 @@ until you regenerate. ### Fixed +- **References resolve the same way from every source (ADR-0059).** + - YAML `::Name` reaches the root-level object even when the package declares the same name. It + used to bind the local object silently. + - A bare `@of` after `@via` resolves in the package the `@via` reaches, from YAML too. + - In JSON, an inherited bare reference resolves in the package that declared it. It used to + fail with `ERR_UNRESOLVED_OBJECT_REF`. + - A file without `package` declares root-level objects whatever order the files load in. + - Canonical serialization of a model that spans packages loads back to the same model. + - C#: `extends` resolves in its own file's package, not the first file's. + - Java: `@through` follows the same reference rules as the other ports. +- **YAML metadata loads to the same model as its JSON twin in three more cases.** The new YAML + twin check found three defects: + - C#: a YAML overlay was merged into its base as soon as the overlay's file was parsed, + because the YAML parser dropped the loader's "defer overlays" setting. Fields could land in a + different order from the JSON load, and an overlay parsed before its base failed with + `ERR_OVERLAY_NO_TARGET`. Overlays in YAML now merge after every file loads, as in JSON + (ADR-0055). + - Java: a non-string `default:` on a field (`default: false` on a `field.boolean`) failed with + `ERR_YAML_COERCION`. + - Java: a documentation attribute that takes a list (`aliases`, `seeAlso`) with more than one + entry failed with `ERR_YAML_COERCION`. +- **TypeScript: generated code reads the target a reference names, never the reference + string.** A stored procedure whose `@parameterRef` was written fully qualified (`pkg::Args`) + got a wrapper with no `args` parameter, because the generator looked the value object up by + short name. An output-format spec's `rootName` (used as an XML element name) was the + `@responseRef` string; it is now the value object's name, as `extract()` already used. The + Mermaid entity diagram names a reference target by its entity name. +- **C#, Java, Kotlin and Python: codegen and the runtime find a reference's target by the object + the loader bound, not by its short name.** Two objects with the same name in different packages + could bind the wrong one in M:N navigation (Spring repositories, OMDB, Exposed, the Python + runtime and route codegen), in the PlantUML diagram and in extract and render helpers. Java: a + field that an overlay adds from another file resolves a bare `extends` in that file's own + package, as the other ports do. +- **A `@via` or `@spine` hop inherited from another package reaches the object its reference + names, in every port (ADR-0059).** The loader and the report shape resolved an inherited + hop's `@objectRef` or `@references` in the package of the entity being walked. A hop that + `base::Order` declares onto a bare `Customer` bound `app::Customer` when walked from + `app::BigOrder`, so the dimension was refused or its `@of` read the wrong entity. It now + resolves in the package of the node that declares the hop. TypeScript's view lowering and + Cube export walk hops the same way. +- **Codegen and the runtime never bind a reference by short name, and never resolve an + inherited reference again in the inheritor's package (ADR-0059 Decision 7).** A root-level + declarer writes a root target bare. Inherited into package `acme` while `acme::Name` exists, + a consumer that resolved it again from `acme` bound `acme::Name`, and a consumer that matched + by short name bound whichever same-named object loaded first. Every consumer now reads the + target the loader bound: + - TypeScript: the M:N traversal route, the Drizzle `relations()` block and FK + `.references()`, reverse finders, the TanStack M:N hook, the M:N derivation, `meta + migrate`'s referential actions, the docs-site link graph, and the runtime's extract and + validators. A relation-map entry carries the bound `target` (and `junction`). + - Python: to-one relationship checks and `@via` inference compare bound targets, and the + M:N derivation and router codegen read them. + - C#: the M:N derivation and navigation, reverse finders, referential actions, the + validation passes and the payload lookups of the template generators. + - Java and Kotlin: the M:N derivation, Spring M:N support and repositories, OMDB's M:N + resolver, PlantUML, the template generators and the validation passes. Kotlin's generated + code also imports a cross-package or root-level M:N table and FK target; it used to + resolve to a same-named type in its own package. + - TypeScript, also: `meta migrate`'s flattened `@storage` columns and FK target table and + column, the template payload, response and trace-helper types, the render-helper and + `meta verify` payload field trees, the template docs page's payload and field links, the + API reference's payload shapes, a report's `@from` in the report docs and view lowering, a + collect-whole-object origin's value object and `@via` terminal in the view lowering, the + extractor and output-parser value objects, and the CLI deprecation lint's `@objectRef`, + `@references` and `@via` hops. + A new shared input, `fixtures/codegen-conformance/cross-package-binding/`, carries a decision + table every port's codegen test answers. +- **A to-one relationship whose `@objectRef` does not resolve reports only the unresolved + reference, in every port.** Python, Java and C# also reported `ERR_INVALID_RELATIONSHIP` when + the relationship declared `@sourceRefField`, and C# matched the candidate references by short + name. Gated by `fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/`. + C# `ReferenceCandidatesFor` / `ResolveRelationshipReference` lose their name overloads; pass + the target object. Java `referenceCandidatesFor` / `resolveRelationshipReference` and Kotlin + `KotlinGenUtil.resolveObjectByShortOrFqn` keep their string forms, deprecated, so a generator + ejected before 1.1.0 still compiles. They now look the value up as an exact canonical + reference: a short name no longer matches an object in a package. Pass the target object, or + call `KotlinGenUtil.lookupCanonicalObject`. +- **Python: the YAML twin gate fails on a JSON load that crashes.** It used to skip the fixture + as if its providers did not compose. +- **`gen` deletes a file it wrote earlier and no longer generates, in every port.** Renaming a + value object (`Telemetry` to `TelemetryReading`), deleting an entity, or a release moving + api-docs pages to another package left the old files on disk. `verify --codegen` then + reported them as extra and said to regenerate, and regenerating removed nothing, so an agent + following the remedy looped. Now a full `gen` run deletes a file the committed manifest + records writing, that no generator emitted this run and that still hashes to what was + recorded, and drops its record. An edited such file is refused by name and stays recorded; + `verify --codegen` reports it as edited by hand and says to delete it rather than to + regenerate. A file the manifest does not record is never touched and is not drift. A run + narrowed by `--generators`, an entity filter or a partial `--target`, and + `--baseline=adopt`, delete nothing. The rule, written once for every port, is in + `docs/features/own-your-codegen.md` ("Files that are no longer generated"). TypeScript's + `orphanPolicy: { keep: true }` opts a generator's namespace out; the `requirement-tests` + generator's `reconcileOrphans: false` now does exactly that. - **SQLite and D1: a backtick-quoted CHECK constraint name is read.** Introspection read `CONSTRAINT "name"` and a bare name but not `` CONSTRAINT `name` `` (the quoting Drizzle-written migrations use) or `[name]`, so the stored check had an empty name, and `meta verify --db` and diff --git a/README.md b/README.md index b918024df..86892af00 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ metaobjects/ ├── CLAUDE.md # project instructions for Claude ├── spec/ # canonical metamodel docs, ADRs, roadmap ├── fixtures/ # 25 cross-language conformance corpora — the oracle -│ ├── conformance/ # metamodel (loader + serializer + navigation), 384 fixtures +│ ├── conformance/ # metamodel (loader + serializer + navigation), 395 fixtures │ ├── yaml-conformance/ # YAML authoring desugar │ ├── render-conformance/ # FR-004 byte-identical render oracle │ ├── verify-conformance/ # FR-004 template-drift gate diff --git a/agent-context/skills/metaobjects-audit/references/surface-review-axes.md b/agent-context/skills/metaobjects-audit/references/surface-review-axes.md index 1012d6fc5..98cb47063 100644 --- a/agent-context/skills/metaobjects-audit/references/surface-review-axes.md +++ b/agent-context/skills/metaobjects-audit/references/surface-review-axes.md @@ -141,8 +141,7 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in taxonomy impurity (entity over read-only primary source; read model that should be `object.projection`; `value` carrying identity/source, ADR-0028); copy-pasted base-field blocks — or the same-named field re-declared across objects (drift signature 10) — instead of - abstract + `extends`; `@`-prefixed YAML keys (ADR-0006); relative refs - in committed canonical JSON (ADR-0032); DB-type-as-logical-subtype (ADR-0013); per-port + abstract + `extends`; `@`-prefixed YAML keys (ADR-0006); DB-type-as-logical-subtype (ADR-0013); per-port migration engine where schema is Node-`meta`-owned (ADR-0015). - [ ] **H2. Wrong native type — `field.` + `@dbColumnType` that hides the real type diff --git a/agent-context/skills/metaobjects-authoring/references/abstracts-and-overlay.md b/agent-context/skills/metaobjects-authoring/references/abstracts-and-overlay.md index 6de9b0113..04d71a042 100644 --- a/agent-context/skills/metaobjects-authoring/references/abstracts-and-overlay.md +++ b/agent-context/skills/metaobjects-authoring/references/abstracts-and-overlay.md @@ -35,7 +35,11 @@ Resolution facts: extends Auditable`) — each `extends` is a super-*reference*, so resolution walks the full chain (it does not flatten inherited members onto the child; see ADR-0039). - **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`); - same-package refs use the bare name. + same-package refs use the bare name. A bare name looks in the declaring file's own + package, then at the root, and never binds an object in another package. `::Name` + names a root-level object even when the package has one of the same name; `..::Name` + names one in the parent package. The forms mean the same in YAML and JSON (ADR-0059), + and a loaded or exported model carries every reference fully qualified. - An unresolved reference fails with `ERR_UNRESOLVED_SUPER`. `abstract` and `extends` are **structural keys** (bare, no `@`). diff --git a/agent-context/skills/metaobjects-codegen/SKILL.md b/agent-context/skills/metaobjects-codegen/SKILL.md index 2cae76911..f67f1978a 100644 --- a/agent-context/skills/metaobjects-codegen/SKILL.md +++ b/agent-context/skills/metaobjects-codegen/SKILL.md @@ -160,21 +160,19 @@ You run a `gen` step. The runner: 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). -4. Decides whether it may overwrite a file — and **the rule differs by port**: - - **TypeScript, C#, Python** — by a committed **hash manifest** - (`.metaobjects/.gen-state/.hashes.json`). If a file still hashes to what the - generator recorded writing, it is safe to overwrite; if it was edited, or there is - no record of it, the write is **refused by name**. TypeScript additionally - three-way-merges against a snapshot when one is present locally. - - **Java, Kotlin** — by a bare **`GENERATED`** token in the file's header comment - (`GeneratedFileWriter.GENERATED_MARKER`). The token is `GENERATED`, **not** - `@generated`: the matcher allows only whitespace between the comment punctuation - and the token, so an `@`-prefixed tag does not match it. Remove that token and - regeneration never touches the file again. - - **A refusal FAILS the run — exit 1 on every port** (TypeScript always did; Python and - C# warned and exited 0 until 1.0, so a `gen` wired into CI was green while codegen - refused to write). The one-time fix for a project that predates the committed manifest +4. Decides whether it may overwrite a file, by a committed **hash manifest** + (`.metaobjects/.gen-state/.hashes.json`) on every port. If a file still hashes to what + the generator recorded writing, it is safe to overwrite; if it was edited, or there is + no record of it, the write is **refused by name**. TypeScript additionally + three-way-merges against a snapshot when one is present locally. **Commit the + manifest.** Java and Kotlin have used it since 8.1.0: there, a file with no record that + carries the bare **`GENERATED`** header token (not `@generated`) is adopted as generated + output, which is how the first run after upgrading from 8.0.x records everything. + + **A refusal FAILS the run — exit 1 on TypeScript, C# and Python** (Python and C# warned + and exited 0 until 1.0, so a `gen` wired into CI was green while codegen refused to + write). On Java and Kotlin a refusal is a warning and `mvn metaobjects:verify` is the + gate that fails. The one-time fix for a project that predates the committed manifest is **`gen --baseline=adopt`**: it records the files you have as the baseline and writes nothing, so there is finally a `.hashes.json` to commit — then `gen` again, and the regeneration lands as its own diff. Adopting DECLARES those files to be generated @@ -182,16 +180,28 @@ You run a `gen` step. The runner: replaces it; commit before you run it. (TypeScript additionally has `--baseline=fresh`: write fresh output now and discard the edits.) - **The two rules protect a hand edit in opposite ways, so do not carry a habit across - ports.** On TypeScript, C# and Python, *editing the content* is what takes ownership — - that is what breaks the hash — and deleting the header changes nothing except your - ability to tell what generated the file. On Java and Kotlin the reverse holds: editing - a file protects it not at all, because the `GENERATED` token is still there and the - next run overwrites the edit; only removing that token does. - - (The header text differs per port and none of it is the write decision on the - hash-manifest ports: TypeScript and Python emit `@generated by …`, C# emits - ``, Java and Kotlin emit the `GENERATED` token above.) + **Output nobody should edit: set `handEdits: overwrite` on its generator entry.** The + default, `keep`, refuses an edited file. `overwrite` replaces it with fresh output, names + it, and re-baselines it; `verify --codegen` then reports the edit as drift that `gen` + fixes. A file the manifest has no record of is refused either way. TypeScript + `{ ...entityFile(), handEdits: "overwrite" }`; Python `{ name: entity, handEdits: overwrite }` + in `metaobjects.config.yaml`; C# `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)`; + Java and Kotlin `overwrite` on the ``. + + **A full `gen` deletes generated files it no longer emits** (a renamed or deleted object, + a page that moved package). Only files the manifest records, only when untouched, and + never on a run narrowed by `--generators` or an entity filter. An edited one is refused by + name, and `verify --codegen` says to delete it: regeneration cannot follow a rename, so + move the edits into the new file first if you want them. Details: + `docs/features/own-your-codegen.md`, "Files that are no longer generated". + + On every port *editing the content* is what takes ownership — that is what breaks the + hash — and deleting the header is just another edit. (Through Maven 8.0.x, Java and + Kotlin did the reverse: an edit was overwritten and only removing the `GENERATED` token + protected a file. Do not carry that habit forward.) + + (The header text differs per port and is not the write decision: TypeScript and Python + emit `@generated by …`, C# emits ``, Java and Kotlin emit `GENERATED`.) The output is normal idiomatic code in your language — you import it and use it like any hand-written module. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index ffd0ad379..feffbed3b 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 384 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 395 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -38,7 +38,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ | -| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 5 shared inputs; `shared-provided-enum` also carries the enum-type decision table every port answers | ✓ | ✓ | ✓ | ✓ | ✓ | +| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 6 shared inputs; `shared-provided-enum` carries the enum-type decision table and `cross-package-binding` the reference-binding decision table (ADR-0059) every port answers | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/template-codegen-conformance/`](../fixtures/template-codegen-conformance/) | 3 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/template-output-render-conformance/`](../fixtures/template-output-render-conformance/) | 5 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/generator-registry-conformance/`](../fixtures/generator-registry-conformance/) | 1 canonical manifest | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -267,12 +267,13 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (384) +### `fixtures/conformance/` — metamodel loader + canonical serializer (395) | Fixture prefix | Feature doc | |---|---| | `loader-basic-*`, `error-parse-*` | [features/loaders.md](features/loaders.md) | | `extends-*`, `error-extends-*` | [features/entities.md](features/entities.md) (inheritance) | +| `ref-*`, `error-ref-*`, `package-default-per-file`, `serialize-multi-package-document` | [features/abstracts-and-inheritance.md](features/abstracts-and-inheritance.md) (how a reference resolves: bare, `::`, `..::`, per-file package; ADR-0059) | | `identity-*`, `subtype-entity-*`, `subtype-value-*` | [features/entities.md](features/entities.md) (identity / entity vs value) | | `attr-*`, `error-attr-*`, `error-reserved-word-as-attr` | [features/entities.md](features/entities.md) (attributes) | | `doc-common-attrs-*` | [features/entities.md](features/entities.md) (documentation common attrs) | @@ -541,7 +542,7 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 384 + yaml 16 + verify 37 +The fixtures in the nine corpora mapped above (metamodel 395 + yaml 16 + verify 37 + render 16 + persistence 54 + api-contract 91 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc, and so do the two requirement corpora and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining diff --git a/docs/features/abstracts-and-inheritance.md b/docs/features/abstracts-and-inheritance.md index 4d4177cbf..38c5d8dff 100644 --- a/docs/features/abstracts-and-inheritance.md +++ b/docs/features/abstracts-and-inheritance.md @@ -150,7 +150,12 @@ This means: attrs. - **Cross-package references** use fully-qualified names: `extends: "shared::auditable"`. Same-package references can use the bare - name. + name: a bare name binds the declaring file's own package, then the root, and + never an object in another package. `::Name` reaches a root-level object even + when the package declares the same name, and `..::Name` names a sibling in the + parent package. These forms mean the same in YAML and canonical JSON + ([ADR-0059](../../spec/decisions/ADR-0059-references-resolve-once-after-the-tree.md)). + Exported JSON writes every reference fully qualified. If `extends:` references a name that doesn't resolve, the loader emits `ERR_UNRESOLVED_SUPER` with the unresolved reference and the source location. diff --git a/docs/features/cli.md b/docs/features/cli.md index b59d9d86f..2ee3965e0 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -234,6 +234,24 @@ one-time fix. | `--baseline=adopt` | TS · Python · C# | record the files you HAVE as the merge base and **write nothing** | | `--baseline=fresh` | TS only | overwrite the refused files from fresh output and re-baseline (**discards** hand edits). A local snapshot body for a path the manifest has no record of is ignored, not merged against, so stale content from an earlier checkout is not kept as a hand edit | +`--baseline` answers for one run. To decide once, per generator, set `handEdits` on its +entry instead: + +| setting | ports | what it does | +|---|---|---| +| `handEdits: keep` | all | the default: an edited generated file is refused, and so is an edited file no generator emits any more | +| `handEdits: overwrite` | all | replace that generator's edited files with fresh output and re-baseline them; the orphan sweep deletes an edited file no generator emits any more when every generator writing into its directory overwrites. Each discarded file is named. A file the manifest has no record of is still refused | + +| | `--baseline=fresh` | `handEdits: overwrite` | +|---|---|---| +| scope | one run, every generator | every run, one generator | +| a file with no manifest record | overwritten | refused | +| an edited orphan | untouched | deleted (when the whole directory overwrites) | +| `verify --codegen` | not affected | reports the edit as drift that `gen` fixes | + +Syntax per port, and the orphan rule both follow: +[own-your-codegen](own-your-codegen.md#handedits-keep-or-discard-edits-to-one-generators-files). + **`adopt` exists because the refusal's own remedy was unreachable.** It told you to commit `.hashes.json` — but nothing writes a manifest until a `gen` succeeds, and a run where every file refuses writes none, so there was nothing to commit and the next run refused diff --git a/docs/features/codegen-concepts.md b/docs/features/codegen-concepts.md index 5a4c318a6..35de5c274 100644 --- a/docs/features/codegen-concepts.md +++ b/docs/features/codegen-concepts.md @@ -103,9 +103,8 @@ generators you author, not as behaviour to expect from `meta gen`. Best when edits are small and scattered through otherwise-generated files. The baseline lives in `.metaobjects/.gen-state/` and its **bodies are gitignored**, so the merge happens on the machine that generated the file and nowhere else; elsewhere - an edited file is refused rather than merged. C# and Python refuse rather than merge; - Java and Kotlin have neither — they check a marker (see - [own-your-codegen.md](own-your-codegen.md)). Say which of those you mean whenever you + an edited file is refused rather than merged. C#, Python, Java and Kotlin refuse rather + than merge (see [own-your-codegen.md](own-your-codegen.md)). Say which of those you mean whenever you say "hand edits survive". 2. **Generated base class + your extension — a pattern, not a feature.** Generate a base and hand-write a subclass that overrides what you need. Nothing ships this: there is @@ -149,11 +148,11 @@ This cleanly separates *owned + edited* (committed, stable, in review) from mechanical half with zero merge noise, and a small hand-owned half that's the only thing in git and the only thing anyone reads. -## 7. Safety: never clobbering hand edits — by two different mechanisms +## 7. Safety: never clobbering hand edits The backstop under every strategy above is that the generator will not silently eat your -work. **How it knows is not the same on every port, and the `@generated` header is only -half the story:** +work. **Every port decides from the committed hash manifest, and the `@generated` header is +not the decision:** - **TypeScript** decides from `.metaobjects/.gen-state/` — the snapshot body if this machine has one (three-way merge), otherwise the committed `.hashes.json` (byte-for-byte @@ -167,13 +166,15 @@ half the story:** gen-state directory is in play, which is every CLI run**. One caller can reach the exception: an *embedder* driving the C# `CodegenRunner` or Python's `decide_and_write` directly and supplying no gen-state directory. With no state to reason from there is no - hash to compare, so both fall back to the JVM's rule — overwrite a file carrying the + hash to compare, so both fall back to a marker rule — overwrite a file carrying the marker, refuse one without it — and in that mode the header IS load-bearing, in both directions. Neither `dotnet meta gen` nor `metaobjects gen` can put you there: both always supply the directory. If you embed either runner, supply one too. -- **Java and Kotlin** decide from the header: `GeneratedFileWriter` refuses any existing - file whose header carries no `GENERATED` marker, and **deleting that line is exactly - how you take ownership**. A refusal there is a warning, not a build failure. +- **Java and Kotlin** decide from the same committed hash manifest since 8.1.0 (ADR-0060): + `GeneratedFileWriter` records what it writes, and an edited file is refused. The + `GENERATED` header token decides only for a file the manifest has no record of: one that + carries it is adopted as generated output, one that does not is refused. A refusal there + is a warning, not a build failure. Both are stated in full, per port, in [own-your-codegen.md](own-your-codegen.md). @@ -281,11 +282,10 @@ Python, `TemplateRoot` in C#; default `prompts/` when it exists, else `templates `perEntity`/`perPackage`/`perModel` and `format` (when present) one of the registered escaper formats; a `target` field is **rejected** (the CLI ports have no output-target concept). The same neutral data dict, output-pattern grammar, and -conformance corpus apply, so a spec renders byte-identically across ports. (Because -a template's output flows through the standard write path — which refuses to -overwrite a file lacking the `@generated` marker — a regenerable template must emit -that header in its own body. The JVM's `TemplateScopeGenerator` writes directly and -carries no such obligation.) +conformance corpus apply, so a spec renders byte-identically across ports. The standard +write path decides from the hash manifest, not the header (section 7), so a template need +not emit the `@generated` marker. The JVM's `TemplateScopeGenerator` writes directly and +keeps no manifest record.) **The spec is declared, never discovered.** Through 1.0 both CLI ports also read a `--template-spec` flag or a project-root `template-spec.json` on their own. Since 1.1.0 a spec diff --git a/docs/features/codegen-data-shapes.md b/docs/features/codegen-data-shapes.md index 6e99c1f0d..609b3d354 100644 --- a/docs/features/codegen-data-shapes.md +++ b/docs/features/codegen-data-shapes.md @@ -93,7 +93,7 @@ interface FieldTemplateData { enumValues?: string[]; // present only for field.enum } interface IdentityTemplateData { kind: string; fields: string[]; } -interface RelationshipTemplateData { name: string; cardinality: string; targetRef: string; } +interface RelationshipTemplateData { name: string; cardinality: string; targetRef: string; } // targetRef: canonical `pkg::Name` (ADR-0059) interface EntityTemplateData { name: string; package: string; // effective package (`::`-separated), "" when none diff --git a/docs/features/migrations/jvm-codegen-hash-manifest.md b/docs/features/migrations/jvm-codegen-hash-manifest.md new file mode 100644 index 000000000..5cf562eff --- /dev/null +++ b/docs/features/migrations/jvm-codegen-hash-manifest.md @@ -0,0 +1,126 @@ +# Migration: Java and Kotlin commit the codegen hash manifest + +**Changed in:** Maven 8.1.0 (npm, PyPI and NuGet 1.1.0) · **Ports affected:** Java and Kotlin +**Automatic fix:** the first `mvn metaobjects:generate` run does it; you commit one file per +module. Decision record: +[ADR-0060](https://github.com/metaobjectsdev/metaobjects/blob/main/spec/decisions/ADR-0060-jvm-codegen-hash-manifest.md). + +## What changed + +Through 8.0.x, `mvn metaobjects:generate` decided whether it could overwrite a file from the +`GENERATED` token in the file's header. A file that carried it was overwritten, even when you +had edited it. A file that was no longer generated (a renamed value object, a deleted entity) +stayed on disk forever, because nothing recorded what a run had written. + +From 8.1.0 the Java and Kotlin generators record every file they write in a committed hash +manifest, the same one TypeScript, Python and C# use: + +``` +/.metaobjects/.gen-state/.hashes.json +``` + +It maps each generated file's module-relative path to the SHA-256 of what was written and +to its owner: the generator that wrote it (its registry id, or the class name of a +generator of your own) and that generator's output directory within the module: + +```json +{ + "target/generated-sources/java/com/acme/CustomerDto.java": { + "hash": "3f1c…", + "generator": "java:dto", + "outputDir": "target/generated-sources/java" + } +} +``` + +With it: + +- a file that still matches its record is overwritten as before; +- **a file you edited is refused by name**, and the build logs a warning (it does not fail); +- **a file no generator emits any more is deleted** when it still matches its record, and + refused by name when you edited it; +- a file the manifest has no record of, and that has no `GENERATED` token, is refused, as + before. + +## What to do + +1. Upgrade the plugin and run `mvn metaobjects:generate` once. The first run finds no + manifest, takes every existing file that carries the `GENERATED` token as generated output, + writes fresh output and records it. It deletes nothing. It prints one notice: + + ``` + mvn metaobjects:generate: created the codegen hash manifest `.metaobjects/.gen-state/.hashes.json` and adopted N existing generated file(s) into it. Commit it: ... + ``` + +2. Commit the manifest, in every module that runs the plugin: + + ```bash + git add .metaobjects/.gen-state/.hashes.json + git commit -m "chore: commit the codegen hash manifest" + ``` + + If a `.gitignore` excludes `.metaobjects/` or `.gen-state/`, re-include the one file: + + ```gitignore + .metaobjects/.gen-state/* + !.metaobjects/.gen-state/.hashes.json + ``` + + Exclude the directory's contents (`.gen-state/*`), not the directory itself: git does not + look inside an excluded directory, so a negation under `.gen-state/` would never apply. + +3. Run `mvn metaobjects:generate` again. A file that a renamed or deleted object used to + produce is now removed. Review that diff. + +If one module declares several `metaobjects:generate` executions, a run deletes only its own +generators' orphans and the files of generators the module no longer configures, so +executions that share an output directory never delete each other's output. The owner does +not name the Maven execution, so `mvn metaobjects:generate` (which runs as `default-cli`) +and the lifecycle execution agree on it. A file two generators emit with different content in +one build fails the goal, naming both: configure only one generator to emit it. + +**New build-time check.** Two executions may not configure the same generator into the same +``, in the main build or in any profile: their files would share one owner, and +neither run could tell the other's output from its own stale files. `metaobjects:generate` +and `metaobjects:verify` now fail before writing anything, naming both executions (and the +profile each comes from). The fix is one line: give each execution its own ``, +or merge the two into one execution. The plugin-level `` is not an execution +of its own, so `mvn metaobjects:generate` beside the execution that inherits it is fine, and +an execution with `none` (inherited and switched off, or run only on demand) +is not counted. Its generators still count as configured, so the output of +`mvn metaobjects:generate@` is never removed by a lifecycle build; that explicit run is +refused when an enabled execution owns the same generator and ``. + +A clone without the manifest adopts again on its first run. That is harmless, but until a +manifest exists the run cannot delete a stale file or protect an edit. + +## If you relied on regeneration discarding edits + +Through 8.0.x an edit to a generated file was overwritten on the next run. Now it is refused, +and the build warns. If the generator's output is never meant to be edited, say so on its +entry, and the run replaces edits as before, naming each file: + +```xml + + com.metaobjects.generator.spring.SpringDtoGenerator + overwrite + ... + +``` + +`keep` is the default. With `overwrite`, an edited file that no generator emits any more is +also deleted, when every generator writing into its directory sets `overwrite`. + +## If you took ownership by deleting the `GENERATED` line + +That still works: deleting the line is an edit, so the file no longer matches its record and +is refused. + +## What does not change + +- User templates (`TemplateScopeGenerator`, `MustacheTemplateGenerator`), the api-docs pages + and the `META-INF/services` registration are not recorded. They are written on every run as + before, and the run never deletes them. +- A refusal is still a warning on the JVM. `mvn metaobjects:verify` is the gate that fails: + it reports a stale file, says whether `generate` deletes it, and tells you to delete an + edited one yourself. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index c1c9be664..0be0e3d3e 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -435,21 +435,21 @@ are in: gitignored and absent, so there is nothing to merge against. What *is* committed is `.gen-state/.hashes.json`, one hash per generated path. That is enough to tell a file nobody has touched (safe to regenerate) from one carrying an edit, and an edited file - is **refused with its path named** rather than overwritten. On TypeScript a refusal - fails the run (`meta gen` exits 1). - -So on **TypeScript, Python and C#** the guarantee is *your edits are never silently -destroyed*; automatic merging is the stronger behaviour you get where the snapshot -exists (TypeScript only — the other two refuse rather than merge). - -**The one way to opt out of that guarantee, stated so nobody meets it by surprise.** C# -and Python fall back to the JVM's marker rule — overwrite a file carrying -`` / `@generated by metaobjects`, refuse one without it — when they are -handed **no gen-state directory at all**. Only an embedder calling `CodegenRunner.Run` or -`decide_and_write` directly can do that; `dotnet meta gen` and `metaobjects gen` always -supply one. In that fallback a hand edit inside a generated file keeps its marker and is + is **refused with its path named** rather than overwritten. On TypeScript, Python and + C# a refusal fails the run (exit 1); on Java and Kotlin it is a warning. + +So on **every port** the guarantee is *your edits are never silently destroyed* (unless +you set `handEdits: overwrite` on that generator); automatic merging is the stronger behaviour you get where the snapshot +exists (TypeScript only — the other ports refuse rather than merge). + +**The one way to opt out of that guarantee, stated so nobody meets it by surprise.** C#, +Python and the JVM fall back to a marker rule — overwrite a file carrying +`` / `@generated by metaobjects` / `GENERATED`, refuse one without it — when +they are handed **no gen-state directory at all**. Only an embedder calling +`CodegenRunner.Run`, `decide_and_write` or a JVM generator directly can do that; +`dotnet meta gen`, `metaobjects gen` and `mvn metaobjects:generate` always supply one. In that fallback a hand edit inside a generated file keeps its marker and is overwritten silently, which is exactly the failure the hash manifest exists to prevent — -so if you embed either runner, supply a gen-state directory. +so if you embed a runner, supply a gen-state directory. **Say the condition out loud, because it is where teams get surprised.** The merge is machine-local. Edit a generated file, push it, and the *next* clone — a colleague's, a CI @@ -490,31 +490,42 @@ artifact is the point of the edit. A `requirementTests()` stub is the standing counterexample: its test name is the link back to the requirement and its own header forbids renaming it, so the body has to stay where it is. -**Java and Kotlin implement the floor, not the detection — and the floor is a different -mechanism, not a weaker version of the same one.** Generated **source** on these ports — -the code MetaObjects authors — goes through `GeneratedFileWriter`, which reads the -existing file before writing and refuses it if it carries no `GENERATED` marker in its -header. That is the whole decision: there -is **no three-way merge** (TypeScript-only) and **no hash manifest**, so nothing here can -notice an edit to a file that still *has* its marker — such a file is overwritten. +**Java and Kotlin decide from the same committed hash manifest (since 8.1.0, ADR-0060).** +Generated **source** on these ports, the code MetaObjects authors, goes through +`GeneratedFileWriter`, which records every file it writes in +`/.metaobjects/.gen-state/.hashes.json`, the same file as the other +ports. Two executions in one module share it: each entry the JVM writes names the generator +and output directory that wrote it, and a run never deletes a file another configured +generator owns, even in an output directory they share; two modules have two manifests. A file that still hashes to its record is +overwritten; an edited one is refused by name (`keep`, the default) or +replaced (`overwrite`). There is **no three-way merge** +(TypeScript-only). **Commit the manifest.** + +The `GENERATED` token in the header still matters at one moment: a file at a generated path +that the manifest has no record of. A file that carries the token is taken as generated +output (**adopted**: written fresh and recorded); one that does not is refused, whatever +`handEdits` says. That is how the first 8.1.0 run on a project upgraded from 8.0.x works: it +adopts every generated file, deletes nothing, and prints one notice to commit the manifest. +The token alone decides only when no manifest is configured, which `mvn metaobjects:generate` +never does (an embedder driving a generator directly can). **Four write paths bypass the guard on purpose**, and they are not oversights: user-supplied templates (`TemplateScopeGenerator`, `MustacheTemplateGenerator`), the `api-docs` generators' pages, and the `META-INF/services` registration whose entire content is a bare -fully-qualified name. None of them emits content MetaObjects authors, so none can be -required to carry a marker — guard one and run 1 writes it while every run after refuses -it, freezing the artifact behind a green build. `GeneratedFileWriter`'s javadoc lists them. +fully-qualified name. None of them emits content MetaObjects authors, so none is recorded in +the manifest. They are overwritten on every run, and the orphan sweep never deletes them. +`GeneratedFileWriter`'s javadoc lists them. -Two consequences to hold on to, because they invert the TypeScript answer: +Two consequences to hold on to: -- **Taking ownership is an explicit gesture: delete the marker line.** After that, - regeneration never touches the file again. (On TypeScript, deleting the header buys you - nothing — the write decision never reads it. There, ownership is what the hash manifest - *observes*, not what the header declares.) +- **Editing a generated file now protects it.** Through 8.0.x an edit that kept the token was + overwritten on the next run. Now it is refused by name, like on the other ports. Deleting the + token line is an edit too, so it still takes a file out of regeneration. - **A refusal is a WARNING, not a build failure.** These generators run inside `mvn metaobjects:generate`, and failing the reactor over a file the user chose to own would punish the person the guard exists to protect. So a JVM build stays green while a - refused file goes stale — watch the log. (On TypeScript a refusal exits 1.) + refused file goes stale; `mvn metaobjects:verify` is the gate that fails. (On the other + ports a refusal exits 1.) **One thing here IS a build failure: two generators claiming the same output path.** If a selection emits one path twice with differing content, `mvn metaobjects:generate` fails @@ -539,29 +550,140 @@ directories never hold the same class. If your `` run `value-object` give exactly one of them `true` and it writes the files into its own `outputDir`. -**Ownership still wins over the collision error.** The marker question is asked first, so +**Ownership still wins over the collision error.** The ownership question is asked first, so a path you hand-own is refused before it is ever claimed: two generators colliding on a file you took ownership of produce two warnings and a green build, not a failed reactor. Neither write could reach disk, so there is no order-dependent output to report — and failing the build there would punish exactly the person the refusal exists to protect. Restore the file to generated output and the collision is raised again on the next run. -Two write paths deliberately **bypass** the guard, and the reason is the same in both: -the guard is sound only where our own emitter always writes the marker. `DocsMojo`'s API -pages render from `templates/api/*.mustache`, and `TemplateScopeGenerator` emits whatever -a user's template-spec renders (SQL, markdown, CSV) — neither content is ours to -require a marker of, and guarding them would make the first run write and every run after -refuse, freezing the artifact while the build stayed green. - -Edit detection is deliberately not replicated here, because these ports' customization -model is build-config and template-spec rather than editing emitted files in place (see -"Per port" below); the accuracy would cost a committed state file, a migration and a new -class of merge conflict on a workflow that does not ask for it. - Keep `.gen-state/.hashes.json` committed. Ignoring it is what turns the second case into a silent overwrite, since a machine with neither a snapshot nor a hash cannot tell your edit from its own output. +## Files that are no longer generated + +Rename a value object from `Telemetry` to `TelemetryReading`, delete an entity, or move a +package, and the next `gen` stops emitting the files the old name produced. Every port +handles those files by one rule: + +- **Only a full run cleans up.** A run of the project's whole generator list decides; a run + narrowed to some generators (`--generators`), to some entities (`meta gen `, + `metaobjects gen --entities`), or to a target that shares its directory with a target left + out, deletes nothing, because the files of the part it skipped would all look stale. So + does `--baseline=adopt`, which writes nothing by definition. +- **A candidate is a file the manifest says `gen` wrote.** It lies under an output directory + of the run, `.metaobjects/.gen-state/.hashes.json` records writing it, and no generator + emitted it this run. A file the manifest does not record is not generated output, whatever + its name or header, and is never a candidate. In TypeScript a full `meta gen` covers every + declared output directory (`outDir` and each `targets.*.outDir`), whether or not a + generator still writes there, so moving every generator out of a directory still cleans + it, as `meta verify --codegen` reports. +- **An untouched candidate is deleted.** Its content still hashes to what was recorded, so + nothing of yours is in it. The file goes and its manifest entry is dropped. `gen` lists it + as removed. +- **An edited candidate is refused by name.** It stays on disk and stays recorded, so the + warning repeats until you act: delete it, or, if what produced it was renamed, move your + edits into the newly generated file (or a file of your own) and then delete it. + Regeneration cannot follow a rename. +- **A recorded file that is already gone is forgotten**, so a file you create later under + that name is not mistaken for generated output. +- **A dry run** (`meta gen --dry-run`) reports what it would remove and removes nothing. + +On **Java and Kotlin** every `mvn metaobjects:generate` execution is a full run, since its +`` list is always complete. Every manifest entry names its owner: the generator's +registry id and its output directory within the module, not the Maven execution, so +`default-cli`, a renamed execution and the lifecycle execution agree. A recorded file no +generator emitted in this build is an orphan when this run's own generators own it, or when +no `metaobjects:generate` execution of the module (nor the plugin-level configuration) +configures its owner any more: a removed generator's files are deleted wherever they live. +Executions inside a profile count whether the profile is active or not, including profiles a +parent POM declares. Maven builds the module with this build's profiles, with no profile, and +with each profile on alone, and the configured generators are the union of the three. A +profile's own properties resolve as they do under `-P`, so a plain build keeps the +files of a generator only `-P` runs, and `-Pmysql` keeps the files `-Ppg` wrote when +the two profiles point one generator at different directories. A generator that only exists +when two profiles are on together counts only on a build that activates both. +A file another configured generator owns is never an orphan to this run, so two executions +in one module never delete each other's files, even when they share an output directory. +Two executions may not configure the same generator into the same output directory, in the +main build or any profile: their files would share one owner. `generate` and `verify` fail +before writing anything, naming both executions and the profile each comes from; give each +its own ``, or merge them into one execution. The plugin-level `` +is no execution of its own, so a command-line run beside the execution that inherits it is +one configuration run twice. An execution with `none` (how a module disables +one it inherits from a parent POM, or declares one that runs only on demand) is not counted +by this check, but its generators still count as configured, so a lifecycle build or +`verify` never treats the output of an on-demand run as stranded. Run explicitly +(`mvn metaobjects:generate@`), it is refused when an enabled execution owns the same +generator and output directory. +One file emitted with different content by two generator/output-directory pairs in the same +build fails the goal, naming both; the same bytes from both are one file, and a file you own +is refused for each, not a collision. The first build on a manifest that names no generator owner records the owners +and deletes nothing. A refused orphan is a warning there, as every JVM refusal is +(ADR-0060). `mvn metaobjects:verify` applies the same rule, so a removed generator's file is +drift that `generate` deletes. + +`verify --codegen` judges extra files on the same evidence, so the gate and `gen` cannot +disagree. A file the manifest does not record is not drift. An untouched stale file is +reported with "gen deletes it", and running `gen` clears it. An edited stale file is +reported as edited by hand, and the gate tells you to delete it instead of telling you to +regenerate, which would change nothing. A project whose manifest records nothing yet (the +first run, before anything is committed) has no evidence either way, so the gate reports +every extra file plainly and `gen` deletes none. + +Keep `.hashes.json` committed: it is the only record that says which files in an output +directory `gen` wrote, so without it a clean clone can neither delete a stale file nor +protect an edited one. + +**Opting a TypeScript generator out.** A generator that writes files it must never have +deleted, because something else reads them by name, sets `orphanPolicy: { keep: true, owns }` +for that namespace; the run-wide cleanup then skips it. The `requirement-tests` generator's +`reconcileOrphans: false` does exactly that for its stubs. A generator's own `orphanPolicy` +with `owns` also cleans its files on a `--generators` run that selects it, which the run-wide +cleanup does not. + +## `handEdits`: keep or discard edits to one generator's files + +By default `gen` never throws away something you typed: an edited generated file is +refused (TypeScript merges it when a local snapshot exists), and an edited file no generator +emits any more is refused too. Some output is never meant to be edited, and for it the +refusal is only noise. Each generator entry takes one setting for that: + +| `handEdits` | an edited generated file | an edited file no generator emits any more | +|---|---|---| +| `keep` (default) | refused by name (TypeScript: merged when a snapshot exists) | refused by name | +| `overwrite` | replaced with fresh output, its manifest record updated | deleted, its manifest record dropped | + +Every file `overwrite` discards is named, one line each (`overwrote hand-edited ...`, +`deleted hand-edited , which no generator emits any more ...`), so nothing disappears +silently. `overwrite` applies only to files the manifest records `gen` writing: a file with +no record is not generated output and is still refused. + +On TypeScript, Python and C# the manifest records which files `gen` wrote, not which +generator wrote each one. So an edited orphan is deleted only when **every** generator +writing into its output directory sets `overwrite`. One `keep` entry in the directory keeps +the file and the refusal. The Java and Kotlin manifest does record each file's generator, +but applies the same directory-wide rule, so every port behaves the same. + +`verify --codegen` follows the same setting. Under `overwrite` an edit is drift that `gen` +fixes, and the report says "gen replaces it" or "gen deletes it" and tells you to +regenerate. Under `keep` an edited orphan is reported as edited by hand, with the advice to +delete it. + +Where you set it: + +- **TypeScript** (`metaobjects.config.ts`): `{ ...entityFile(), handEdits: "overwrite" }`. +- **Python** (`metaobjects.config.yaml`): an entry written as a mapping, + `generators: [{ name: entity, handEdits: overwrite }]`. A plain name keeps the default. +- **C#** (`codegen/Generators.cs`): `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)`. +- **Java, Kotlin** (the Maven plugin): `overwrite` on the + `` element. + +`handEdits` is a standing decision about one generator. `--baseline=fresh` (TypeScript) is a +one-off for one run: it overwrites every refused file of that run, of every generator, +including files the manifest has no record of. + ## The sibling module (`.extra.ts`) is a convention, not a mechanism Generated TypeScript files point at a sibling module for code metadata can't express, and @@ -1043,7 +1165,8 @@ field, a constructor or an accessor, edit the writer. You subclass nothing. `-Dl What stays in the package holds no emit: `MultiFileDirectGeneratorBase` (the one-file-per-object loop), `FileDirectWriter` (indentation and `println`) and -`GeneratedFileWriter` (the write guard: a file with no `GENERATED` marker is left alone). +`GeneratedFileWriter` (the write guard: the hash manifest decides, and a file with no record +and no `GENERATED` marker is left alone). `entity` writes no extractors; those are the `extractor` generator's, below. **`extractor` copies three files.** `ExtractorCodeGenerator.java` holds its emit. diff --git a/docs/features/relationships.md b/docs/features/relationships.md index 652281d18..8c7dabb78 100644 --- a/docs/features/relationships.md +++ b/docs/features/relationships.md @@ -584,6 +584,7 @@ The following conformance fixtures gate this feature's behavior across ports: - [`fixtures/conformance/relationship-one-two-refs-name-pairing/`](../../fixtures/conformance/relationship-one-two-refs-name-pairing/) — the same shape resolved by name-pairing alone (ladder stage 3) - [`fixtures/conformance/error-relationship-one-refs-ambiguous/`](../../fixtures/conformance/error-relationship-one-refs-ambiguous/) — neither `@sourceRefField` nor a pairing name given: `ERR_INVALID_RELATIONSHIP` at load (#368, ADR-0029 Amendment 1) - [`fixtures/conformance/relationship-one-two-refs-dotted-references/`](../../fixtures/conformance/relationship-one-two-refs-dotted-references/) — the same two-reference shape with `@references` in the dotted `Entity.field` form: the entity half is the segment before the first `.`, so the ladder resolves identically to the bare form +- [`fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/`](../../fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/) — a `@cardinality: one` relationship whose `@objectRef` names no object while it declares `@sourceRefField`: one `ERR_INVALID_RELATIONSHIP` for the unresolved reference, and the reference ladder is not run against a target that does not exist - [`fixtures/conformance/error-relationship-m2m-junction-unpairable/`](../../fixtures/conformance/error-relationship-m2m-junction-unpairable/) — a junction declaring its two `identity.reference` children but neither resolving to the navigating entity: `ERR_INVALID_RELATIONSHIP` at load in every port (owner ruling 2026-09-20) Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index 1d4ae8d03..d9ef71757 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -586,8 +586,9 @@ Each spec entry derives the neutral template data dict for its scope `--out`, or under its own `OutDir` when it sets one, and are gated byte-identical against the shared `fixtures/template-codegen-conformance/` corpus. A `target` field is rejected (C# has no output-target concept); a missing spec file, a bad template ref or a wrong `TemplateRoot` -surfaces as a clean error, not a stack trace. For output to be regenerable, the **template** -must emit the `@generated` header itself (the write path refuses to overwrite files lacking it). +surfaces as a clean error, not a stack trace. The write path decides from the committed hash +manifest, not the `@generated` header, so the template need not emit one (see +[codegen-concepts](../features/codegen-concepts.md#7-safety-never-clobbering-hand-edits)). ## Angular 18 frontend diff --git a/docs/ports/java.md b/docs/ports/java.md index d714b20ab..5c1389ee9 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -652,9 +652,10 @@ renders the same everywhere. One deliberate difference from the other ports: this generator writes its output **directly**, not through `GeneratedFileWriter`, so your template is under no -obligation to emit the `@generated` marker. The marker floor guards output whose -header MetaObjects controls; a user template's output is not that. The tradeoff is -that these files are overwritten on every run — keep hand edits out of them. +obligation to emit a marker, and its output is not recorded in the codegen hash manifest. +The tradeoff is that these files are overwritten on every run, and the orphan sweep never +deletes one that is no longer rendered — keep hand edits out of them, and delete a stale one +yourself. The data dict your template receives is documented in [`codegen-data-shapes.md`](../features/codegen-data-shapes.md). diff --git a/docs/ports/python.md b/docs/ports/python.md index 70fe9e6de..ad3b30936 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -523,9 +523,9 @@ The named generators run after the target's `generators`, into the same `outDir` are gated byte-identical against the shared `fixtures/template-codegen-conformance/` corpus. Output is format-agnostic (text/markdown/csv/json/xml/html), so the template-spec pass emits no `__init__.py` into its tree. A `target` field inside the spec is rejected (the -spec's target is the one that declares it); for output to be regenerable, the **template** -must emit the `@generated` header itself (the write path refuses to overwrite files lacking -it). +spec's target is the one that declares it). The write path decides from the committed hash +manifest, not the `@generated` header, so the template need not emit one (see +[codegen-concepts](../features/codegen-concepts.md#7-safety-never-clobbering-hand-edits)). ### Universal browser-client hookup (React / Angular 18) diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 1c0960d4f..c6f8a3d89 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -85,6 +85,7 @@ import { GENERATED_EDIT_NOTE, sidecarLine, DEFAULT_ID_FIELD, + pkInfoOf, isTphSubtype, tphDiscriminatorPin, tphStorageObject, @@ -338,9 +339,10 @@ ${m2mMountsFlat}} function m2mEntriesOf( ctx: RenderContext, name: string, -): Array { +): Array { return (ctx.relationMap.get(name) ?? []).filter( - (e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined, + (e): e is RelationEntry & { junctionEntity: string; junction: MetaObject } => + e.junctionEntity !== undefined && e.junction !== undefined, ); } @@ -353,7 +355,7 @@ function m2mEntriesOf( * has no M:N relationships — CRUD-only output stays byte-identical to before. */ function renderM2mMounts( - entries: ReadonlyArray, + entries: ReadonlyArray, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -397,7 +399,7 @@ interface TphM2mSource { * column). mountM2mRoute appends `/:id/` to the source $path. */ function renderM2mMount( - entry: RelationEntry & { junctionEntity: string }, + entry: RelationEntry & { junctionEntity: string; junction: MetaObject }, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -412,7 +414,7 @@ function renderM2mMount( `${ctx.collectionName(entry.junctionEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, )}`, @@ -421,26 +423,25 @@ function renderM2mMount( // subtype has no table const, and the junction FK can only point at the base table — // and filters the rows to the subtype, because a Broker id in that FK column is not a // Carrier. Every other target binds to itself, and emits no filter. - const declaredTarget = ctx.loadedRoot.findObject(entry.targetEntity); - const target = declaredTarget === undefined ? undefined : tphStorageObject(declaredTarget); - const targetTableEntity = target?.name ?? entry.targetEntity; - const pin = declaredTarget === undefined ? undefined : tphDiscriminatorPin(declaredTarget); + const target = tphStorageObject(entry.target); + const targetTableEntity = target.name; + const pin = tphDiscriminatorPin(entry.target); // A self-join's target table IS the source table, which this file already imports from // the entity module; a second import of the same binding is TS2300, and a SyntaxError // when Node loads the module. - const targetVarSym = targetTableEntity === source.name + const targetVarSym = target === source ? ctx.collectionName(source.name) : imp( `${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(targetTableEntity), + effectivePackage(target), targetTableEntity, ctx.extStyle, )}`, ); const mountM2mRouteSym = imp(`mountM2mRoute@${httpRuntimeSpecifier("drizzle-fastify", ctx, sourcePkg)}`); - const junction = ctx.loadedRoot.findObject(entry.junctionEntity); + const junction = entry.junction; // fromPackage = source.package: this routes file is SOURCE's own module, never the // junction's or the target's — see resolveJunctionColumn's doc comment (B1). // The relation-resolver pre-pass derives both join fields for every M:N entry. @@ -448,16 +449,10 @@ function renderM2mMount( if (sourceJoinField === undefined || targetJoinField === undefined) { throw new Error(`routes-file: M:N "${entry.name}" on ${source.name} has no derived join fields`); } - const sourceColumn: Code = junction - ? resolveJunctionColumn(junction, sourceJoinField, ctx, sourcePkg) - : code`${JSON.stringify(sourceJoinField)}`; - const targetColumn: Code = junction - ? resolveJunctionColumn(junction, targetJoinField, ctx, sourcePkg) - : code`${JSON.stringify(targetJoinField)}`; - const targetPkColumn: Code = target - ? resolveJunctionColumn(target, ctx.pkMap.get(targetTableEntity)?.fieldName ?? "id", ctx, sourcePkg) - : code`${JSON.stringify("id")}`; - const discriminatorLine: Code | string = pin !== undefined && target !== undefined + const sourceColumn: Code = resolveJunctionColumn(junction, sourceJoinField, ctx, sourcePkg); + const targetColumn: Code = resolveJunctionColumn(junction, targetJoinField, ctx, sourcePkg); + const targetPkColumn: Code = resolveJunctionColumn(target, pkInfoOf(target)?.fieldName ?? "id", ctx, sourcePkg); + const discriminatorLine: Code | string = pin !== undefined ? code` targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },` : ""; @@ -595,7 +590,7 @@ function renderTphRoutes( // compile gate stayed green while the endpoint 404'd. // // The physical columns stage 0 needs, resolved once against the base's own table. - const basePkField = ctx.pkMap.get(baseName)?.fieldName ?? "id"; + const basePkField = pkInfoOf(base)?.fieldName ?? "id"; const basePkColumn = resolveJunctionColumn(base, basePkField, ctx, basePkg); const baseDiscColumn = resolveJunctionColumn(base, discField, ctx, basePkg); const baseM2mMounts = renderM2mMounts(m2mEntriesOf(ctx, baseName), base, ctx, fastifyRef); diff --git a/examples/advanced-modeling/codegen/templates/drizzle-schema.ts b/examples/advanced-modeling/codegen/templates/drizzle-schema.ts index 4c9418ad9..93e85d56a 100644 --- a/examples/advanced-modeling/codegen/templates/drizzle-schema.ts +++ b/examples/advanced-modeling/codegen/templates/drizzle-schema.ts @@ -3,7 +3,7 @@ // plus the relations() block auto-emitted at the end. import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts/engine"; -import { MetaObject, MetaField, MetaIndex, isMetaObject, stripPackage } from "@metaobjectsdev/metadata"; +import { MetaObject, MetaField, MetaIndex, isMetaObject } from "@metaobjectsdev/metadata"; import { IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_EXPR, IDENTITY_ATTR_USING, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE, @@ -12,8 +12,10 @@ import { FIELD_ATTR_OBJECT_REF, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_DATE, + IDENTITY_REFERENCE_ATTR_REFERENCES, + refTarget, } from "@metaobjectsdev/metadata"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { mapColumnType, PG_IDENTITY_CAPABLE_FNS, @@ -411,6 +413,7 @@ ${joinCode(columnLines, { on: ",\n", trim: false })} interface FkInfo { targetVarName: string; // e.g., "users" targetEntityName: string; // e.g., "User" — used for the import path + targetPackage: string | undefined; // the target's effective package, for the import path targetPkField: string; // e.g., "id" } @@ -439,20 +442,16 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map { const refRaw = field.attr(FIELD_ATTR_OBJECT_REF); const emitted = typeof refRaw === "string" - ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage)) + ? ctx.resolveValueObjectName(field) : name; return imp(`t:${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`); }; diff --git a/examples/advanced-modeling/codegen/templates/entity-file.ts b/examples/advanced-modeling/codegen/templates/entity-file.ts index 91357b080..b448ae1a5 100644 --- a/examples/advanced-modeling/codegen/templates/entity-file.ts +++ b/examples/advanced-modeling/codegen/templates/entity-file.ts @@ -8,8 +8,8 @@ import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts/engine"; import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; -import { FIELD_ATTR_OBJECT_REF, SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import { SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { renderDrizzleSchema } from "./drizzle-schema.js"; import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js"; import { renderZodValidators, primaryIdentityFieldNames } from "./zod-validators.js"; @@ -153,8 +153,7 @@ export function renderEntityFile( // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`) // under a cross-package short-name collision. const voRef = (field: MetaField): { name: string; module: string } => { - const ref = field.attr(FIELD_ATTR_OBJECT_REF); - const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entityPkg)); + const name = ctx.resolveValueObjectName(field); const module = valueObjectModuleSpecifier(name, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle); return { name, module }; }; diff --git a/examples/advanced-modeling/codegen/templates/inferred-types.ts b/examples/advanced-modeling/codegen/templates/inferred-types.ts index 93cd39981..588ec5fde 100644 --- a/examples/advanced-modeling/codegen/templates/inferred-types.ts +++ b/examples/advanced-modeling/codegen/templates/inferred-types.ts @@ -42,7 +42,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { isTphReadNullTolerant } from "./zod-validators.js"; import { sharedEnumForField } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "@metaobjectsdev/codegen-ts/engine"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine"; /** @@ -311,7 +311,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render // Drizzle .$type<> use) so all three agree. Without a ctx (bare unit-test // calls) fall back to the bare name + flat same-dir specifier. const entityPkg = effectivePackage(entity); - const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -330,7 +330,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const entityPkg = effectivePackage(entity); - const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/examples/advanced-modeling/codegen/templates/projection-decl.ts b/examples/advanced-modeling/codegen/templates/projection-decl.ts index 5d81ec24d..e9ab1e45d 100644 --- a/examples/advanced-modeling/codegen/templates/projection-decl.ts +++ b/examples/advanced-modeling/codegen/templates/projection-decl.ts @@ -18,7 +18,7 @@ import { import { projectionViewName } from "@metaobjectsdev/codegen-ts/engine"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "@metaobjectsdev/codegen-ts/engine"; import type { ColumnNamingStrategy } from "@metaobjectsdev/codegen-ts/engine"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import type { Dialect } from "@metaobjectsdev/codegen-ts/engine"; import { physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; @@ -113,7 +113,7 @@ export function renderProjectionDecl( const ref = field.attr(FIELD_ATTR_OBJECT_REF); const rawRef = typeof ref === "string" ? ref : ""; const name = ctx - ? ctx.resolveValueObjectName(rawRef, fieldDeclaringPackage(field, projectionPkg)) + ? ctx.resolveValueObjectName(field) : stripPackage(rawRef); const module = ctx ? valueObjectModuleSpecifier(name, ctx.packageOf, projectionPkg, ctx.outputLayout, ctx.extStyle) diff --git a/examples/advanced-modeling/codegen/templates/queries.ts b/examples/advanced-modeling/codegen/templates/queries.ts index 485daf65e..95f427fd6 100644 --- a/examples/advanced-modeling/codegen/templates/queries.ts +++ b/examples/advanced-modeling/codegen/templates/queries.ts @@ -4,10 +4,13 @@ import { code, imp, type Code } from "@metaobjectsdev/codegen-ts/engine"; import { type MetaObject, + IDENTITY_REFERENCE_ATTR_REFERENCES, + isMetaObject, + refTarget, stripPackage, } from "@metaobjectsdev/metadata"; import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; -import { getPkInfo, subTypeToTsType } from "@metaobjectsdev/codegen-ts/engine"; +import { getPkInfo, pkInfoOf, subTypeToTsType } from "@metaobjectsdev/codegen-ts/engine"; import { supportsReturning } from "@metaobjectsdev/codegen-ts/engine"; import { findByIdFnName, @@ -218,8 +221,10 @@ export async function ${fnName}(db: Db, ${pkField}: ${pkType}): Promise interface ReverseFk { /** FK FIELD name on this entity (logical), e.g. `currentSceneId`. */ fkField: string; - /** Target entity (the `T` referenced), e.g. `Scene`. Drives the value TS type. */ + /** Target entity name (the `T` referenced), e.g. `Scene`. An identifier, never a lookup key. */ targetEntity: string; + /** The entity `@references` binds to (ADR-0059 R7). Its PK type drives the value TS type. */ + target?: MetaObject | undefined; } /** Collect this entity's OWN reverse FK targets, in declaration order. */ @@ -227,9 +232,11 @@ export function reverseFksFor(entity: MetaObject): ReverseFk[] { const out: ReverseFk[] = []; for (const ref of entity.referenceIdentities()) { const fkField = ref.fields[0]; - const target = ref.targetEntity; - if (!fkField || !target) continue; - out.push({ fkField, targetEntity: stripPackage(target) }); + const targetRef = ref.targetEntity; + if (!fkField || !targetRef) continue; + const bound = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); + const target = isMetaObject(bound) ? bound : undefined; + out.push({ fkField, targetEntity: target?.name ?? stripPackage(targetRef), target }); } return out; } @@ -244,7 +251,7 @@ export function renderReverseFinderFns(entity: MetaObject, fk: ReverseFk, ctx: R // name is the argument to integer()/text()), so column access uses fk.fkField. // The FK value type is the target entity's PK type (the FK references it). Fall // back to the FK field's own subType, then to number (long-shaped key default). - const targetPk = ctx.pkMap.get(fk.targetEntity); + const targetPk = fk.target === undefined ? undefined : pkInfoOf(fk.target); const ownField = entity.findField(fk.fkField); const valueType = subTypeToTsType(targetPk?.fieldSubType ?? ownField?.subType ?? "long"); diff --git a/examples/advanced-modeling/codegen/templates/relations-block.ts b/examples/advanced-modeling/codegen/templates/relations-block.ts index f345c27a0..5b1cbbc64 100644 --- a/examples/advanced-modeling/codegen/templates/relations-block.ts +++ b/examples/advanced-modeling/codegen/templates/relations-block.ts @@ -8,7 +8,8 @@ import { type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { crossEntitySpecifier } from "@metaobjectsdev/codegen-ts/engine"; import type { RelationEntry } from "@metaobjectsdev/codegen-ts/engine"; import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine"; -import { tphStorageName } from "@metaobjectsdev/codegen-ts/engine"; +import { tphStorageObject } from "@metaobjectsdev/codegen-ts/engine"; +import { pkInfoOf } from "@metaobjectsdev/codegen-ts/engine"; /** * Render the relations() block for one entity. @@ -32,7 +33,7 @@ export function renderRelationsBlock(entity: MetaObject, ctx: RenderContext): Co const thisEntityPackage = effectivePackage(entity); const lines: Code[] = entries.map((entry) => - renderRelationEntry(entry, ctx, entity.name, varName, thisEntityPackage), + renderRelationEntry(entry, ctx, entity, varName, thisEntityPackage), ); return code`export const ${relationsVarName} = ${relationsFn}(${varName}, (${params}) => ({ @@ -44,7 +45,7 @@ ${joinCode(lines, { on: ",\n", trim: false })} function renderRelationEntry( entry: RelationEntry, ctx: RenderContext, - thisEntityName: string, + thisEntity: MetaObject, thisVarName: string, thisEntityPackage: string | undefined, ): Code { @@ -53,11 +54,11 @@ function renderRelationEntry( // then hops junction → target via the junction's one() sides). The // mountM2mRoute the routes generator emits performs the flattened two-stage // traversal for the REST contract. - if (entry.cardinality === CARDINALITY_MANY && entry.junctionEntity !== undefined) { + if (entry.cardinality === CARDINALITY_MANY && entry.junctionEntity !== undefined && entry.junction !== undefined) { const junctionSpec = crossEntitySpecifier( ctx.outputLayout, thisEntityPackage, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, ); @@ -67,22 +68,23 @@ function renderRelationEntry( // Bind to the table that stores the target's rows: a TPH subtype's module has no // table const, so a navigation onto `Carrier` binds `parties` from `Party`. - const targetTableEntity = tphStorageName(entry.targetEntity, ctx.loadedRoot); + const targetTable = tphStorageObject(entry.target); + const targetTableEntity = targetTable.name; // A navigation onto this entity's OWN table (`Order.parent`, or a TPH base onto one of // its subtypes) names the local const: importing it from this very module clashes with // its declaration (TS2440). Otherwise imp() tracks and emits the cross-entity import. - const targetVarSym = targetTableEntity === thisEntityName + const targetVarSym = targetTable === thisEntity ? thisVarName : imp(`${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, thisEntityPackage, - ctx.packageOf.get(targetTableEntity), + effectivePackage(targetTable), targetTableEntity, ctx.extStyle, )}`); if (entry.cardinality === CARDINALITY_ONE) { - const pkInfo = ctx.pkMap.get(targetTableEntity); + const pkInfo = pkInfoOf(targetTable); const targetPkField = pkInfo?.fieldName ?? "id"; return code` ${entry.name}: one(${targetVarSym}, { fields: [${thisVarName}.${entry.fkField ?? "id"}], references: [${targetVarSym}.${targetPkField}] })`; } diff --git a/examples/advanced-modeling/codegen/templates/zod-validators.ts b/examples/advanced-modeling/codegen/templates/zod-validators.ts index 2b5ce2908..113186b89 100644 --- a/examples/advanced-modeling/codegen/templates/zod-validators.ts +++ b/examples/advanced-modeling/codegen/templates/zod-validators.ts @@ -46,7 +46,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { sharedEnumForField } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumImportSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumZodConstName } from "./enums-file.js"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; // FR-035: the SAME required-predicate that drives the Drizzle column's .notNull() // drives the UpdateSchema's .nullable() exclusion — shared so they cannot drift. @@ -626,7 +626,7 @@ function zodFieldExpr( // Drizzle .$type<> use) so all three agree. Without owner/ctx (bare // unit-test calls) fall back to the bare name + flat same-dir. const ownerPkg = (ctx && owner) ? effectivePackage(owner) : undefined; - const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, ownerPkg)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, ownerPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -648,7 +648,7 @@ function zodFieldExpr( const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const ownerPkg = (ctx && owner) ? effectivePackage(owner) : undefined; - const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, ownerPkg)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, ownerPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index 1c0960d4f..c6f8a3d89 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -85,6 +85,7 @@ import { GENERATED_EDIT_NOTE, sidecarLine, DEFAULT_ID_FIELD, + pkInfoOf, isTphSubtype, tphDiscriminatorPin, tphStorageObject, @@ -338,9 +339,10 @@ ${m2mMountsFlat}} function m2mEntriesOf( ctx: RenderContext, name: string, -): Array { +): Array { return (ctx.relationMap.get(name) ?? []).filter( - (e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined, + (e): e is RelationEntry & { junctionEntity: string; junction: MetaObject } => + e.junctionEntity !== undefined && e.junction !== undefined, ); } @@ -353,7 +355,7 @@ function m2mEntriesOf( * has no M:N relationships — CRUD-only output stays byte-identical to before. */ function renderM2mMounts( - entries: ReadonlyArray, + entries: ReadonlyArray, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -397,7 +399,7 @@ interface TphM2mSource { * column). mountM2mRoute appends `/:id/` to the source $path. */ function renderM2mMount( - entry: RelationEntry & { junctionEntity: string }, + entry: RelationEntry & { junctionEntity: string; junction: MetaObject }, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -412,7 +414,7 @@ function renderM2mMount( `${ctx.collectionName(entry.junctionEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, )}`, @@ -421,26 +423,25 @@ function renderM2mMount( // subtype has no table const, and the junction FK can only point at the base table — // and filters the rows to the subtype, because a Broker id in that FK column is not a // Carrier. Every other target binds to itself, and emits no filter. - const declaredTarget = ctx.loadedRoot.findObject(entry.targetEntity); - const target = declaredTarget === undefined ? undefined : tphStorageObject(declaredTarget); - const targetTableEntity = target?.name ?? entry.targetEntity; - const pin = declaredTarget === undefined ? undefined : tphDiscriminatorPin(declaredTarget); + const target = tphStorageObject(entry.target); + const targetTableEntity = target.name; + const pin = tphDiscriminatorPin(entry.target); // A self-join's target table IS the source table, which this file already imports from // the entity module; a second import of the same binding is TS2300, and a SyntaxError // when Node loads the module. - const targetVarSym = targetTableEntity === source.name + const targetVarSym = target === source ? ctx.collectionName(source.name) : imp( `${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(targetTableEntity), + effectivePackage(target), targetTableEntity, ctx.extStyle, )}`, ); const mountM2mRouteSym = imp(`mountM2mRoute@${httpRuntimeSpecifier("drizzle-fastify", ctx, sourcePkg)}`); - const junction = ctx.loadedRoot.findObject(entry.junctionEntity); + const junction = entry.junction; // fromPackage = source.package: this routes file is SOURCE's own module, never the // junction's or the target's — see resolveJunctionColumn's doc comment (B1). // The relation-resolver pre-pass derives both join fields for every M:N entry. @@ -448,16 +449,10 @@ function renderM2mMount( if (sourceJoinField === undefined || targetJoinField === undefined) { throw new Error(`routes-file: M:N "${entry.name}" on ${source.name} has no derived join fields`); } - const sourceColumn: Code = junction - ? resolveJunctionColumn(junction, sourceJoinField, ctx, sourcePkg) - : code`${JSON.stringify(sourceJoinField)}`; - const targetColumn: Code = junction - ? resolveJunctionColumn(junction, targetJoinField, ctx, sourcePkg) - : code`${JSON.stringify(targetJoinField)}`; - const targetPkColumn: Code = target - ? resolveJunctionColumn(target, ctx.pkMap.get(targetTableEntity)?.fieldName ?? "id", ctx, sourcePkg) - : code`${JSON.stringify("id")}`; - const discriminatorLine: Code | string = pin !== undefined && target !== undefined + const sourceColumn: Code = resolveJunctionColumn(junction, sourceJoinField, ctx, sourcePkg); + const targetColumn: Code = resolveJunctionColumn(junction, targetJoinField, ctx, sourcePkg); + const targetPkColumn: Code = resolveJunctionColumn(target, pkInfoOf(target)?.fieldName ?? "id", ctx, sourcePkg); + const discriminatorLine: Code | string = pin !== undefined ? code` targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },` : ""; @@ -595,7 +590,7 @@ function renderTphRoutes( // compile gate stayed green while the endpoint 404'd. // // The physical columns stage 0 needs, resolved once against the base's own table. - const basePkField = ctx.pkMap.get(baseName)?.fieldName ?? "id"; + const basePkField = pkInfoOf(base)?.fieldName ?? "id"; const basePkColumn = resolveJunctionColumn(base, basePkField, ctx, basePkg); const baseDiscColumn = resolveJunctionColumn(base, discField, ctx, basePkg); const baseM2mMounts = renderM2mMounts(m2mEntriesOf(ctx, baseName), base, ctx, fastifyRef); diff --git a/examples/showcase/codegen/templates/drizzle-schema.ts b/examples/showcase/codegen/templates/drizzle-schema.ts index 4c9418ad9..93e85d56a 100644 --- a/examples/showcase/codegen/templates/drizzle-schema.ts +++ b/examples/showcase/codegen/templates/drizzle-schema.ts @@ -3,7 +3,7 @@ // plus the relations() block auto-emitted at the end. import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts/engine"; -import { MetaObject, MetaField, MetaIndex, isMetaObject, stripPackage } from "@metaobjectsdev/metadata"; +import { MetaObject, MetaField, MetaIndex, isMetaObject } from "@metaobjectsdev/metadata"; import { IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_EXPR, IDENTITY_ATTR_USING, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE, @@ -12,8 +12,10 @@ import { FIELD_ATTR_OBJECT_REF, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_DATE, + IDENTITY_REFERENCE_ATTR_REFERENCES, + refTarget, } from "@metaobjectsdev/metadata"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { mapColumnType, PG_IDENTITY_CAPABLE_FNS, @@ -411,6 +413,7 @@ ${joinCode(columnLines, { on: ",\n", trim: false })} interface FkInfo { targetVarName: string; // e.g., "users" targetEntityName: string; // e.g., "User" — used for the import path + targetPackage: string | undefined; // the target's effective package, for the import path targetPkField: string; // e.g., "id" } @@ -439,20 +442,16 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map { const refRaw = field.attr(FIELD_ATTR_OBJECT_REF); const emitted = typeof refRaw === "string" - ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage)) + ? ctx.resolveValueObjectName(field) : name; return imp(`t:${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`); }; diff --git a/examples/showcase/codegen/templates/entity-file.ts b/examples/showcase/codegen/templates/entity-file.ts index 91357b080..b448ae1a5 100644 --- a/examples/showcase/codegen/templates/entity-file.ts +++ b/examples/showcase/codegen/templates/entity-file.ts @@ -8,8 +8,8 @@ import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts/engine"; import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; -import { FIELD_ATTR_OBJECT_REF, SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import { SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { renderDrizzleSchema } from "./drizzle-schema.js"; import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js"; import { renderZodValidators, primaryIdentityFieldNames } from "./zod-validators.js"; @@ -153,8 +153,7 @@ export function renderEntityFile( // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`) // under a cross-package short-name collision. const voRef = (field: MetaField): { name: string; module: string } => { - const ref = field.attr(FIELD_ATTR_OBJECT_REF); - const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entityPkg)); + const name = ctx.resolveValueObjectName(field); const module = valueObjectModuleSpecifier(name, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle); return { name, module }; }; diff --git a/examples/showcase/codegen/templates/inferred-types.ts b/examples/showcase/codegen/templates/inferred-types.ts index 93cd39981..588ec5fde 100644 --- a/examples/showcase/codegen/templates/inferred-types.ts +++ b/examples/showcase/codegen/templates/inferred-types.ts @@ -42,7 +42,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { isTphReadNullTolerant } from "./zod-validators.js"; import { sharedEnumForField } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "@metaobjectsdev/codegen-ts/engine"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine"; /** @@ -311,7 +311,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render // Drizzle .$type<> use) so all three agree. Without a ctx (bare unit-test // calls) fall back to the bare name + flat same-dir specifier. const entityPkg = effectivePackage(entity); - const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -330,7 +330,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const entityPkg = effectivePackage(entity); - const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/examples/showcase/codegen/templates/projection-decl.ts b/examples/showcase/codegen/templates/projection-decl.ts index 5d81ec24d..e9ab1e45d 100644 --- a/examples/showcase/codegen/templates/projection-decl.ts +++ b/examples/showcase/codegen/templates/projection-decl.ts @@ -18,7 +18,7 @@ import { import { projectionViewName } from "@metaobjectsdev/codegen-ts/engine"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "@metaobjectsdev/codegen-ts/engine"; import type { ColumnNamingStrategy } from "@metaobjectsdev/codegen-ts/engine"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import type { Dialect } from "@metaobjectsdev/codegen-ts/engine"; import { physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; @@ -113,7 +113,7 @@ export function renderProjectionDecl( const ref = field.attr(FIELD_ATTR_OBJECT_REF); const rawRef = typeof ref === "string" ? ref : ""; const name = ctx - ? ctx.resolveValueObjectName(rawRef, fieldDeclaringPackage(field, projectionPkg)) + ? ctx.resolveValueObjectName(field) : stripPackage(rawRef); const module = ctx ? valueObjectModuleSpecifier(name, ctx.packageOf, projectionPkg, ctx.outputLayout, ctx.extStyle) diff --git a/examples/showcase/codegen/templates/queries.ts b/examples/showcase/codegen/templates/queries.ts index 485daf65e..95f427fd6 100644 --- a/examples/showcase/codegen/templates/queries.ts +++ b/examples/showcase/codegen/templates/queries.ts @@ -4,10 +4,13 @@ import { code, imp, type Code } from "@metaobjectsdev/codegen-ts/engine"; import { type MetaObject, + IDENTITY_REFERENCE_ATTR_REFERENCES, + isMetaObject, + refTarget, stripPackage, } from "@metaobjectsdev/metadata"; import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; -import { getPkInfo, subTypeToTsType } from "@metaobjectsdev/codegen-ts/engine"; +import { getPkInfo, pkInfoOf, subTypeToTsType } from "@metaobjectsdev/codegen-ts/engine"; import { supportsReturning } from "@metaobjectsdev/codegen-ts/engine"; import { findByIdFnName, @@ -218,8 +221,10 @@ export async function ${fnName}(db: Db, ${pkField}: ${pkType}): Promise interface ReverseFk { /** FK FIELD name on this entity (logical), e.g. `currentSceneId`. */ fkField: string; - /** Target entity (the `T` referenced), e.g. `Scene`. Drives the value TS type. */ + /** Target entity name (the `T` referenced), e.g. `Scene`. An identifier, never a lookup key. */ targetEntity: string; + /** The entity `@references` binds to (ADR-0059 R7). Its PK type drives the value TS type. */ + target?: MetaObject | undefined; } /** Collect this entity's OWN reverse FK targets, in declaration order. */ @@ -227,9 +232,11 @@ export function reverseFksFor(entity: MetaObject): ReverseFk[] { const out: ReverseFk[] = []; for (const ref of entity.referenceIdentities()) { const fkField = ref.fields[0]; - const target = ref.targetEntity; - if (!fkField || !target) continue; - out.push({ fkField, targetEntity: stripPackage(target) }); + const targetRef = ref.targetEntity; + if (!fkField || !targetRef) continue; + const bound = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); + const target = isMetaObject(bound) ? bound : undefined; + out.push({ fkField, targetEntity: target?.name ?? stripPackage(targetRef), target }); } return out; } @@ -244,7 +251,7 @@ export function renderReverseFinderFns(entity: MetaObject, fk: ReverseFk, ctx: R // name is the argument to integer()/text()), so column access uses fk.fkField. // The FK value type is the target entity's PK type (the FK references it). Fall // back to the FK field's own subType, then to number (long-shaped key default). - const targetPk = ctx.pkMap.get(fk.targetEntity); + const targetPk = fk.target === undefined ? undefined : pkInfoOf(fk.target); const ownField = entity.findField(fk.fkField); const valueType = subTypeToTsType(targetPk?.fieldSubType ?? ownField?.subType ?? "long"); diff --git a/examples/showcase/codegen/templates/relations-block.ts b/examples/showcase/codegen/templates/relations-block.ts index f345c27a0..5b1cbbc64 100644 --- a/examples/showcase/codegen/templates/relations-block.ts +++ b/examples/showcase/codegen/templates/relations-block.ts @@ -8,7 +8,8 @@ import { type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { crossEntitySpecifier } from "@metaobjectsdev/codegen-ts/engine"; import type { RelationEntry } from "@metaobjectsdev/codegen-ts/engine"; import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine"; -import { tphStorageName } from "@metaobjectsdev/codegen-ts/engine"; +import { tphStorageObject } from "@metaobjectsdev/codegen-ts/engine"; +import { pkInfoOf } from "@metaobjectsdev/codegen-ts/engine"; /** * Render the relations() block for one entity. @@ -32,7 +33,7 @@ export function renderRelationsBlock(entity: MetaObject, ctx: RenderContext): Co const thisEntityPackage = effectivePackage(entity); const lines: Code[] = entries.map((entry) => - renderRelationEntry(entry, ctx, entity.name, varName, thisEntityPackage), + renderRelationEntry(entry, ctx, entity, varName, thisEntityPackage), ); return code`export const ${relationsVarName} = ${relationsFn}(${varName}, (${params}) => ({ @@ -44,7 +45,7 @@ ${joinCode(lines, { on: ",\n", trim: false })} function renderRelationEntry( entry: RelationEntry, ctx: RenderContext, - thisEntityName: string, + thisEntity: MetaObject, thisVarName: string, thisEntityPackage: string | undefined, ): Code { @@ -53,11 +54,11 @@ function renderRelationEntry( // then hops junction → target via the junction's one() sides). The // mountM2mRoute the routes generator emits performs the flattened two-stage // traversal for the REST contract. - if (entry.cardinality === CARDINALITY_MANY && entry.junctionEntity !== undefined) { + if (entry.cardinality === CARDINALITY_MANY && entry.junctionEntity !== undefined && entry.junction !== undefined) { const junctionSpec = crossEntitySpecifier( ctx.outputLayout, thisEntityPackage, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, ); @@ -67,22 +68,23 @@ function renderRelationEntry( // Bind to the table that stores the target's rows: a TPH subtype's module has no // table const, so a navigation onto `Carrier` binds `parties` from `Party`. - const targetTableEntity = tphStorageName(entry.targetEntity, ctx.loadedRoot); + const targetTable = tphStorageObject(entry.target); + const targetTableEntity = targetTable.name; // A navigation onto this entity's OWN table (`Order.parent`, or a TPH base onto one of // its subtypes) names the local const: importing it from this very module clashes with // its declaration (TS2440). Otherwise imp() tracks and emits the cross-entity import. - const targetVarSym = targetTableEntity === thisEntityName + const targetVarSym = targetTable === thisEntity ? thisVarName : imp(`${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, thisEntityPackage, - ctx.packageOf.get(targetTableEntity), + effectivePackage(targetTable), targetTableEntity, ctx.extStyle, )}`); if (entry.cardinality === CARDINALITY_ONE) { - const pkInfo = ctx.pkMap.get(targetTableEntity); + const pkInfo = pkInfoOf(targetTable); const targetPkField = pkInfo?.fieldName ?? "id"; return code` ${entry.name}: one(${targetVarSym}, { fields: [${thisVarName}.${entry.fkField ?? "id"}], references: [${targetVarSym}.${targetPkField}] })`; } diff --git a/examples/showcase/codegen/templates/zod-validators.ts b/examples/showcase/codegen/templates/zod-validators.ts index 2b5ce2908..113186b89 100644 --- a/examples/showcase/codegen/templates/zod-validators.ts +++ b/examples/showcase/codegen/templates/zod-validators.ts @@ -46,7 +46,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { sharedEnumForField } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumImportSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumZodConstName } from "./enums-file.js"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; // FR-035: the SAME required-predicate that drives the Drizzle column's .notNull() // drives the UpdateSchema's .nullable() exclusion — shared so they cannot drift. @@ -626,7 +626,7 @@ function zodFieldExpr( // Drizzle .$type<> use) so all three agree. Without owner/ctx (bare // unit-test calls) fall back to the bare name + flat same-dir. const ownerPkg = (ctx && owner) ? effectivePackage(owner) : undefined; - const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, ownerPkg)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, ownerPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -648,7 +648,7 @@ function zodFieldExpr( const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const ownerPkg = (ctx && owner) ? effectivePackage(owner) : undefined; - const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, ownerPkg)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, ownerPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index c315df099..d827f32f4 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.1" }, "counts": { - "fixtures": 384, + "fixtures": 395, "corpora": 29, "baseTypes": 17 }, diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md index 6de9b0113..04d71a042 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md @@ -35,7 +35,11 @@ Resolution facts: extends Auditable`) — each `extends` is a super-*reference*, so resolution walks the full chain (it does not flatten inherited members onto the child; see ADR-0039). - **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`); - same-package refs use the bare name. + same-package refs use the bare name. A bare name looks in the declaring file's own + package, then at the root, and never binds an object in another package. `::Name` + names a root-level object even when the package has one of the same name; `..::Name` + names one in the parent package. The forms mean the same in YAML and JSON (ADR-0059), + and a loaded or exported model carries every reference fully qualified. - An unresolved reference fails with `ERR_UNRESOLVED_SUPER`. `abstract` and `extends` are **structural keys** (bare, no `@`). diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index 2cae76911..f67f1978a 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -160,21 +160,19 @@ You run a `gen` step. The runner: 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). -4. Decides whether it may overwrite a file — and **the rule differs by port**: - - **TypeScript, C#, Python** — by a committed **hash manifest** - (`.metaobjects/.gen-state/.hashes.json`). If a file still hashes to what the - generator recorded writing, it is safe to overwrite; if it was edited, or there is - no record of it, the write is **refused by name**. TypeScript additionally - three-way-merges against a snapshot when one is present locally. - - **Java, Kotlin** — by a bare **`GENERATED`** token in the file's header comment - (`GeneratedFileWriter.GENERATED_MARKER`). The token is `GENERATED`, **not** - `@generated`: the matcher allows only whitespace between the comment punctuation - and the token, so an `@`-prefixed tag does not match it. Remove that token and - regeneration never touches the file again. - - **A refusal FAILS the run — exit 1 on every port** (TypeScript always did; Python and - C# warned and exited 0 until 1.0, so a `gen` wired into CI was green while codegen - refused to write). The one-time fix for a project that predates the committed manifest +4. Decides whether it may overwrite a file, by a committed **hash manifest** + (`.metaobjects/.gen-state/.hashes.json`) on every port. If a file still hashes to what + the generator recorded writing, it is safe to overwrite; if it was edited, or there is + no record of it, the write is **refused by name**. TypeScript additionally + three-way-merges against a snapshot when one is present locally. **Commit the + manifest.** Java and Kotlin have used it since 8.1.0: there, a file with no record that + carries the bare **`GENERATED`** header token (not `@generated`) is adopted as generated + output, which is how the first run after upgrading from 8.0.x records everything. + + **A refusal FAILS the run — exit 1 on TypeScript, C# and Python** (Python and C# warned + and exited 0 until 1.0, so a `gen` wired into CI was green while codegen refused to + write). On Java and Kotlin a refusal is a warning and `mvn metaobjects:verify` is the + gate that fails. The one-time fix for a project that predates the committed manifest is **`gen --baseline=adopt`**: it records the files you have as the baseline and writes nothing, so there is finally a `.hashes.json` to commit — then `gen` again, and the regeneration lands as its own diff. Adopting DECLARES those files to be generated @@ -182,16 +180,28 @@ You run a `gen` step. The runner: replaces it; commit before you run it. (TypeScript additionally has `--baseline=fresh`: write fresh output now and discard the edits.) - **The two rules protect a hand edit in opposite ways, so do not carry a habit across - ports.** On TypeScript, C# and Python, *editing the content* is what takes ownership — - that is what breaks the hash — and deleting the header changes nothing except your - ability to tell what generated the file. On Java and Kotlin the reverse holds: editing - a file protects it not at all, because the `GENERATED` token is still there and the - next run overwrites the edit; only removing that token does. - - (The header text differs per port and none of it is the write decision on the - hash-manifest ports: TypeScript and Python emit `@generated by …`, C# emits - ``, Java and Kotlin emit the `GENERATED` token above.) + **Output nobody should edit: set `handEdits: overwrite` on its generator entry.** The + default, `keep`, refuses an edited file. `overwrite` replaces it with fresh output, names + it, and re-baselines it; `verify --codegen` then reports the edit as drift that `gen` + fixes. A file the manifest has no record of is refused either way. TypeScript + `{ ...entityFile(), handEdits: "overwrite" }`; Python `{ name: entity, handEdits: overwrite }` + in `metaobjects.config.yaml`; C# `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)`; + Java and Kotlin `overwrite` on the ``. + + **A full `gen` deletes generated files it no longer emits** (a renamed or deleted object, + a page that moved package). Only files the manifest records, only when untouched, and + never on a run narrowed by `--generators` or an entity filter. An edited one is refused by + name, and `verify --codegen` says to delete it: regeneration cannot follow a rename, so + move the edits into the new file first if you want them. Details: + `docs/features/own-your-codegen.md`, "Files that are no longer generated". + + On every port *editing the content* is what takes ownership — that is what breaks the + hash — and deleting the header is just another edit. (Through Maven 8.0.x, Java and + Kotlin did the reverse: an edit was overwritten and only removing the `GENERATED` token + protected a file. Do not carry that habit forward.) + + (The header text differs per port and is not the write decision: TypeScript and Python + emit `@generated by …`, C# emits ``, Java and Kotlin emit `GENERATED`.) The output is normal idiomatic code in your language — you import it and use it like any hand-written module. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md index 6de9b0113..04d71a042 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md @@ -35,7 +35,11 @@ Resolution facts: extends Auditable`) — each `extends` is a super-*reference*, so resolution walks the full chain (it does not flatten inherited members onto the child; see ADR-0039). - **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`); - same-package refs use the bare name. + same-package refs use the bare name. A bare name looks in the declaring file's own + package, then at the root, and never binds an object in another package. `::Name` + names a root-level object even when the package has one of the same name; `..::Name` + names one in the parent package. The forms mean the same in YAML and JSON (ADR-0059), + and a loaded or exported model carries every reference fully qualified. - An unresolved reference fails with `ERR_UNRESOLVED_SUPER`. `abstract` and `extends` are **structural keys** (bare, no `@`). diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md index 2cae76911..f67f1978a 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -160,21 +160,19 @@ You run a `gen` step. The runner: 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). -4. Decides whether it may overwrite a file — and **the rule differs by port**: - - **TypeScript, C#, Python** — by a committed **hash manifest** - (`.metaobjects/.gen-state/.hashes.json`). If a file still hashes to what the - generator recorded writing, it is safe to overwrite; if it was edited, or there is - no record of it, the write is **refused by name**. TypeScript additionally - three-way-merges against a snapshot when one is present locally. - - **Java, Kotlin** — by a bare **`GENERATED`** token in the file's header comment - (`GeneratedFileWriter.GENERATED_MARKER`). The token is `GENERATED`, **not** - `@generated`: the matcher allows only whitespace between the comment punctuation - and the token, so an `@`-prefixed tag does not match it. Remove that token and - regeneration never touches the file again. - - **A refusal FAILS the run — exit 1 on every port** (TypeScript always did; Python and - C# warned and exited 0 until 1.0, so a `gen` wired into CI was green while codegen - refused to write). The one-time fix for a project that predates the committed manifest +4. Decides whether it may overwrite a file, by a committed **hash manifest** + (`.metaobjects/.gen-state/.hashes.json`) on every port. If a file still hashes to what + the generator recorded writing, it is safe to overwrite; if it was edited, or there is + no record of it, the write is **refused by name**. TypeScript additionally + three-way-merges against a snapshot when one is present locally. **Commit the + manifest.** Java and Kotlin have used it since 8.1.0: there, a file with no record that + carries the bare **`GENERATED`** header token (not `@generated`) is adopted as generated + output, which is how the first run after upgrading from 8.0.x records everything. + + **A refusal FAILS the run — exit 1 on TypeScript, C# and Python** (Python and C# warned + and exited 0 until 1.0, so a `gen` wired into CI was green while codegen refused to + write). On Java and Kotlin a refusal is a warning and `mvn metaobjects:verify` is the + gate that fails. The one-time fix for a project that predates the committed manifest is **`gen --baseline=adopt`**: it records the files you have as the baseline and writes nothing, so there is finally a `.hashes.json` to commit — then `gen` again, and the regeneration lands as its own diff. Adopting DECLARES those files to be generated @@ -182,16 +180,28 @@ You run a `gen` step. The runner: replaces it; commit before you run it. (TypeScript additionally has `--baseline=fresh`: write fresh output now and discard the edits.) - **The two rules protect a hand edit in opposite ways, so do not carry a habit across - ports.** On TypeScript, C# and Python, *editing the content* is what takes ownership — - that is what breaks the hash — and deleting the header changes nothing except your - ability to tell what generated the file. On Java and Kotlin the reverse holds: editing - a file protects it not at all, because the `GENERATED` token is still there and the - next run overwrites the edit; only removing that token does. - - (The header text differs per port and none of it is the write decision on the - hash-manifest ports: TypeScript and Python emit `@generated by …`, C# emits - ``, Java and Kotlin emit the `GENERATED` token above.) + **Output nobody should edit: set `handEdits: overwrite` on its generator entry.** The + default, `keep`, refuses an edited file. `overwrite` replaces it with fresh output, names + it, and re-baselines it; `verify --codegen` then reports the edit as drift that `gen` + fixes. A file the manifest has no record of is refused either way. TypeScript + `{ ...entityFile(), handEdits: "overwrite" }`; Python `{ name: entity, handEdits: overwrite }` + in `metaobjects.config.yaml`; C# `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)`; + Java and Kotlin `overwrite` on the ``. + + **A full `gen` deletes generated files it no longer emits** (a renamed or deleted object, + a page that moved package). Only files the manifest records, only when untouched, and + never on a run narrowed by `--generators` or an entity filter. An edited one is refused by + name, and `verify --codegen` says to delete it: regeneration cannot follow a rename, so + move the edits into the new file first if you want them. Details: + `docs/features/own-your-codegen.md`, "Files that are no longer generated". + + On every port *editing the content* is what takes ownership — that is what breaks the + hash — and deleting the header is just another edit. (Through Maven 8.0.x, Java and + Kotlin did the reverse: an edit was overwritten and only removing the `GENERATED` token + protected a file. Do not carry that habit forward.) + + (The header text differs per port and is not the write decision: TypeScript and Python + emit `@generated by …`, C# emits ``, Java and Kotlin emit `GENERATED`.) The output is normal idiomatic code in your language — you import it and use it like any hand-written module. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md index 6de9b0113..04d71a042 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md @@ -35,7 +35,11 @@ Resolution facts: extends Auditable`) — each `extends` is a super-*reference*, so resolution walks the full chain (it does not flatten inherited members onto the child; see ADR-0039). - **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`); - same-package refs use the bare name. + same-package refs use the bare name. A bare name looks in the declaring file's own + package, then at the root, and never binds an object in another package. `::Name` + names a root-level object even when the package has one of the same name; `..::Name` + names one in the parent package. The forms mean the same in YAML and JSON (ADR-0059), + and a loaded or exported model carries every reference fully qualified. - An unresolved reference fails with `ERR_UNRESOLVED_SUPER`. `abstract` and `extends` are **structural keys** (bare, no `@`). diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md index 2cae76911..f67f1978a 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -160,21 +160,19 @@ You run a `gen` step. The runner: 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). -4. Decides whether it may overwrite a file — and **the rule differs by port**: - - **TypeScript, C#, Python** — by a committed **hash manifest** - (`.metaobjects/.gen-state/.hashes.json`). If a file still hashes to what the - generator recorded writing, it is safe to overwrite; if it was edited, or there is - no record of it, the write is **refused by name**. TypeScript additionally - three-way-merges against a snapshot when one is present locally. - - **Java, Kotlin** — by a bare **`GENERATED`** token in the file's header comment - (`GeneratedFileWriter.GENERATED_MARKER`). The token is `GENERATED`, **not** - `@generated`: the matcher allows only whitespace between the comment punctuation - and the token, so an `@`-prefixed tag does not match it. Remove that token and - regeneration never touches the file again. - - **A refusal FAILS the run — exit 1 on every port** (TypeScript always did; Python and - C# warned and exited 0 until 1.0, so a `gen` wired into CI was green while codegen - refused to write). The one-time fix for a project that predates the committed manifest +4. Decides whether it may overwrite a file, by a committed **hash manifest** + (`.metaobjects/.gen-state/.hashes.json`) on every port. If a file still hashes to what + the generator recorded writing, it is safe to overwrite; if it was edited, or there is + no record of it, the write is **refused by name**. TypeScript additionally + three-way-merges against a snapshot when one is present locally. **Commit the + manifest.** Java and Kotlin have used it since 8.1.0: there, a file with no record that + carries the bare **`GENERATED`** header token (not `@generated`) is adopted as generated + output, which is how the first run after upgrading from 8.0.x records everything. + + **A refusal FAILS the run — exit 1 on TypeScript, C# and Python** (Python and C# warned + and exited 0 until 1.0, so a `gen` wired into CI was green while codegen refused to + write). On Java and Kotlin a refusal is a warning and `mvn metaobjects:verify` is the + gate that fails. The one-time fix for a project that predates the committed manifest is **`gen --baseline=adopt`**: it records the files you have as the baseline and writes nothing, so there is finally a `.hashes.json` to commit — then `gen` again, and the regeneration lands as its own diff. Adopting DECLARES those files to be generated @@ -182,16 +180,28 @@ You run a `gen` step. The runner: replaces it; commit before you run it. (TypeScript additionally has `--baseline=fresh`: write fresh output now and discard the edits.) - **The two rules protect a hand edit in opposite ways, so do not carry a habit across - ports.** On TypeScript, C# and Python, *editing the content* is what takes ownership — - that is what breaks the hash — and deleting the header changes nothing except your - ability to tell what generated the file. On Java and Kotlin the reverse holds: editing - a file protects it not at all, because the `GENERATED` token is still there and the - next run overwrites the edit; only removing that token does. - - (The header text differs per port and none of it is the write decision on the - hash-manifest ports: TypeScript and Python emit `@generated by …`, C# emits - ``, Java and Kotlin emit the `GENERATED` token above.) + **Output nobody should edit: set `handEdits: overwrite` on its generator entry.** The + default, `keep`, refuses an edited file. `overwrite` replaces it with fresh output, names + it, and re-baselines it; `verify --codegen` then reports the edit as drift that `gen` + fixes. A file the manifest has no record of is refused either way. TypeScript + `{ ...entityFile(), handEdits: "overwrite" }`; Python `{ name: entity, handEdits: overwrite }` + in `metaobjects.config.yaml`; C# `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)`; + Java and Kotlin `overwrite` on the ``. + + **A full `gen` deletes generated files it no longer emits** (a renamed or deleted object, + a page that moved package). Only files the manifest records, only when untouched, and + never on a run narrowed by `--generators` or an entity filter. An edited one is refused by + name, and `verify --codegen` says to delete it: regeneration cannot follow a rename, so + move the edits into the new file first if you want them. Details: + `docs/features/own-your-codegen.md`, "Files that are no longer generated". + + On every port *editing the content* is what takes ownership — that is what breaks the + hash — and deleting the header is just another edit. (Through Maven 8.0.x, Java and + Kotlin did the reverse: an edit was overwritten and only removing the `GENERATED` token + protected a file. Do not carry that habit forward.) + + (The header text differs per port and is not the write decision: TypeScript and Python + emit `@generated by …`, C# emits ``, Java and Kotlin emit `GENERATED`.) The output is normal idiomatic code in your language — you import it and use it like any hand-written module. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md index 6de9b0113..04d71a042 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md @@ -35,7 +35,11 @@ Resolution facts: extends Auditable`) — each `extends` is a super-*reference*, so resolution walks the full chain (it does not flatten inherited members onto the child; see ADR-0039). - **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`); - same-package refs use the bare name. + same-package refs use the bare name. A bare name looks in the declaring file's own + package, then at the root, and never binds an object in another package. `::Name` + names a root-level object even when the package has one of the same name; `..::Name` + names one in the parent package. The forms mean the same in YAML and JSON (ADR-0059), + and a loaded or exported model carries every reference fully qualified. - An unresolved reference fails with `ERR_UNRESOLVED_SUPER`. `abstract` and `extends` are **structural keys** (bare, no `@`). diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index 2cae76911..f67f1978a 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -160,21 +160,19 @@ You run a `gen` step. The runner: 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). -4. Decides whether it may overwrite a file — and **the rule differs by port**: - - **TypeScript, C#, Python** — by a committed **hash manifest** - (`.metaobjects/.gen-state/.hashes.json`). If a file still hashes to what the - generator recorded writing, it is safe to overwrite; if it was edited, or there is - no record of it, the write is **refused by name**. TypeScript additionally - three-way-merges against a snapshot when one is present locally. - - **Java, Kotlin** — by a bare **`GENERATED`** token in the file's header comment - (`GeneratedFileWriter.GENERATED_MARKER`). The token is `GENERATED`, **not** - `@generated`: the matcher allows only whitespace between the comment punctuation - and the token, so an `@`-prefixed tag does not match it. Remove that token and - regeneration never touches the file again. - - **A refusal FAILS the run — exit 1 on every port** (TypeScript always did; Python and - C# warned and exited 0 until 1.0, so a `gen` wired into CI was green while codegen - refused to write). The one-time fix for a project that predates the committed manifest +4. Decides whether it may overwrite a file, by a committed **hash manifest** + (`.metaobjects/.gen-state/.hashes.json`) on every port. If a file still hashes to what + the generator recorded writing, it is safe to overwrite; if it was edited, or there is + no record of it, the write is **refused by name**. TypeScript additionally + three-way-merges against a snapshot when one is present locally. **Commit the + manifest.** Java and Kotlin have used it since 8.1.0: there, a file with no record that + carries the bare **`GENERATED`** header token (not `@generated`) is adopted as generated + output, which is how the first run after upgrading from 8.0.x records everything. + + **A refusal FAILS the run — exit 1 on TypeScript, C# and Python** (Python and C# warned + and exited 0 until 1.0, so a `gen` wired into CI was green while codegen refused to + write). On Java and Kotlin a refusal is a warning and `mvn metaobjects:verify` is the + gate that fails. The one-time fix for a project that predates the committed manifest is **`gen --baseline=adopt`**: it records the files you have as the baseline and writes nothing, so there is finally a `.hashes.json` to commit — then `gen` again, and the regeneration lands as its own diff. Adopting DECLARES those files to be generated @@ -182,16 +180,28 @@ You run a `gen` step. The runner: replaces it; commit before you run it. (TypeScript additionally has `--baseline=fresh`: write fresh output now and discard the edits.) - **The two rules protect a hand edit in opposite ways, so do not carry a habit across - ports.** On TypeScript, C# and Python, *editing the content* is what takes ownership — - that is what breaks the hash — and deleting the header changes nothing except your - ability to tell what generated the file. On Java and Kotlin the reverse holds: editing - a file protects it not at all, because the `GENERATED` token is still there and the - next run overwrites the edit; only removing that token does. - - (The header text differs per port and none of it is the write decision on the - hash-manifest ports: TypeScript and Python emit `@generated by …`, C# emits - ``, Java and Kotlin emit the `GENERATED` token above.) + **Output nobody should edit: set `handEdits: overwrite` on its generator entry.** The + default, `keep`, refuses an edited file. `overwrite` replaces it with fresh output, names + it, and re-baselines it; `verify --codegen` then reports the edit as drift that `gen` + fixes. A file the manifest has no record of is refused either way. TypeScript + `{ ...entityFile(), handEdits: "overwrite" }`; Python `{ name: entity, handEdits: overwrite }` + in `metaobjects.config.yaml`; C# `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)`; + Java and Kotlin `overwrite` on the ``. + + **A full `gen` deletes generated files it no longer emits** (a renamed or deleted object, + a page that moved package). Only files the manifest records, only when untouched, and + never on a run narrowed by `--generators` or an entity filter. An edited one is refused by + name, and `verify --codegen` says to delete it: regeneration cannot follow a rename, so + move the edits into the new file first if you want them. Details: + `docs/features/own-your-codegen.md`, "Files that are no longer generated". + + On every port *editing the content* is what takes ownership — that is what breaks the + hash — and deleting the header is just another edit. (Through Maven 8.0.x, Java and + Kotlin did the reverse: an edit was overwritten and only removing the `GENERATED` token + protected a file. Do not carry that habit forward.) + + (The header text differs per port and is not the write decision: TypeScript and Python + emit `@generated by …`, C# emits ``, Java and Kotlin emit `GENERATED`.) The output is normal idiomatic code in your language — you import it and use it like any hand-written module. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md index 6de9b0113..04d71a042 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/abstracts-and-overlay.md @@ -35,7 +35,11 @@ Resolution facts: extends Auditable`) — each `extends` is a super-*reference*, so resolution walks the full chain (it does not flatten inherited members onto the child; see ADR-0039). - **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`); - same-package refs use the bare name. + same-package refs use the bare name. A bare name looks in the declaring file's own + package, then at the root, and never binds an object in another package. `::Name` + names a root-level object even when the package has one of the same name; `..::Name` + names one in the parent package. The forms mean the same in YAML and JSON (ADR-0059), + and a loaded or exported model carries every reference fully qualified. - An unresolved reference fails with `ERR_UNRESOLVED_SUPER`. `abstract` and `extends` are **structural keys** (bare, no `@`). diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md index 2cae76911..f67f1978a 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -160,21 +160,19 @@ You run a `gen` step. The runner: 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). -4. Decides whether it may overwrite a file — and **the rule differs by port**: - - **TypeScript, C#, Python** — by a committed **hash manifest** - (`.metaobjects/.gen-state/.hashes.json`). If a file still hashes to what the - generator recorded writing, it is safe to overwrite; if it was edited, or there is - no record of it, the write is **refused by name**. TypeScript additionally - three-way-merges against a snapshot when one is present locally. - - **Java, Kotlin** — by a bare **`GENERATED`** token in the file's header comment - (`GeneratedFileWriter.GENERATED_MARKER`). The token is `GENERATED`, **not** - `@generated`: the matcher allows only whitespace between the comment punctuation - and the token, so an `@`-prefixed tag does not match it. Remove that token and - regeneration never touches the file again. - - **A refusal FAILS the run — exit 1 on every port** (TypeScript always did; Python and - C# warned and exited 0 until 1.0, so a `gen` wired into CI was green while codegen - refused to write). The one-time fix for a project that predates the committed manifest +4. Decides whether it may overwrite a file, by a committed **hash manifest** + (`.metaobjects/.gen-state/.hashes.json`) on every port. If a file still hashes to what + the generator recorded writing, it is safe to overwrite; if it was edited, or there is + no record of it, the write is **refused by name**. TypeScript additionally + three-way-merges against a snapshot when one is present locally. **Commit the + manifest.** Java and Kotlin have used it since 8.1.0: there, a file with no record that + carries the bare **`GENERATED`** header token (not `@generated`) is adopted as generated + output, which is how the first run after upgrading from 8.0.x records everything. + + **A refusal FAILS the run — exit 1 on TypeScript, C# and Python** (Python and C# warned + and exited 0 until 1.0, so a `gen` wired into CI was green while codegen refused to + write). On Java and Kotlin a refusal is a warning and `mvn metaobjects:verify` is the + gate that fails. The one-time fix for a project that predates the committed manifest is **`gen --baseline=adopt`**: it records the files you have as the baseline and writes nothing, so there is finally a `.hashes.json` to commit — then `gen` again, and the regeneration lands as its own diff. Adopting DECLARES those files to be generated @@ -182,16 +180,28 @@ You run a `gen` step. The runner: replaces it; commit before you run it. (TypeScript additionally has `--baseline=fresh`: write fresh output now and discard the edits.) - **The two rules protect a hand edit in opposite ways, so do not carry a habit across - ports.** On TypeScript, C# and Python, *editing the content* is what takes ownership — - that is what breaks the hash — and deleting the header changes nothing except your - ability to tell what generated the file. On Java and Kotlin the reverse holds: editing - a file protects it not at all, because the `GENERATED` token is still there and the - next run overwrites the edit; only removing that token does. - - (The header text differs per port and none of it is the write decision on the - hash-manifest ports: TypeScript and Python emit `@generated by …`, C# emits - ``, Java and Kotlin emit the `GENERATED` token above.) + **Output nobody should edit: set `handEdits: overwrite` on its generator entry.** The + default, `keep`, refuses an edited file. `overwrite` replaces it with fresh output, names + it, and re-baselines it; `verify --codegen` then reports the edit as drift that `gen` + fixes. A file the manifest has no record of is refused either way. TypeScript + `{ ...entityFile(), handEdits: "overwrite" }`; Python `{ name: entity, handEdits: overwrite }` + in `metaobjects.config.yaml`; C# `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)`; + Java and Kotlin `overwrite` on the ``. + + **A full `gen` deletes generated files it no longer emits** (a renamed or deleted object, + a page that moved package). Only files the manifest records, only when untouched, and + never on a run narrowed by `--generators` or an entity filter. An edited one is refused by + name, and `verify --codegen` says to delete it: regeneration cannot follow a rename, so + move the edits into the new file first if you want them. Details: + `docs/features/own-your-codegen.md`, "Files that are no longer generated". + + On every port *editing the content* is what takes ownership — that is what breaks the + hash — and deleting the header is just another edit. (Through Maven 8.0.x, Java and + Kotlin did the reverse: an edit was overwritten and only removing the `GENERATED` token + protected a file. Do not carry that habit forward.) + + (The header text differs per port and is not the write decision: TypeScript and Python + emit `@generated by …`, C# emits ``, Java and Kotlin emit `GENERATED`.) The output is normal idiomatic code in your language — you import it and use it like any hand-written module. diff --git a/fixtures/codegen-conformance/README.md b/fixtures/codegen-conformance/README.md index b86d565f5..2325ef5bb 100644 --- a/fixtures/codegen-conformance/README.md +++ b/fixtures/codegen-conformance/README.md @@ -50,6 +50,16 @@ It meets the bar this README sets. The incident: a two-hop enum field (`A.x exte the shared type in the others, and every behavior corpus stayed green, because both shapes store and validate the same members. The rule it gates is ADR-0026 Amendment 1. +## `cross-package-binding/` is a shared input with a decision table, too + +`cross-package-binding/` holds one metadata input and `expected-bindings.json`, a port-neutral +DECISION TABLE: for every relationship and `identity.reference`, the object it binds to and +the tables and join fields generated code uses. It records a RESOLUTION, never generated +bytes. The incident: codegen in every port bound a reference by short name or re-resolved an +inherited bare reference in the inheritor's package, so the first same-named object loaded +won, while every behavior corpus stayed green because none declares two same-named objects. +The rule it gates is ADR-0059 Decision 7. + ## Do not delete this README / do not re-propose FR-007 Keep this file so the gap-that-isn't stays documented. If you believe a codegen corpus is warranted, diff --git a/fixtures/codegen-conformance/cross-package-binding/README.md b/fixtures/codegen-conformance/cross-package-binding/README.md new file mode 100644 index 000000000..877b475c3 --- /dev/null +++ b/fixtures/codegen-conformance/cross-package-binding/README.md @@ -0,0 +1,28 @@ +# `cross-package-binding/` — codegen binds the target a reference names + +One metadata input every port's codegen test loads, and `expected-bindings.json`, a +port-neutral DECISION TABLE: for every relationship and `identity.reference` in the input, +the object it binds to (by resolution key) and, for a relationship, the physical tables and +join fields the generated code must use. Like `shared-provided-enum/`, it records a +RESOLUTION, never generated bytes, so each port keeps its own idiom. + +The rule it gates is ADR-0059 Decision 7: a consumer without a referrer reads the bound +target or resolves the canonical value exactly, never by short name, and never re-applies +the package of the node that inherits an attribute. + +Two traps, each built so a short-name lookup and an inheritor-package lookup both bind the +wrong object, because the wrong object loads first (`meta.a.json` and `meta.acme.json` sort +before `meta.b.json` and `meta.root.json`). Each decoy is an `object.value`: two ENTITIES that +pluralize to the same collection name are refused by codegen (`ERR_COLLECTION_NAME_COLLISION`), +and a value object gets no collection, so the whole model still generates. + +| Relationship | Names | Wrong binding a defect produces | +|---|---|---| +| `a::Post.tags` (M:N via `a::PostTag`) | `b::Tag` (table `b_tags`) | the value object `a::Tag`, the first `Tag` loaded | +| `a::Post.featuredTag` (to-one) | `b::Tag` | `a::Tag` | +| `acme::Order.customer` (to-one, inherited from root `BaseOrder`) | root `Customer` (table `customers`) | the value object `acme::Customer`, by short name or by resolving the inherited bare `Customer` in `acme` | +| `acme::Order.labels` (M:N via root `OrderLabel`, inherited) | root `Label` (table `labels`) | the value object `acme::Label` | + +The root-level `BaseOrder` writes `Customer` and `Label` bare. After `resolveReferences` +they stay bare, which means the root-level object (ADR-0059 Decision 6), and they keep +meaning that when `acme::Order` inherits them. diff --git a/fixtures/codegen-conformance/cross-package-binding/expected-bindings.json b/fixtures/codegen-conformance/cross-package-binding/expected-bindings.json new file mode 100644 index 000000000..fe85ba7fb --- /dev/null +++ b/fixtures/codegen-conformance/cross-package-binding/expected-bindings.json @@ -0,0 +1,42 @@ +{ + "relations": { + "a::Post.tags": { + "cardinality": "many", + "target": "b::Tag", + "targetTable": "b_tags", + "junction": "a::PostTag", + "junctionTable": "post_tags", + "sourceJoinField": "postId", + "targetJoinField": "tagId" + }, + "a::Post.featuredTag": { + "cardinality": "one", + "target": "b::Tag", + "targetTable": "b_tags", + "fkField": "featuredTagId" + }, + "acme::Order.customer": { + "cardinality": "one", + "target": "Customer", + "targetTable": "customers", + "fkField": "customerId" + }, + "acme::Order.labels": { + "cardinality": "many", + "target": "Label", + "targetTable": "labels", + "junction": "OrderLabel", + "junctionTable": "order_labels", + "sourceJoinField": "orderId", + "targetJoinField": "labelId" + } + }, + "references": { + "a::Post.featuredTagRef": "b::Tag", + "a::PostTag.postRef": "a::Post", + "a::PostTag.tagRef": "b::Tag", + "acme::Order.customerRef": "Customer", + "OrderLabel.orderRef": "BaseOrder", + "OrderLabel.labelRef": "Label" + } +} diff --git a/fixtures/codegen-conformance/cross-package-binding/input/meta.a.json b/fixtures/codegen-conformance/cross-package-binding/input/meta.a.json new file mode 100644 index 000000000..728019040 --- /dev/null +++ b/fixtures/codegen-conformance/cross-package-binding/input/meta.a.json @@ -0,0 +1,126 @@ +{ + "metadata.root": { + "package": "a", + "children": [ + { + "object.value": { + "name": "Tag", + "children": [ + { + "field.string": { + "name": "code", + "@required": true, + "@maxLength": 40 + } + } + ] + } + }, + { + "object.entity": { + "name": "Post", + "children": [ + { + "source.rdb": { + "@table": "posts" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "title", + "@required": true, + "@maxLength": 200 + } + }, + { + "field.long": { + "name": "featuredTagId" + } + }, + { + "relationship.association": { + "name": "tags", + "@cardinality": "many", + "@objectRef": "b::Tag", + "@through": "PostTag" + } + }, + { + "relationship.association": { + "name": "featuredTag", + "@cardinality": "one", + "@objectRef": "b::Tag" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id", + "@generation": "increment" + } + }, + { + "identity.reference": { + "name": "featuredTagRef", + "@fields": "featuredTagId", + "@references": "b::Tag" + } + } + ] + } + }, + { + "object.entity": { + "name": "PostTag", + "children": [ + { + "source.rdb": { + "@table": "post_tags" + } + }, + { + "field.long": { + "name": "postId", + "@required": true + } + }, + { + "field.long": { + "name": "tagId", + "@required": true + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "postId", + "tagId" + ] + } + }, + { + "identity.reference": { + "name": "postRef", + "@fields": "postId", + "@references": "Post" + } + }, + { + "identity.reference": { + "name": "tagRef", + "@fields": "tagId", + "@references": "b::Tag" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/codegen-conformance/cross-package-binding/input/meta.acme.json b/fixtures/codegen-conformance/cross-package-binding/input/meta.acme.json new file mode 100644 index 000000000..8083bdbb6 --- /dev/null +++ b/fixtures/codegen-conformance/cross-package-binding/input/meta.acme.json @@ -0,0 +1,48 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.value": { + "name": "Customer", + "children": [ + { + "field.string": { + "name": "code", + "@required": true, + "@maxLength": 40 + } + } + ] + } + }, + { + "object.value": { + "name": "Label", + "children": [ + { + "field.string": { + "name": "code", + "@required": true, + "@maxLength": 40 + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "extends": "::BaseOrder", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/codegen-conformance/cross-package-binding/input/meta.b.json b/fixtures/codegen-conformance/cross-package-binding/input/meta.b.json new file mode 100644 index 000000000..d82b70b30 --- /dev/null +++ b/fixtures/codegen-conformance/cross-package-binding/input/meta.b.json @@ -0,0 +1,38 @@ +{ + "metadata.root": { + "package": "b", + "children": [ + { + "object.entity": { + "name": "Tag", + "children": [ + { + "source.rdb": { + "@table": "b_tags" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "name", + "@required": true, + "@maxLength": 80 + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id", + "@generation": "increment" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/codegen-conformance/cross-package-binding/input/meta.root.json b/fixtures/codegen-conformance/cross-package-binding/input/meta.root.json new file mode 100644 index 000000000..99f57ee97 --- /dev/null +++ b/fixtures/codegen-conformance/cross-package-binding/input/meta.root.json @@ -0,0 +1,162 @@ +{ + "metadata.root": { + "children": [ + { + "object.entity": { + "name": "Customer", + "children": [ + { + "source.rdb": { + "@table": "customers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "name", + "@required": true, + "@maxLength": 80 + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id", + "@generation": "increment" + } + } + ] + } + }, + { + "object.entity": { + "name": "Label", + "children": [ + { + "source.rdb": { + "@table": "labels" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "name", + "@required": true, + "@maxLength": 80 + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id", + "@generation": "increment" + } + } + ] + } + }, + { + "object.entity": { + "name": "BaseOrder", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "customerId" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id", + "@generation": "increment" + } + }, + { + "identity.reference": { + "name": "customerRef", + "@fields": "customerId", + "@references": "Customer" + } + }, + { + "relationship.association": { + "name": "customer", + "@cardinality": "one", + "@objectRef": "Customer" + } + }, + { + "relationship.association": { + "name": "labels", + "@cardinality": "many", + "@objectRef": "Label", + "@through": "OrderLabel" + } + } + ] + } + }, + { + "object.entity": { + "name": "OrderLabel", + "children": [ + { + "source.rdb": { + "@table": "order_labels" + } + }, + { + "field.long": { + "name": "orderId", + "@required": true + } + }, + { + "field.long": { + "name": "labelId", + "@required": true + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "orderId", + "labelId" + ] + } + }, + { + "identity.reference": { + "name": "orderRef", + "@fields": "orderId", + "@references": "BaseOrder" + } + }, + { + "identity.reference": { + "name": "labelRef", + "@fields": "labelId", + "@references": "Label" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index ea9b94e89..a679de1ef 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -79,7 +79,6 @@ "ERR_REGISTRY_SEALED": "ADR-0023: a registration (register / registerType / extend / registerCommonAttrs / setDefaultSubType / addConstraint / registerProviders) was attempted against a registry sealed after its agreed metamodel-provider bootstrap. Made-up metamodel attributes/types are structurally disallowed in the library: codegen cannot register post-bootstrap. A new metamodel attribute requires a registered provider + human agreement. Downstream apps that need extra vocabulary compose their own (unsealed) registry.", "ERR_UNKNOWN": "An internal loader error with no stable error code.", "ERR_ENTITY_PRIMARY_SOURCE_READONLY": "FR-024 (ADR-0028) hard cutover: an object.entity's PRIMARY source has a read-only @kind (view/materializedView/storedProc/tableFunction). Read-only kinds are legal only in non-primary roles; a derived read model is an object.projection.", - "ERR_RELATIVE_REF_IN_CANONICAL": "FR-032 (ADR-0032): a ref-bearing attribute (extends/@objectRef/@references/origin @from/@of/@via/@parameterRef/@payloadRef/@responseRef) in CANONICAL JSON used a relative authoring form (leading :: or ..::). Canonical JSON is the self-contained interchange form — every reference MUST be fully-qualified. Relative navigation (root-absolute ::, parent-relative ..::, bare-current-package) is a YAML-authoring affordance the desugar expands to FQN; it must never survive into canonical JSON.", "ERR_INVALID_METAMODEL_CONSTRAINT": "FR-033: a provider set's merged metamodel constraint graph contains a contradiction surfaced by validateConstraints — one of: (1) a parents/children rule references an unregistered type.subType (dangling); (2) a required child (min>=1) whose type.subType is not admitted under that parent (unsatisfiable); (3) bad cardinality (min>max, max:0 with min>=1, or min<0); (4) closed-set clash — a child declares a parent whose OWN children is a closed set (no * wildcard) that does not admit it; (5) a required-child cycle that cannot bottom out; (6) the same attr name contributed twice (across providers or the extends chain) with a conflicting valueType/required/default. The detail names which check and the offending type(s).", "ERR_INVALID_INDEX": "An index.lookup or identity.secondary declares neither @fields nor @expr, declares BOTH (they are the two mutually exclusive ways to key an index — @expr is used INSTEAD of @fields), or names a field that does not exist on the owning entity's effective (resolved, via extends) field set.", "ERR_INVALID_DIMENSION": "FR-044: a dimension.* is invalid: @of is not Entity.field on the owning entity (or, with @via, on the @via terminal) or names no field (D1); @via does not start at the owning entity, names an unknown hop, or crosses a hop that is not @cardinality: one or an identity.reference (D2); a dimension.time's @of is not a field.date or field.timestamp (D3); or a dimension.time over a field.date declares the 'hour' grain (D4).", diff --git a/fixtures/conformance/ai-llm-call-extends/expected.json b/fixtures/conformance/ai-llm-call-extends/expected.json index 70e4dd588..6a74404f8 100644 --- a/fixtures/conformance/ai-llm-call-extends/expected.json +++ b/fixtures/conformance/ai-llm-call-extends/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "metaobjects::ai", "children": [ { "object.entity": { "name": "LlmCallBase", + "package": "metaobjects::ai", "abstract": true, "children": [ { @@ -51,6 +51,7 @@ { "object.entity": { "name": "LlmCall", + "package": "metaobjects::ai", "extends": "metaobjects::ai::LlmCallBase", "children": [ { @@ -73,6 +74,7 @@ { "object.entity": { "name": "ApiCall", + "package": "app::ops", "extends": "metaobjects::ai::LlmCallBase", "children": [ { diff --git a/fixtures/conformance/collect-whole-object/expected.json b/fixtures/conformance/collect-whole-object/expected.json index 76cedec37..f3ea1b1b3 100644 --- a/fixtures/conformance/collect-whole-object/expected.json +++ b/fixtures/conformance/collect-whole-object/expected.json @@ -41,7 +41,7 @@ "@fields": [ "productId" ], - "@references": "Product" + "@references": "acme::shop::Product" } } ] @@ -74,7 +74,7 @@ "relationship.association": { "name": "suppliers", "@cardinality": "many", - "@objectRef": "Supplier" + "@objectRef": "acme::shop::Supplier" } } ] @@ -110,25 +110,25 @@ { "field.long": { "name": "productId", - "extends": "Product.id" + "extends": "acme::shop::Product.id" } }, { "identity.primary": { "name": "id", - "extends": "Product.id" + "extends": "acme::shop::Product.id" } }, { "field.object": { "name": "supplierBriefs", "isArray": true, - "@objectRef": "SupplierBrief", + "@objectRef": "acme::shop::SupplierBrief", "children": [ { "origin.aggregate": { "@agg": "collect", - "@via": "Product.suppliers" + "@via": "acme::shop::Product.suppliers" } } ] diff --git a/fixtures/conformance/error-extends-bare-cross-package/expected-errors.json b/fixtures/conformance/error-extends-bare-cross-package/expected-errors.json new file mode 100644 index 000000000..e255bdc51 --- /dev/null +++ b/fixtures/conformance/error-extends-bare-cross-package/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_UNRESOLVED_SUPER", + "source": { + "format": "resolved", + "files": [ + "meta.acme.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.value']", + "referrer": "Derived", + "target": "Base" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-extends-bare-cross-package/input/meta.acme.json b/fixtures/conformance/error-extends-bare-cross-package/input/meta.acme.json new file mode 100644 index 000000000..f5b796b5b --- /dev/null +++ b/fixtures/conformance/error-extends-bare-cross-package/input/meta.acme.json @@ -0,0 +1,13 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.value": { + "name": "Derived", + "extends": "Base" + } + } + ] + } +} diff --git a/fixtures/conformance/error-extends-bare-cross-package/input/meta.other.json b/fixtures/conformance/error-extends-bare-cross-package/input/meta.other.json new file mode 100644 index 000000000..ee0270f30 --- /dev/null +++ b/fixtures/conformance/error-extends-bare-cross-package/input/meta.other.json @@ -0,0 +1,19 @@ +{ + "metadata.root": { + "package": "other", + "children": [ + { + "object.value": { + "name": "Base", + "children": [ + { + "field.string": { + "name": "otherOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-extends-bare-cross-package/providers.json b/fixtures/conformance/error-extends-bare-cross-package/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/error-extends-bare-cross-package/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/error-extends-nested-overlay-target-package/expected-errors.json b/fixtures/conformance/error-extends-nested-overlay-target-package/expected-errors.json new file mode 100644 index 000000000..734803800 --- /dev/null +++ b/fixtures/conformance/error-extends-nested-overlay-target-package/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_UNRESOLVED_SUPER", + "source": { + "format": "resolved", + "files": [ + "meta.b.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.value'].children[0]['field.long']", + "referrer": "x", + "target": "Base.id" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-extends-nested-overlay-target-package/input/meta.a.json b/fixtures/conformance/error-extends-nested-overlay-target-package/input/meta.a.json new file mode 100644 index 000000000..e1fc6d24b --- /dev/null +++ b/fixtures/conformance/error-extends-nested-overlay-target-package/input/meta.a.json @@ -0,0 +1,9 @@ +{ + "metadata.root": { + "package": "a", + "children": [ + { "object.value": { "name": "Order", "children": [ { "field.string": { "name": "code" } } ] } }, + { "object.value": { "name": "Base", "children": [ { "field.long": { "name": "id" } } ] } } + ] + } +} diff --git a/fixtures/conformance/error-extends-nested-overlay-target-package/input/meta.b.json b/fixtures/conformance/error-extends-nested-overlay-target-package/input/meta.b.json new file mode 100644 index 000000000..38fa1951b --- /dev/null +++ b/fixtures/conformance/error-extends-nested-overlay-target-package/input/meta.b.json @@ -0,0 +1,11 @@ +{ + "metadata.root": { + "package": "b", + "children": [ + { "object.value": { "name": "Base", "children": [ { "field.long": { "name": "bid" } } ] } }, + { "object.value": { "name": "Order", "package": "a", "overlay": true, "children": [ + { "field.long": { "name": "x", "extends": "Base.id" } } + ] } } + ] + } +} diff --git a/fixtures/conformance/error-extends-nested-overlay-target-package/providers.json b/fixtures/conformance/error-extends-nested-overlay-target-package/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/error-extends-nested-overlay-target-package/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/error-ref-parent-over-drop/expected-errors.json b/fixtures/conformance/error-ref-parent-over-drop/expected-errors.json new file mode 100644 index 000000000..95b7ec684 --- /dev/null +++ b/fixtures/conformance/error-ref-parent-over-drop/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "resolved", + "files": [ + "meta.acme.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.value']", + "referrer": "X", + "target": "..::..::Base" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-ref-parent-over-drop/input/meta.acme.json b/fixtures/conformance/error-ref-parent-over-drop/input/meta.acme.json new file mode 100644 index 000000000..4328f0b53 --- /dev/null +++ b/fixtures/conformance/error-ref-parent-over-drop/input/meta.acme.json @@ -0,0 +1,25 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.value": { + "name": "Base", + "children": [ + { + "field.string": { + "name": "baseOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "X", + "extends": "..::..::Base" + } + } + ] + } +} diff --git a/fixtures/conformance/error-ref-parent-over-drop/providers.json b/fixtures/conformance/error-ref-parent-over-drop/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/error-ref-parent-over-drop/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/expected-errors.json b/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/expected-errors.json new file mode 100644 index 000000000..fcbfa6657 --- /dev/null +++ b/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/expected-errors.json @@ -0,0 +1,13 @@ +{ + "errors": [ + { + "code": "ERR_INVALID_RELATIONSHIP", + "source": { + "format": "json", + "files": ["meta.demo.json"], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[3]['relationship.association']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/input/meta.demo.json b/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/input/meta.demo.json new file mode 100644 index 000000000..db127f0f0 --- /dev/null +++ b/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/input/meta.demo.json @@ -0,0 +1,18 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { + "object.entity": { + "name": "Match", + "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "homeTeamId" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "relationship.association": { "name": "homeTeam", "@cardinality": "one", "@objectRef": "Teem", "@sourceRefField": "homeTeamId" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/providers.json b/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/error-relationship-one-unresolved-objectref-sourceref/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/error-relative-ref-in-canonical/expected-errors.json b/fixtures/conformance/error-relative-ref-in-canonical/expected-errors.json deleted file mode 100644 index d90d36efd..000000000 --- a/fixtures/conformance/error-relative-ref-in-canonical/expected-errors.json +++ /dev/null @@ -1 +0,0 @@ -[{"code":"ERR_RELATIVE_REF_IN_CANONICAL"}] diff --git a/fixtures/conformance/error-relative-ref-in-canonical/input/meta.bad.json b/fixtures/conformance/error-relative-ref-in-canonical/input/meta.bad.json deleted file mode 100644 index 69e729920..000000000 --- a/fixtures/conformance/error-relative-ref-in-canonical/input/meta.bad.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "metadata.root": { - "package": "demo", - "children": [ - { - "object.entity": { - "name": "Widget", - "extends": "::Base", - "children": [ - { "field.long": { "name": "id" } }, - { "identity.primary": { "name": "id", "@fields": "id" } } - ] - } - }, - { - "object.entity": { - "name": "Base", - "children": [ - { "field.string": { "name": "label" } } - ] - } - } - ] - } -} diff --git a/fixtures/conformance/extends-bare-declaring-file-package/expected-effective.json b/fixtures/conformance/extends-bare-declaring-file-package/expected-effective.json new file mode 100644 index 000000000..39357f18e --- /dev/null +++ b/fixtures/conformance/extends-bare-declaring-file-package/expected-effective.json @@ -0,0 +1,46 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "package": "alpha", + "children": [ + { + "field.string": { + "name": "alphaOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "package": "beta", + "children": [ + { + "field.string": { + "name": "betaOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Sub", + "package": "beta", + "extends": "beta::Apple", + "children": [ + { + "field.string": { + "name": "betaOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/extends-bare-declaring-file-package/expected.json b/fixtures/conformance/extends-bare-declaring-file-package/expected.json new file mode 100644 index 000000000..bdba37374 --- /dev/null +++ b/fixtures/conformance/extends-bare-declaring-file-package/expected.json @@ -0,0 +1,39 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "package": "alpha", + "children": [ + { + "field.string": { + "name": "alphaOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "package": "beta", + "children": [ + { + "field.string": { + "name": "betaOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Sub", + "package": "beta", + "extends": "beta::Apple" + } + } + ] + } +} diff --git a/fixtures/conformance/extends-bare-declaring-file-package/input/meta.a.json b/fixtures/conformance/extends-bare-declaring-file-package/input/meta.a.json new file mode 100644 index 000000000..f099354dd --- /dev/null +++ b/fixtures/conformance/extends-bare-declaring-file-package/input/meta.a.json @@ -0,0 +1,19 @@ +{ + "metadata.root": { + "package": "alpha", + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "alphaOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/extends-bare-declaring-file-package/input/meta.b.json b/fixtures/conformance/extends-bare-declaring-file-package/input/meta.b.json new file mode 100644 index 000000000..7cfd80ece --- /dev/null +++ b/fixtures/conformance/extends-bare-declaring-file-package/input/meta.b.json @@ -0,0 +1,25 @@ +{ + "metadata.root": { + "package": "beta", + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "betaOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Sub", + "extends": "Apple" + } + } + ] + } +} diff --git a/fixtures/conformance/extends-bare-declaring-file-package/providers.json b/fixtures/conformance/extends-bare-declaring-file-package/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/extends-bare-declaring-file-package/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/extends-dotted-inherited-member-load-order/expected-effective.json b/fixtures/conformance/extends-dotted-inherited-member-load-order/expected-effective.json index 0e2702cfc..fae541023 100644 --- a/fixtures/conformance/extends-dotted-inherited-member-load-order/expected-effective.json +++ b/fixtures/conformance/extends-dotted-inherited-member-load-order/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::shop", "children": [ { "object.projection": { "name": "CustomerView", + "package": "acme::shop", "children": [ { "source.rdb": { @@ -53,6 +53,7 @@ { "object.entity": { "name": "Customer", + "package": "acme::shop", "extends": "acme::common::BaseEntity", "children": [ { @@ -86,6 +87,7 @@ { "object.entity": { "name": "BaseEntity", + "package": "acme::common", "abstract": true, "children": [ { diff --git a/fixtures/conformance/extends-dotted-inherited-member-load-order/expected.json b/fixtures/conformance/extends-dotted-inherited-member-load-order/expected.json index f0bffd871..e6730b34d 100644 --- a/fixtures/conformance/extends-dotted-inherited-member-load-order/expected.json +++ b/fixtures/conformance/extends-dotted-inherited-member-load-order/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::shop", "children": [ { "object.projection": { "name": "CustomerView", + "package": "acme::shop", "children": [ { "source.rdb": { @@ -52,6 +52,7 @@ { "object.entity": { "name": "Customer", + "package": "acme::shop", "extends": "acme::common::BaseEntity", "children": [ { @@ -71,6 +72,7 @@ { "object.entity": { "name": "BaseEntity", + "package": "acme::common", "abstract": true, "children": [ { diff --git a/fixtures/conformance/extends-entity-field-cross-package/expected.json b/fixtures/conformance/extends-entity-field-cross-package/expected.json index 165f1f251..3d64678a1 100644 --- a/fixtures/conformance/extends-entity-field-cross-package/expected.json +++ b/fixtures/conformance/extends-entity-field-cross-package/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::api", "children": [ { "object.value": { "name": "CustomerArgs", + "package": "acme::api", "children": [ { "field.uuid": { @@ -18,6 +18,7 @@ { "object.entity": { "name": "Customer", + "package": "acme::sales", "children": [ { "field.uuid": { diff --git a/fixtures/conformance/extends-nested-overlay-file-package/expected-effective.json b/fixtures/conformance/extends-nested-overlay-file-package/expected-effective.json new file mode 100644 index 000000000..093647813 --- /dev/null +++ b/fixtures/conformance/extends-nested-overlay-file-package/expected-effective.json @@ -0,0 +1,51 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Order", + "package": "a", + "children": [ + { + "field.string": { + "name": "code" + } + }, + { + "field.long": { + "name": "x", + "extends": "b::Base.bid" + } + } + ] + } + }, + { + "object.value": { + "name": "Base", + "package": "a", + "children": [ + { + "field.long": { + "name": "id" + } + } + ] + } + }, + { + "object.value": { + "name": "Base", + "package": "b", + "children": [ + { + "field.long": { + "name": "bid" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/extends-nested-overlay-file-package/expected.json b/fixtures/conformance/extends-nested-overlay-file-package/expected.json new file mode 100644 index 000000000..093647813 --- /dev/null +++ b/fixtures/conformance/extends-nested-overlay-file-package/expected.json @@ -0,0 +1,51 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Order", + "package": "a", + "children": [ + { + "field.string": { + "name": "code" + } + }, + { + "field.long": { + "name": "x", + "extends": "b::Base.bid" + } + } + ] + } + }, + { + "object.value": { + "name": "Base", + "package": "a", + "children": [ + { + "field.long": { + "name": "id" + } + } + ] + } + }, + { + "object.value": { + "name": "Base", + "package": "b", + "children": [ + { + "field.long": { + "name": "bid" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/extends-nested-overlay-file-package/input/meta.a.json b/fixtures/conformance/extends-nested-overlay-file-package/input/meta.a.json new file mode 100644 index 000000000..e1fc6d24b --- /dev/null +++ b/fixtures/conformance/extends-nested-overlay-file-package/input/meta.a.json @@ -0,0 +1,9 @@ +{ + "metadata.root": { + "package": "a", + "children": [ + { "object.value": { "name": "Order", "children": [ { "field.string": { "name": "code" } } ] } }, + { "object.value": { "name": "Base", "children": [ { "field.long": { "name": "id" } } ] } } + ] + } +} diff --git a/fixtures/conformance/extends-nested-overlay-file-package/input/meta.b.json b/fixtures/conformance/extends-nested-overlay-file-package/input/meta.b.json new file mode 100644 index 000000000..1e4bde37b --- /dev/null +++ b/fixtures/conformance/extends-nested-overlay-file-package/input/meta.b.json @@ -0,0 +1,11 @@ +{ + "metadata.root": { + "package": "b", + "children": [ + { "object.value": { "name": "Base", "children": [ { "field.long": { "name": "bid" } } ] } }, + { "object.value": { "name": "Order", "package": "a", "overlay": true, "children": [ + { "field.long": { "name": "x", "extends": "Base.bid" } } + ] } } + ] + } +} diff --git a/fixtures/conformance/extends-nested-overlay-file-package/providers.json b/fixtures/conformance/extends-nested-overlay-file-package/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/extends-nested-overlay-file-package/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/flattened-kitchen-sink/expected-effective.json b/fixtures/conformance/flattened-kitchen-sink/expected-effective.json index 95bafdb42..bb089d654 100644 --- a/fixtures/conformance/flattened-kitchen-sink/expected-effective.json +++ b/fixtures/conformance/flattened-kitchen-sink/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::ai", "children": [ { "object.value": { "name": "ProductPromptPayload", + "package": "acme::ai", "@description": "Typed projection passed to the product prompt/output/toolcall templates.", "children": [ { @@ -49,6 +49,7 @@ { "object.value": { "name": "ProductDescription", + "package": "acme::ai", "@description": "The typed shape describeProduct expects back from the model.", "children": [ { @@ -67,6 +68,7 @@ { "template.prompt": { "name": "describeProduct", + "package": "acme::ai", "@format": "xml", "@payloadRef": "acme::ai::ProductPromptPayload", "@promptStyle": "guide", @@ -78,6 +80,7 @@ { "template.output": { "name": "productCard", + "package": "acme::ai", "@format": "html", "@payloadRef": "acme::ai::ProductPromptPayload", "@textRef": "product/card" @@ -86,6 +89,7 @@ { "template.toolcall": { "name": "lookupProduct", + "package": "acme::ai", "@payloadRef": "acme::ai::ProductPromptPayload", "@toolName": "lookup_product" } @@ -93,6 +97,7 @@ { "object.value": { "name": "Dimensions", + "package": "acme::catalog", "extends": "acme::common::BaseValue", "@description": "Owned value object stored flattened onto the product row.", "children": [ @@ -124,6 +129,7 @@ { "object.value": { "name": "SeoMeta", + "package": "acme::catalog", "extends": "acme::common::BaseValue", "@description": "Structured value object stored as a JSONB document.", "children": [ @@ -154,6 +160,7 @@ { "object.entity": { "name": "Product", + "package": "acme::catalog", "extends": "acme::common::BaseEntity", "@description": "Kitchen-sink entity exercising every field subtype and attribute band.", "@uiHints": { @@ -354,7 +361,7 @@ "name": "suppliers", "@cardinality": "many", "@objectRef": "acme::catalog::Supplier", - "@through": "ProductSupplier" + "@through": "acme::catalog::ProductSupplier" } }, { @@ -404,6 +411,7 @@ { "object.entity": { "name": "Category", + "package": "acme::catalog", "extends": "acme::common::BaseEntity", "@description": "Owns products; composition-related to its products.", "children": [ @@ -452,6 +460,7 @@ { "object.entity": { "name": "Supplier", + "package": "acme::catalog", "extends": "acme::common::BaseEntity", "@description": "A product supplier; reachable via the M:N junction.", "children": [ @@ -491,7 +500,7 @@ "@cardinality": "many", "@objectRef": "acme::catalog::Supplier", "@symmetric": true, - "@through": "SupplierPeer" + "@through": "acme::catalog::SupplierPeer" } } ] @@ -500,6 +509,7 @@ { "object.entity": { "name": "ProductSupplier", + "package": "acme::catalog", "@description": "Junction for the Product<->Supplier M:N association.", "children": [ { @@ -554,6 +564,7 @@ { "object.entity": { "name": "SupplierPeer", + "package": "acme::catalog", "@description": "Symmetric self-join junction for Supplier peers.", "children": [ { @@ -608,6 +619,7 @@ { "object.projection": { "name": "ProductSummary", + "package": "acme::catalog", "@description": "Read-only projection: a passthrough origin and a count aggregate.", "children": [ { @@ -663,6 +675,7 @@ { "object.entity": { "name": "BaseEntity", + "package": "acme::common", "abstract": true, "@description": "Shared abstract base — every concrete entity inherits id + createdAt.", "children": [ @@ -704,6 +717,7 @@ { "object.value": { "name": "BaseValue", + "package": "acme::common", "abstract": true, "@description": "Abstract value-object base carrying an audit label.", "children": [ @@ -718,4 +732,3 @@ ] } } - diff --git a/fixtures/conformance/flattened-kitchen-sink/expected.json b/fixtures/conformance/flattened-kitchen-sink/expected.json index e7cf1b0d6..7fdb949c0 100644 --- a/fixtures/conformance/flattened-kitchen-sink/expected.json +++ b/fixtures/conformance/flattened-kitchen-sink/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::ai", "children": [ { "object.value": { "name": "ProductPromptPayload", + "package": "acme::ai", "@description": "Typed projection passed to the product prompt/output/toolcall templates.", "children": [ { @@ -43,6 +43,7 @@ { "object.value": { "name": "ProductDescription", + "package": "acme::ai", "@description": "The typed shape describeProduct expects back from the model.", "children": [ { @@ -61,6 +62,7 @@ { "template.prompt": { "name": "describeProduct", + "package": "acme::ai", "@format": "xml", "@payloadRef": "acme::ai::ProductPromptPayload", "@promptStyle": "guide", @@ -72,6 +74,7 @@ { "template.output": { "name": "productCard", + "package": "acme::ai", "@format": "html", "@payloadRef": "acme::ai::ProductPromptPayload", "@textRef": "product/card" @@ -80,6 +83,7 @@ { "template.toolcall": { "name": "lookupProduct", + "package": "acme::ai", "@payloadRef": "acme::ai::ProductPromptPayload", "@toolName": "lookup_product" } @@ -87,6 +91,7 @@ { "object.value": { "name": "Dimensions", + "package": "acme::catalog", "extends": "acme::common::BaseValue", "@description": "Owned value object stored flattened onto the product row.", "children": [ @@ -113,6 +118,7 @@ { "object.value": { "name": "SeoMeta", + "package": "acme::catalog", "extends": "acme::common::BaseValue", "@description": "Structured value object stored as a JSONB document.", "children": [ @@ -138,6 +144,7 @@ { "object.entity": { "name": "Product", + "package": "acme::catalog", "extends": "acme::common::BaseEntity", "@description": "Kitchen-sink entity exercising every field subtype and attribute band.", "@uiHints": { @@ -313,7 +320,7 @@ "name": "suppliers", "@cardinality": "many", "@objectRef": "acme::catalog::Supplier", - "@through": "ProductSupplier" + "@through": "acme::catalog::ProductSupplier" } }, { @@ -363,6 +370,7 @@ { "object.entity": { "name": "Category", + "package": "acme::catalog", "extends": "acme::common::BaseEntity", "@description": "Owns products; composition-related to its products.", "children": [ @@ -392,6 +400,7 @@ { "object.entity": { "name": "Supplier", + "package": "acme::catalog", "extends": "acme::common::BaseEntity", "@description": "A product supplier; reachable via the M:N junction.", "children": [ @@ -412,7 +421,7 @@ "@cardinality": "many", "@objectRef": "acme::catalog::Supplier", "@symmetric": true, - "@through": "SupplierPeer" + "@through": "acme::catalog::SupplierPeer" } } ] @@ -421,6 +430,7 @@ { "object.entity": { "name": "ProductSupplier", + "package": "acme::catalog", "@description": "Junction for the Product<->Supplier M:N association.", "children": [ { @@ -475,6 +485,7 @@ { "object.entity": { "name": "SupplierPeer", + "package": "acme::catalog", "@description": "Symmetric self-join junction for Supplier peers.", "children": [ { @@ -529,6 +540,7 @@ { "object.projection": { "name": "ProductSummary", + "package": "acme::catalog", "@description": "Read-only projection: a passthrough origin and a count aggregate.", "children": [ { @@ -581,6 +593,7 @@ { "object.entity": { "name": "BaseEntity", + "package": "acme::common", "abstract": true, "@description": "Shared abstract base — every concrete entity inherits id + createdAt.", "children": [ @@ -622,6 +635,7 @@ { "object.value": { "name": "BaseValue", + "package": "acme::common", "abstract": true, "@description": "Abstract value-object base carrying an audit label.", "children": [ @@ -636,4 +650,3 @@ ] } } - diff --git a/fixtures/conformance/loader-relative-package-multi-root/expected.json b/fixtures/conformance/loader-relative-package-multi-root/expected.json index 6dcdd0574..1948ab143 100644 --- a/fixtures/conformance/loader-relative-package-multi-root/expected.json +++ b/fixtures/conformance/loader-relative-package-multi-root/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::app", "children": [ { "object.entity": { "name": "Store", + "package": "acme::app", "children": [ { "field.long": { diff --git a/fixtures/conformance/loader-same-name-distinct-packages/expected.json b/fixtures/conformance/loader-same-name-distinct-packages/expected.json index 1f96860b5..f1c789dfe 100644 --- a/fixtures/conformance/loader-same-name-distinct-packages/expected.json +++ b/fixtures/conformance/loader-same-name-distinct-packages/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::alpha", "children": [ { "object.value": { "name": "ItemView", + "package": "acme::alpha", "children": [ { "field.string": { @@ -17,6 +17,7 @@ { "object.value": { "name": "SituationView", + "package": "acme::alpha", "children": [ { "field.object": { @@ -36,6 +37,7 @@ { "object.value": { "name": "ItemView", + "package": "acme::beta", "children": [ { "field.string": { @@ -48,6 +50,7 @@ { "object.value": { "name": "SituationView", + "package": "acme::beta", "children": [ { "field.object": { diff --git a/fixtures/conformance/multi-package-extends-overlay-references/expected-effective.json b/fixtures/conformance/multi-package-extends-overlay-references/expected-effective.json index cc186c24d..0bc2223ee 100644 --- a/fixtures/conformance/multi-package-extends-overlay-references/expected-effective.json +++ b/fixtures/conformance/multi-package-extends-overlay-references/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::common", "children": [ { "object.entity": { "name": "BaseEntity", + "package": "acme::common", "abstract": true, "children": [ { @@ -31,6 +31,7 @@ { "object.entity": { "name": "Order", + "package": "acme::orders", "extends": "acme::common::BaseEntity", "children": [ { @@ -82,6 +83,7 @@ { "object.entity": { "name": "Customer", + "package": "acme::sales", "extends": "acme::common::BaseEntity", "children": [ { @@ -114,4 +116,3 @@ ] } } - diff --git a/fixtures/conformance/multi-package-extends-overlay-references/expected.json b/fixtures/conformance/multi-package-extends-overlay-references/expected.json index 39bd77aec..3625c7c5d 100644 --- a/fixtures/conformance/multi-package-extends-overlay-references/expected.json +++ b/fixtures/conformance/multi-package-extends-overlay-references/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::common", "children": [ { "object.entity": { "name": "BaseEntity", + "package": "acme::common", "abstract": true, "children": [ { @@ -31,6 +31,7 @@ { "object.entity": { "name": "Order", + "package": "acme::orders", "extends": "acme::common::BaseEntity", "children": [ { @@ -64,6 +65,7 @@ { "object.entity": { "name": "Customer", + "package": "acme::sales", "extends": "acme::common::BaseEntity", "children": [ { @@ -78,4 +80,3 @@ ] } } - diff --git a/fixtures/conformance/package-default-per-file/expected-effective.json b/fixtures/conformance/package-default-per-file/expected-effective.json new file mode 100644 index 000000000..3ea56311e --- /dev/null +++ b/fixtures/conformance/package-default-per-file/expected-effective.json @@ -0,0 +1,31 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "package": "acme", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/package-default-per-file/expected.json b/fixtures/conformance/package-default-per-file/expected.json new file mode 100644 index 000000000..3ea56311e --- /dev/null +++ b/fixtures/conformance/package-default-per-file/expected.json @@ -0,0 +1,31 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "package": "acme", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/package-default-per-file/input/meta.a.json b/fixtures/conformance/package-default-per-file/input/meta.a.json new file mode 100644 index 000000000..6bb81ddba --- /dev/null +++ b/fixtures/conformance/package-default-per-file/input/meta.a.json @@ -0,0 +1,19 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/package-default-per-file/input/meta.b.json b/fixtures/conformance/package-default-per-file/input/meta.b.json new file mode 100644 index 000000000..c48ecd0f6 --- /dev/null +++ b/fixtures/conformance/package-default-per-file/input/meta.b.json @@ -0,0 +1,18 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/package-default-per-file/providers.json b/fixtures/conformance/package-default-per-file/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/package-default-per-file/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/ref-bare-local-shadows-root/expected-effective.json b/fixtures/conformance/ref-bare-local-shadows-root/expected-effective.json new file mode 100644 index 000000000..19d96d9dd --- /dev/null +++ b/fixtures/conformance/ref-bare-local-shadows-root/expected-effective.json @@ -0,0 +1,59 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "package": "acme", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "LocalApple", + "package": "acme", + "extends": "acme::Apple", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Basket", + "package": "acme", + "children": [ + { + "field.object": { + "name": "fruit", + "@objectRef": "acme::Apple" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-bare-local-shadows-root/expected.json b/fixtures/conformance/ref-bare-local-shadows-root/expected.json new file mode 100644 index 000000000..61bbd5204 --- /dev/null +++ b/fixtures/conformance/ref-bare-local-shadows-root/expected.json @@ -0,0 +1,52 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "package": "acme", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "LocalApple", + "package": "acme", + "extends": "acme::Apple" + } + }, + { + "object.value": { + "name": "Basket", + "package": "acme", + "children": [ + { + "field.object": { + "name": "fruit", + "@objectRef": "acme::Apple" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-bare-local-shadows-root/input/meta.acme.json b/fixtures/conformance/ref-bare-local-shadows-root/input/meta.acme.json new file mode 100644 index 000000000..9182cfe6d --- /dev/null +++ b/fixtures/conformance/ref-bare-local-shadows-root/input/meta.acme.json @@ -0,0 +1,38 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "LocalApple", + "extends": "Apple" + } + }, + { + "object.value": { + "name": "Basket", + "children": [ + { + "field.object": { + "name": "fruit", + "@objectRef": "Apple" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-bare-local-shadows-root/input/meta.root.json b/fixtures/conformance/ref-bare-local-shadows-root/input/meta.root.json new file mode 100644 index 000000000..c48ecd0f6 --- /dev/null +++ b/fixtures/conformance/ref-bare-local-shadows-root/input/meta.root.json @@ -0,0 +1,18 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-bare-local-shadows-root/providers.json b/fixtures/conformance/ref-bare-local-shadows-root/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/ref-bare-local-shadows-root/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/ref-inherited-bare-objectref/expected-effective.json b/fixtures/conformance/ref-inherited-bare-objectref/expected-effective.json new file mode 100644 index 000000000..605bdefcc --- /dev/null +++ b/fixtures/conformance/ref-inherited-bare-objectref/expected-effective.json @@ -0,0 +1,61 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Thing", + "package": "base", + "children": [ + { + "field.string": { + "name": "label" + } + } + ] + } + }, + { + "object.value": { + "name": "Holder", + "package": "base", + "children": [ + { + "field.object": { + "name": "thing", + "@objectRef": "base::Thing" + } + } + ] + } + }, + { + "object.value": { + "name": "Thing", + "package": "app", + "children": [ + { + "field.string": { + "name": "appOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Copy", + "package": "app", + "children": [ + { + "field.object": { + "name": "thing", + "extends": "base::Holder.thing", + "@objectRef": "base::Thing" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-inherited-bare-objectref/expected.json b/fixtures/conformance/ref-inherited-bare-objectref/expected.json new file mode 100644 index 000000000..92e1b3d49 --- /dev/null +++ b/fixtures/conformance/ref-inherited-bare-objectref/expected.json @@ -0,0 +1,60 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Thing", + "package": "base", + "children": [ + { + "field.string": { + "name": "label" + } + } + ] + } + }, + { + "object.value": { + "name": "Holder", + "package": "base", + "children": [ + { + "field.object": { + "name": "thing", + "@objectRef": "base::Thing" + } + } + ] + } + }, + { + "object.value": { + "name": "Thing", + "package": "app", + "children": [ + { + "field.string": { + "name": "appOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Copy", + "package": "app", + "children": [ + { + "field.object": { + "name": "thing", + "extends": "base::Holder.thing" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-inherited-bare-objectref/input/meta.base.json b/fixtures/conformance/ref-inherited-bare-objectref/input/meta.base.json new file mode 100644 index 000000000..1a1b45d25 --- /dev/null +++ b/fixtures/conformance/ref-inherited-bare-objectref/input/meta.base.json @@ -0,0 +1,32 @@ +{ + "metadata.root": { + "package": "base", + "children": [ + { + "object.value": { + "name": "Thing", + "children": [ + { + "field.string": { + "name": "label" + } + } + ] + } + }, + { + "object.value": { + "name": "Holder", + "children": [ + { + "field.object": { + "name": "thing", + "@objectRef": "Thing" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-inherited-bare-objectref/input/meta.copy.json b/fixtures/conformance/ref-inherited-bare-objectref/input/meta.copy.json new file mode 100644 index 000000000..77d3d639b --- /dev/null +++ b/fixtures/conformance/ref-inherited-bare-objectref/input/meta.copy.json @@ -0,0 +1,32 @@ +{ + "metadata.root": { + "package": "app", + "children": [ + { + "object.value": { + "name": "Thing", + "children": [ + { + "field.string": { + "name": "appOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Copy", + "children": [ + { + "field.object": { + "name": "thing", + "extends": "base::Holder.thing" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-inherited-bare-objectref/providers.json b/fixtures/conformance/ref-inherited-bare-objectref/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/ref-inherited-bare-objectref/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/ref-parent-relative/expected-effective.json b/fixtures/conformance/ref-parent-relative/expected-effective.json new file mode 100644 index 000000000..70213c55c --- /dev/null +++ b/fixtures/conformance/ref-parent-relative/expected-effective.json @@ -0,0 +1,52 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Base", + "package": "acme", + "children": [ + { + "field.string": { + "name": "baseOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Thing", + "package": "acme", + "children": [ + { + "field.string": { + "name": "label" + } + } + ] + } + }, + { + "object.value": { + "name": "X", + "package": "acme::sub", + "extends": "acme::Base", + "children": [ + { + "field.string": { + "name": "baseOnly" + } + }, + { + "field.object": { + "name": "thing", + "@objectRef": "acme::Thing" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-parent-relative/expected.json b/fixtures/conformance/ref-parent-relative/expected.json new file mode 100644 index 000000000..2ed44ad05 --- /dev/null +++ b/fixtures/conformance/ref-parent-relative/expected.json @@ -0,0 +1,47 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Base", + "package": "acme", + "children": [ + { + "field.string": { + "name": "baseOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Thing", + "package": "acme", + "children": [ + { + "field.string": { + "name": "label" + } + } + ] + } + }, + { + "object.value": { + "name": "X", + "package": "acme::sub", + "extends": "acme::Base", + "children": [ + { + "field.object": { + "name": "thing", + "@objectRef": "acme::Thing" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-parent-relative/input/meta.acme.json b/fixtures/conformance/ref-parent-relative/input/meta.acme.json new file mode 100644 index 000000000..53a705581 --- /dev/null +++ b/fixtures/conformance/ref-parent-relative/input/meta.acme.json @@ -0,0 +1,31 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.value": { + "name": "Base", + "children": [ + { + "field.string": { + "name": "baseOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Thing", + "children": [ + { + "field.string": { + "name": "label" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-parent-relative/input/meta.sub.json b/fixtures/conformance/ref-parent-relative/input/meta.sub.json new file mode 100644 index 000000000..f7e3476b2 --- /dev/null +++ b/fixtures/conformance/ref-parent-relative/input/meta.sub.json @@ -0,0 +1,21 @@ +{ + "metadata.root": { + "package": "acme::sub", + "children": [ + { + "object.value": { + "name": "X", + "extends": "..::Base", + "children": [ + { + "field.object": { + "name": "thing", + "@objectRef": "..::Thing" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-parent-relative/providers.json b/fixtures/conformance/ref-parent-relative/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/ref-parent-relative/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/ref-root-escape-shadowed/expected-effective.json b/fixtures/conformance/ref-root-escape-shadowed/expected-effective.json new file mode 100644 index 000000000..668c194ac --- /dev/null +++ b/fixtures/conformance/ref-root-escape-shadowed/expected-effective.json @@ -0,0 +1,59 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "package": "acme", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "RootApple", + "package": "acme", + "extends": "::Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Basket", + "package": "acme", + "children": [ + { + "field.object": { + "name": "fruit", + "@objectRef": "::Apple" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-root-escape-shadowed/expected.json b/fixtures/conformance/ref-root-escape-shadowed/expected.json new file mode 100644 index 000000000..dd8f35888 --- /dev/null +++ b/fixtures/conformance/ref-root-escape-shadowed/expected.json @@ -0,0 +1,52 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "package": "acme", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "RootApple", + "package": "acme", + "extends": "::Apple" + } + }, + { + "object.value": { + "name": "Basket", + "package": "acme", + "children": [ + { + "field.object": { + "name": "fruit", + "@objectRef": "::Apple" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-root-escape-shadowed/input/meta.acme.json b/fixtures/conformance/ref-root-escape-shadowed/input/meta.acme.json new file mode 100644 index 000000000..d18024f0a --- /dev/null +++ b/fixtures/conformance/ref-root-escape-shadowed/input/meta.acme.json @@ -0,0 +1,38 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "RootApple", + "extends": "::Apple" + } + }, + { + "object.value": { + "name": "Basket", + "children": [ + { + "field.object": { + "name": "fruit", + "@objectRef": "::Apple" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-root-escape-shadowed/input/meta.root.json b/fixtures/conformance/ref-root-escape-shadowed/input/meta.root.json new file mode 100644 index 000000000..c48ecd0f6 --- /dev/null +++ b/fixtures/conformance/ref-root-escape-shadowed/input/meta.root.json @@ -0,0 +1,18 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/ref-root-escape-shadowed/providers.json b/fixtures/conformance/ref-root-escape-shadowed/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/ref-root-escape-shadowed/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/relationship-m2m-hetero/expected.json b/fixtures/conformance/relationship-m2m-hetero/expected.json index 24fc6c683..6349fcd8f 100644 --- a/fixtures/conformance/relationship-m2m-hetero/expected.json +++ b/fixtures/conformance/relationship-m2m-hetero/expected.json @@ -21,7 +21,7 @@ "name": "tags", "@cardinality": "many", "@objectRef": "acme::commerce::Tag", - "@through": "PostTag" + "@through": "acme::commerce::PostTag" } }, { diff --git a/fixtures/conformance/relationship-m2m-self-join-directed/expected.json b/fixtures/conformance/relationship-m2m-self-join-directed/expected.json index 3fad681e9..f3e1534f5 100644 --- a/fixtures/conformance/relationship-m2m-self-join-directed/expected.json +++ b/fixtures/conformance/relationship-m2m-self-join-directed/expected.json @@ -22,7 +22,7 @@ "@cardinality": "many", "@objectRef": "acme::social::User", "@sourceRefField": "followerId", - "@through": "Follow" + "@through": "acme::social::Follow" } }, { diff --git a/fixtures/conformance/relationship-m2m-self-join-symmetric/expected.json b/fixtures/conformance/relationship-m2m-self-join-symmetric/expected.json index c9340072f..f4b10991c 100644 --- a/fixtures/conformance/relationship-m2m-self-join-symmetric/expected.json +++ b/fixtures/conformance/relationship-m2m-self-join-symmetric/expected.json @@ -22,7 +22,7 @@ "@cardinality": "many", "@objectRef": "acme::social::User", "@symmetric": true, - "@through": "Friendship" + "@through": "acme::social::Friendship" } }, { diff --git a/fixtures/conformance/reporting-any-all-predicate/expected.json b/fixtures/conformance/reporting-any-all-predicate/expected.json index e6205b950..8271ecc42 100644 --- a/fixtures/conformance/reporting-any-all-predicate/expected.json +++ b/fixtures/conformance/reporting-any-all-predicate/expected.json @@ -54,7 +54,7 @@ { "dimension.attribute": { "name": "game", - "@of": "Call.gameId" + "@of": "acme::ops::Call.gameId" } }, { @@ -62,7 +62,7 @@ "name": "calls", "@agg": "count", "@of": [ - "Call.id" + "acme::ops::Call.id" ] } }, @@ -106,7 +106,7 @@ "@dimensions": [ "game" ], - "@from": "Call", + "@from": "acme::ops::Call", "@measures": [ "calls", "hasError", diff --git a/fixtures/conformance/reporting-boolean-measures/expected.json b/fixtures/conformance/reporting-boolean-measures/expected.json index 4b885586e..827b56efa 100644 --- a/fixtures/conformance/reporting-boolean-measures/expected.json +++ b/fixtures/conformance/reporting-boolean-measures/expected.json @@ -39,7 +39,7 @@ "name": "hasFailure", "@agg": "any", "@of": [ - "Call.failed" + "acme::ops::Call.failed" ] } }, @@ -48,7 +48,7 @@ "name": "allFailed", "@agg": "all", "@of": [ - "Call.failed" + "acme::ops::Call.failed" ] } }, @@ -58,7 +58,7 @@ "@agg": "any", "@default": 0, "@of": [ - "Call.failed" + "acme::ops::Call.failed" ] } }, @@ -68,7 +68,7 @@ "@agg": "all", "@default": 1, "@of": [ - "Call.failed" + "acme::ops::Call.failed" ] } } @@ -78,7 +78,7 @@ { "object.report": { "name": "CallFlags", - "@from": "Call", + "@from": "acme::ops::Call", "@measures": [ "hasFailure", "allFailed", diff --git a/fixtures/conformance/reporting-cross-package-short-names/expected.json b/fixtures/conformance/reporting-cross-package-short-names/expected.json index 4f1bd7b94..58db39858 100644 --- a/fixtures/conformance/reporting-cross-package-short-names/expected.json +++ b/fixtures/conformance/reporting-cross-package-short-names/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::play", "children": [ { "object.entity": { "name": "Game", + "package": "acme::play", "children": [ { "source.rdb": { @@ -42,8 +42,8 @@ { "dimension.attribute": { "name": "realmName", - "@of": "Realm.name", - "@via": "Game.fkRealm" + "@of": "acme::world::Realm.name", + "@via": "acme::play::Game.fkRealm" } }, { @@ -51,7 +51,7 @@ "name": "games", "@agg": "count", "@of": [ - "Game.id" + "acme::play::Game.id" ] } } @@ -61,6 +61,7 @@ { "object.report": { "name": "GamesByRealm", + "package": "acme::reports", "@dimensions": [ "realmName" ], @@ -68,7 +69,7 @@ "@measures": [ "games" ], - "@spine": "Game.fkRealm", + "@spine": "acme::play::Game.fkRealm", "children": [ { "source.rdb": { @@ -82,6 +83,7 @@ { "object.entity": { "name": "Realm", + "package": "acme::world", "children": [ { "source.rdb": { diff --git a/fixtures/conformance/reporting-inherited-members/expected.json b/fixtures/conformance/reporting-inherited-members/expected.json index 036da1d48..b87fb6424 100644 --- a/fixtures/conformance/reporting-inherited-members/expected.json +++ b/fixtures/conformance/reporting-inherited-members/expected.json @@ -24,7 +24,7 @@ "day", "week" ], - "@of": "BaseEvent.occurredAt" + "@of": "acme::shop::BaseEvent.occurredAt" } }, { @@ -32,7 +32,7 @@ "name": "events", "@agg": "count", "@of": [ - "BaseEvent.id" + "acme::shop::BaseEvent.id" ] } } @@ -42,7 +42,7 @@ { "object.entity": { "name": "WorkoutEvent", - "extends": "BaseEvent", + "extends": "acme::shop::BaseEvent", "children": [ { "source.rdb": { @@ -71,7 +71,7 @@ "@dimensions": [ "occurredAt:day" ], - "@from": "WorkoutEvent", + "@from": "acme::shop::WorkoutEvent", "@measures": [ "events" ] diff --git a/fixtures/conformance/reporting-ratio-numeric-min-max/expected.json b/fixtures/conformance/reporting-ratio-numeric-min-max/expected.json index a3b62d34c..425b4b562 100644 --- a/fixtures/conformance/reporting-ratio-numeric-min-max/expected.json +++ b/fixtures/conformance/reporting-ratio-numeric-min-max/expected.json @@ -39,7 +39,7 @@ "name": "calls", "@agg": "count", "@of": [ - "Call.id" + "acme::ops::Call.id" ] } }, @@ -48,7 +48,7 @@ "name": "longestSeconds", "@agg": "max", "@of": [ - "Call.durationSeconds" + "acme::ops::Call.durationSeconds" ] } }, @@ -57,7 +57,7 @@ "name": "shortestSeconds", "@agg": "min", "@of": [ - "Call.durationSeconds" + "acme::ops::Call.durationSeconds" ] } }, @@ -66,7 +66,7 @@ "name": "highestCost", "@agg": "max", "@of": [ - "Call.costCents" + "acme::ops::Call.costCents" ] } }, @@ -90,7 +90,7 @@ { "object.report": { "name": "CallExtremes", - "@from": "Call", + "@from": "acme::ops::Call", "@measures": [ "calls", "durationSpread", diff --git a/fixtures/conformance/reporting-spine-and-default/expected.json b/fixtures/conformance/reporting-spine-and-default/expected.json index 1c3cbfc49..d50324554 100644 --- a/fixtures/conformance/reporting-spine-and-default/expected.json +++ b/fixtures/conformance/reporting-spine-and-default/expected.json @@ -77,14 +77,14 @@ "@fields": [ "catalogId" ], - "@references": "Catalog" + "@references": "acme::shop::Catalog" } }, { "relationship.association": { "name": "catalog", "@cardinality": "one", - "@objectRef": "Catalog" + "@objectRef": "acme::shop::Catalog" } } ] @@ -143,14 +143,14 @@ "@fields": [ "programId" ], - "@references": "Program" + "@references": "acme::shop::Program" } }, { "relationship.association": { "name": "program", "@cardinality": "one", - "@objectRef": "Program" + "@objectRef": "acme::shop::Program" } }, { @@ -166,15 +166,15 @@ { "dimension.attribute": { "name": "programId", - "@of": "Program.id", - "@via": "Purchase.program" + "@of": "acme::shop::Program.id", + "@via": "acme::shop::Purchase.program" } }, { "dimension.attribute": { "name": "programTitle", - "@of": "Program.title", - "@via": "Purchase.program" + "@of": "acme::shop::Program.title", + "@via": "acme::shop::Purchase.program" } }, { @@ -184,8 +184,8 @@ "month", "year" ], - "@of": "Program.publishedAt", - "@via": "Purchase.program" + "@of": "acme::shop::Program.publishedAt", + "@via": "acme::shop::Purchase.program" } }, { @@ -195,21 +195,21 @@ "day", "month" ], - "@of": "Purchase.purchasedAt" + "@of": "acme::shop::Purchase.purchasedAt" } }, { "dimension.attribute": { "name": "programByRef", - "@of": "Program.title", - "@via": "Purchase.fkProgram" + "@of": "acme::shop::Program.title", + "@via": "acme::shop::Purchase.fkProgram" } }, { "dimension.attribute": { "name": "catalogName", - "@of": "Catalog.name", - "@via": "Purchase.program.catalog" + "@of": "acme::shop::Catalog.name", + "@via": "acme::shop::Purchase.program.catalog" } }, { @@ -217,7 +217,7 @@ "name": "purchases", "@agg": "count", "@of": [ - "Purchase.id" + "acme::shop::Purchase.id" ] } }, @@ -227,7 +227,7 @@ "@agg": "sum", "@default": 0, "@of": [ - "Purchase.amountCents" + "acme::shop::Purchase.amountCents" ] } }, @@ -237,7 +237,7 @@ "@agg": "avg", "@default": 0, "@of": [ - "Purchase.amountCents" + "acme::shop::Purchase.amountCents" ] } }, @@ -247,7 +247,7 @@ "@agg": "min", "@default": -1, "@of": [ - "Purchase.amountCents" + "acme::shop::Purchase.amountCents" ] } }, @@ -256,7 +256,7 @@ "name": "lastPurchaseAt", "@agg": "max", "@of": [ - "Purchase.purchasedAt" + "acme::shop::Purchase.purchasedAt" ] } }, @@ -280,7 +280,7 @@ "publishedAt:month", "catalogName" ], - "@from": "Purchase", + "@from": "acme::shop::Purchase", "@measures": [ "purchases", "revenue", @@ -290,7 +290,7 @@ "revenuePerPurchase" ], "@segment": "active", - "@spine": "Purchase.program", + "@spine": "acme::shop::Purchase.program", "children": [ { "source.rdb": { @@ -307,12 +307,12 @@ "@dimensions": [ "catalogName" ], - "@from": "Purchase", + "@from": "acme::shop::Purchase", "@measures": [ "purchases", "revenue" ], - "@spine": "Purchase.program.catalog" + "@spine": "acme::shop::Purchase.program.catalog" } }, { @@ -321,7 +321,7 @@ "@dimensions": [ "purchasedAt:day" ], - "@from": "Purchase", + "@from": "acme::shop::Purchase", "@measures": [ "revenue" ] diff --git a/fixtures/conformance/reporting-spine-inherited/expected.json b/fixtures/conformance/reporting-spine-inherited/expected.json index e621c266d..40b050ba5 100644 --- a/fixtures/conformance/reporting-spine-inherited/expected.json +++ b/fixtures/conformance/reporting-spine-inherited/expected.json @@ -59,28 +59,28 @@ "@fields": [ "programId" ], - "@references": "Program" + "@references": "acme::shop::Program" } }, { "relationship.association": { "name": "program", "@cardinality": "one", - "@objectRef": "Program" + "@objectRef": "acme::shop::Program" } }, { "dimension.attribute": { "name": "programId", - "@of": "Program.id", - "@via": "BaseEvent.program" + "@of": "acme::shop::Program.id", + "@via": "acme::shop::BaseEvent.program" } }, { "dimension.attribute": { "name": "programTitle", - "@of": "Program.title", - "@via": "BaseEvent.program" + "@of": "acme::shop::Program.title", + "@via": "acme::shop::BaseEvent.program" } }, { @@ -89,7 +89,7 @@ "@agg": "sum", "@default": 0, "@of": [ - "BaseEvent.minutes" + "acme::shop::BaseEvent.minutes" ] } } @@ -99,7 +99,7 @@ { "object.entity": { "name": "WorkoutEvent", - "extends": "BaseEvent", + "extends": "acme::shop::BaseEvent", "children": [ { "source.rdb": { @@ -129,11 +129,11 @@ "programId", "programTitle" ], - "@from": "WorkoutEvent", + "@from": "acme::shop::WorkoutEvent", "@measures": [ "totalMinutes" ], - "@spine": "BaseEvent.program" + "@spine": "acme::shop::BaseEvent.program" } } ] diff --git a/fixtures/conformance/reporting-vocabulary/expected.json b/fixtures/conformance/reporting-vocabulary/expected.json index b61dea5ec..289e7744c 100644 --- a/fixtures/conformance/reporting-vocabulary/expected.json +++ b/fixtures/conformance/reporting-vocabulary/expected.json @@ -38,7 +38,7 @@ "relationship.association": { "name": "purchases", "@cardinality": "many", - "@objectRef": "Purchase" + "@objectRef": "acme::shop::Purchase" } } ] @@ -107,27 +107,27 @@ "@fields": [ "programId" ], - "@references": "Program" + "@references": "acme::shop::Program" } }, { "relationship.association": { "name": "program", "@cardinality": "one", - "@objectRef": "Program" + "@objectRef": "acme::shop::Program" } }, { "dimension.attribute": { "name": "program", - "@of": "Purchase.programId" + "@of": "acme::shop::Purchase.programId" } }, { "dimension.attribute": { "name": "programTitle", - "@of": "Program.title", - "@via": "Purchase.program" + "@of": "acme::shop::Program.title", + "@via": "acme::shop::Purchase.program" } }, { @@ -140,7 +140,7 @@ "quarter", "year" ], - "@of": "Purchase.purchasedAt" + "@of": "acme::shop::Purchase.purchasedAt" } }, { @@ -150,8 +150,8 @@ "month", "year" ], - "@of": "Program.createdAt", - "@via": "Purchase.program" + "@of": "acme::shop::Program.createdAt", + "@via": "acme::shop::Purchase.program" } }, { @@ -164,7 +164,7 @@ } }, "@of": [ - "Purchase.id" + "acme::shop::Purchase.id" ] } }, @@ -173,7 +173,7 @@ "name": "purchases", "@agg": "count", "@of": [ - "Purchase.id" + "acme::shop::Purchase.id" ], "@segment": "active" } @@ -184,7 +184,7 @@ "@agg": "count", "@distinct": true, "@of": [ - "Purchase.customerEmail" + "acme::shop::Purchase.customerEmail" ], "@segment": "active" } @@ -194,7 +194,7 @@ "name": "revenue", "@agg": "sum", "@of": [ - "Purchase.amountCents" + "acme::shop::Purchase.amountCents" ], "@segment": "active" } @@ -267,7 +267,7 @@ { "dimension.attribute": { "name": "program", - "@of": "WorkoutEvent.programId" + "@of": "acme::shop::WorkoutEvent.programId" } }, { @@ -276,7 +276,7 @@ "@agg": "count", "@distinct": true, "@of": [ - "WorkoutEvent.customerEmail" + "acme::shop::WorkoutEvent.customerEmail" ] } }, @@ -286,10 +286,10 @@ "@agg": "count", "@distinct": true, "@of": [ - "WorkoutEvent.programId", - "WorkoutEvent.customerEmail", - "WorkoutEvent.weekNumber", - "WorkoutEvent.dayNumber" + "acme::shop::WorkoutEvent.programId", + "acme::shop::WorkoutEvent.customerEmail", + "acme::shop::WorkoutEvent.weekNumber", + "acme::shop::WorkoutEvent.dayNumber" ] } }, @@ -298,7 +298,7 @@ "name": "lastActivityAt", "@agg": "max", "@of": [ - "WorkoutEvent.occurredAt" + "acme::shop::WorkoutEvent.occurredAt" ] } }, @@ -328,7 +328,7 @@ "@dimensions": [ "program" ], - "@from": "WorkoutEvent", + "@from": "acme::shop::WorkoutEvent", "@measures": [ "starters", "daysEngaged", @@ -351,7 +351,7 @@ } } }, - "@from": "Purchase", + "@from": "acme::shop::Purchase", "@measures": [ "purchases", "revenue" @@ -361,7 +361,7 @@ { "object.report": { "name": "StoreTotals", - "@from": "Purchase", + "@from": "acme::shop::Purchase", "@measures": [ "purchases", "buyers", diff --git a/fixtures/conformance/requirement-disposition-and-planned/expected.json b/fixtures/conformance/requirement-disposition-and-planned/expected.json index 12259985e..5897639df 100644 --- a/fixtures/conformance/requirement-disposition-and-planned/expected.json +++ b/fixtures/conformance/requirement-disposition-and-planned/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::caps", "children": [ { "requirement.architectural": { "name": "MoneyIsExactMinorUnits", + "package": "acme::caps", "@counterexample": "A cent that disappears when a total is split three ways", "@implementedBy": [ "acme::shop::Invoice" @@ -16,6 +16,7 @@ { "requirement.architectural": { "name": "Security", + "package": "acme::caps", "@counterexample": "A record readable by someone with no claim to it", "@level": 1, "@statement": "The system protects the data it holds and the actions it permits", @@ -54,6 +55,7 @@ { "requirement.functional": { "name": "Billing", + "package": "acme::caps", "@counterexample": "A charge nobody can explain", "@level": 1, "@statement": "A customer is billed for what they bought and can see why", @@ -108,6 +110,7 @@ { "object.entity": { "name": "Invoice", + "package": "acme::shop", "children": [ { "source.rdb": { diff --git a/fixtures/conformance/requirement-levels-and-nesting/expected.json b/fixtures/conformance/requirement-levels-and-nesting/expected.json index 2d8e03402..b03acfef8 100644 --- a/fixtures/conformance/requirement-levels-and-nesting/expected.json +++ b/fixtures/conformance/requirement-levels-and-nesting/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "acme::caps", "children": [ { "requirement.functional": { "name": "Storefront", + "package": "acme::caps", "@counterexample": "Nothing can be sold", "@level": 1, "@statement": "The storefront solution", @@ -68,6 +68,7 @@ { "requirement.architectural": { "name": "UuidPrimaryKeys", + "package": "acme::caps", "@counterexample": "An entity keyed by a composite string", "@implementedBy": [ "acme::shop::Order" @@ -79,6 +80,7 @@ { "requirement.architectural": { "name": "SoftDeleteEverywhere", + "package": "acme::caps", "@counterexample": "A hard delete on a table that promised its rows were recoverable", "@statement": "Every table carries a soft-delete flag rather than deleting rows", "@status": "planned" @@ -87,6 +89,7 @@ { "object.entity": { "name": "Order", + "package": "acme::shop", "children": [ { "source.rdb": { diff --git a/fixtures/conformance/reverse-finders-same-pair/expected.json b/fixtures/conformance/reverse-finders-same-pair/expected.json index 351b3b3a2..c42047a4e 100644 --- a/fixtures/conformance/reverse-finders-same-pair/expected.json +++ b/fixtures/conformance/reverse-finders-same-pair/expected.json @@ -102,14 +102,14 @@ "relationship.association": { "name": "currentScene", "@cardinality": "one", - "@objectRef": "Scene" + "@objectRef": "game::Scene" } }, { "relationship.association": { "name": "player", "@cardinality": "one", - "@objectRef": "Player" + "@objectRef": "game::Player" } }, { @@ -127,7 +127,7 @@ "@fields": [ "currentSceneId" ], - "@references": "Scene" + "@references": "game::Scene" } }, { @@ -136,7 +136,7 @@ "@fields": [ "lastOpeningNarrativeSceneId" ], - "@references": "Scene" + "@references": "game::Scene" } }, { @@ -145,7 +145,7 @@ "@fields": [ "transitioningFromSceneId" ], - "@references": "Scene" + "@references": "game::Scene" } }, { @@ -154,7 +154,7 @@ "@fields": [ "playerId" ], - "@references": "Player" + "@references": "game::Player" } } ] @@ -163,4 +163,3 @@ ] } } - diff --git a/fixtures/conformance/round-trip-exclusions.json b/fixtures/conformance/round-trip-exclusions.json new file mode 100644 index 000000000..6299a0889 --- /dev/null +++ b/fixtures/conformance/round-trip-exclusions.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "notes": "Gate 2 (round trip, ADR-0059): fixtures whose expected.json is not a loadable model on its own, so loading it and serializing it again cannot reproduce it. Each entry names why.", + "exclusions": {} +} diff --git a/fixtures/conformance/serialize-multi-package-document/expected-effective.json b/fixtures/conformance/serialize-multi-package-document/expected-effective.json new file mode 100644 index 000000000..9a9ece8eb --- /dev/null +++ b/fixtures/conformance/serialize-multi-package-document/expected-effective.json @@ -0,0 +1,65 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Product", + "package": "shop::catalog", + "children": [ + { + "field.string": { + "name": "sku" + } + } + ] + } + }, + { + "object.value": { + "name": "Order", + "package": "shop::orders", + "children": [ + { + "field.object": { + "name": "customer", + "@objectRef": "shop::people::Customer" + } + }, + { + "field.object": { + "name": "product", + "@objectRef": "shop::catalog::Product" + } + } + ] + } + }, + { + "object.value": { + "name": "Note", + "package": "shop::orders", + "children": [ + { + "field.string": { + "name": "text" + } + } + ] + } + }, + { + "object.value": { + "name": "Customer", + "package": "shop::people", + "children": [ + { + "field.string": { + "name": "email" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/serialize-multi-package-document/expected.json b/fixtures/conformance/serialize-multi-package-document/expected.json new file mode 100644 index 000000000..9a9ece8eb --- /dev/null +++ b/fixtures/conformance/serialize-multi-package-document/expected.json @@ -0,0 +1,65 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Product", + "package": "shop::catalog", + "children": [ + { + "field.string": { + "name": "sku" + } + } + ] + } + }, + { + "object.value": { + "name": "Order", + "package": "shop::orders", + "children": [ + { + "field.object": { + "name": "customer", + "@objectRef": "shop::people::Customer" + } + }, + { + "field.object": { + "name": "product", + "@objectRef": "shop::catalog::Product" + } + } + ] + } + }, + { + "object.value": { + "name": "Note", + "package": "shop::orders", + "children": [ + { + "field.string": { + "name": "text" + } + } + ] + } + }, + { + "object.value": { + "name": "Customer", + "package": "shop::people", + "children": [ + { + "field.string": { + "name": "email" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/serialize-multi-package-document/input/meta.catalog.json b/fixtures/conformance/serialize-multi-package-document/input/meta.catalog.json new file mode 100644 index 000000000..8a9cd6f12 --- /dev/null +++ b/fixtures/conformance/serialize-multi-package-document/input/meta.catalog.json @@ -0,0 +1,19 @@ +{ + "metadata.root": { + "package": "shop::catalog", + "children": [ + { + "object.value": { + "name": "Product", + "children": [ + { + "field.string": { + "name": "sku" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/serialize-multi-package-document/input/meta.orders.json b/fixtures/conformance/serialize-multi-package-document/input/meta.orders.json new file mode 100644 index 000000000..22d46b00e --- /dev/null +++ b/fixtures/conformance/serialize-multi-package-document/input/meta.orders.json @@ -0,0 +1,38 @@ +{ + "metadata.root": { + "package": "shop::orders", + "children": [ + { + "object.value": { + "name": "Order", + "children": [ + { + "field.object": { + "name": "customer", + "@objectRef": "shop::people::Customer" + } + }, + { + "field.object": { + "name": "product", + "@objectRef": "shop::catalog::Product" + } + } + ] + } + }, + { + "object.value": { + "name": "Note", + "children": [ + { + "field.string": { + "name": "text" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/serialize-multi-package-document/input/meta.people.json b/fixtures/conformance/serialize-multi-package-document/input/meta.people.json new file mode 100644 index 000000000..b6ffab725 --- /dev/null +++ b/fixtures/conformance/serialize-multi-package-document/input/meta.people.json @@ -0,0 +1,19 @@ +{ + "metadata.root": { + "package": "shop::people", + "children": [ + { + "object.value": { + "name": "Customer", + "children": [ + { + "field.string": { + "name": "email" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/serialize-multi-package-document/providers.json b/fixtures/conformance/serialize-multi-package-document/providers.json new file mode 100644 index 000000000..8ec88fc09 --- /dev/null +++ b/fixtures/conformance/serialize-multi-package-document/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types"] diff --git a/fixtures/conformance/value-reference-navigation-only/expected.json b/fixtures/conformance/value-reference-navigation-only/expected.json index 78dc1db1f..46d335030 100644 --- a/fixtures/conformance/value-reference-navigation-only/expected.json +++ b/fixtures/conformance/value-reference-navigation-only/expected.json @@ -48,7 +48,7 @@ "@fields": [ "tableId" ], - "@references": "Table" + "@references": "gaming::poker::Table" } } ] diff --git a/fixtures/conformance/xpkg-collision-bare-same-package/expected-effective.json b/fixtures/conformance/xpkg-collision-bare-same-package/expected-effective.json index 4dc953b29..335adf93d 100644 --- a/fixtures/conformance/xpkg-collision-bare-same-package/expected-effective.json +++ b/fixtures/conformance/xpkg-collision-bare-same-package/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::home", "children": [ { "object.entity": { "name": "Base", + "package": "xpkg::home", "abstract": true, "children": [ { @@ -23,7 +23,8 @@ { "object.entity": { "name": "Gadget", - "extends": "Base", + "package": "xpkg::home", + "extends": "xpkg::home::Base", "children": [ { "field.long": { @@ -59,6 +60,7 @@ { "object.entity": { "name": "Base", + "package": "xpkg::other", "abstract": true, "children": [ { @@ -77,4 +79,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-collision-bare-same-package/expected.json b/fixtures/conformance/xpkg-collision-bare-same-package/expected.json index 6f918a471..e1c64205d 100644 --- a/fixtures/conformance/xpkg-collision-bare-same-package/expected.json +++ b/fixtures/conformance/xpkg-collision-bare-same-package/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::home", "children": [ { "object.entity": { "name": "Base", + "package": "xpkg::home", "abstract": true, "children": [ { @@ -23,7 +23,8 @@ { "object.entity": { "name": "Gadget", - "extends": "Base", + "package": "xpkg::home", + "extends": "xpkg::home::Base", "children": [ { "source.rdb": { @@ -49,6 +50,7 @@ { "object.entity": { "name": "Base", + "package": "xpkg::other", "abstract": true, "children": [ { @@ -67,4 +69,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-collision-extends-fqn/expected-effective.json b/fixtures/conformance/xpkg-collision-extends-fqn/expected-effective.json index 2f9af7361..5f8f0d0c8 100644 --- a/fixtures/conformance/xpkg-collision-extends-fqn/expected-effective.json +++ b/fixtures/conformance/xpkg-collision-extends-fqn/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::a", "children": [ { "object.entity": { "name": "Base", + "package": "xpkg::a", "abstract": true, "children": [ { @@ -23,6 +23,7 @@ { "object.entity": { "name": "Base", + "package": "xpkg::b", "abstract": true, "children": [ { @@ -41,6 +42,7 @@ { "object.entity": { "name": "Widget", + "package": "xpkg::c", "extends": "xpkg::b::Base", "children": [ { @@ -77,4 +79,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-collision-extends-fqn/expected.json b/fixtures/conformance/xpkg-collision-extends-fqn/expected.json index 25dc443ce..cf81eae6b 100644 --- a/fixtures/conformance/xpkg-collision-extends-fqn/expected.json +++ b/fixtures/conformance/xpkg-collision-extends-fqn/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::a", "children": [ { "object.entity": { "name": "Base", + "package": "xpkg::a", "abstract": true, "children": [ { @@ -23,6 +23,7 @@ { "object.entity": { "name": "Base", + "package": "xpkg::b", "abstract": true, "children": [ { @@ -41,6 +42,7 @@ { "object.entity": { "name": "Widget", + "package": "xpkg::c", "extends": "xpkg::b::Base", "children": [ { @@ -67,4 +69,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-field-object/expected-effective.json b/fixtures/conformance/xpkg-field-object/expected-effective.json index 8dd657d47..5f05bebd5 100644 --- a/fixtures/conformance/xpkg-field-object/expected-effective.json +++ b/fixtures/conformance/xpkg-field-object/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::common", "children": [ { "object.value": { "name": "Address", + "package": "xpkg::common", "children": [ { "field.string": { @@ -22,6 +22,7 @@ { "object.entity": { "name": "Shipment", + "package": "xpkg::orders", "children": [ { "source.rdb": { @@ -54,4 +55,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-field-object/expected.json b/fixtures/conformance/xpkg-field-object/expected.json index 8dd657d47..5f05bebd5 100644 --- a/fixtures/conformance/xpkg-field-object/expected.json +++ b/fixtures/conformance/xpkg-field-object/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::common", "children": [ { "object.value": { "name": "Address", + "package": "xpkg::common", "children": [ { "field.string": { @@ -22,6 +22,7 @@ { "object.entity": { "name": "Shipment", + "package": "xpkg::orders", "children": [ { "source.rdb": { @@ -54,4 +55,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-m2n-collision/expected-effective.json b/fixtures/conformance/xpkg-m2n-collision/expected-effective.json index 7f37fb36d..54cc665e9 100644 --- a/fixtures/conformance/xpkg-m2n-collision/expected-effective.json +++ b/fixtures/conformance/xpkg-m2n-collision/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::partner", "children": [ { "object.entity": { "name": "Account", + "package": "xpkg::partner", "children": [ { "field.long": { @@ -30,6 +30,7 @@ { "object.entity": { "name": "AccountLink", + "package": "xpkg::partner", "children": [ { "field.long": { @@ -78,6 +79,7 @@ { "object.entity": { "name": "Account", + "package": "xpkg::store", "children": [ { "field.long": { @@ -111,6 +113,7 @@ { "object.entity": { "name": "AccountLink", + "package": "xpkg::store", "children": [ { "field.long": { diff --git a/fixtures/conformance/xpkg-m2n-collision/expected.json b/fixtures/conformance/xpkg-m2n-collision/expected.json index 7f37fb36d..54cc665e9 100644 --- a/fixtures/conformance/xpkg-m2n-collision/expected.json +++ b/fixtures/conformance/xpkg-m2n-collision/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::partner", "children": [ { "object.entity": { "name": "Account", + "package": "xpkg::partner", "children": [ { "field.long": { @@ -30,6 +30,7 @@ { "object.entity": { "name": "AccountLink", + "package": "xpkg::partner", "children": [ { "field.long": { @@ -78,6 +79,7 @@ { "object.entity": { "name": "Account", + "package": "xpkg::store", "children": [ { "field.long": { @@ -111,6 +113,7 @@ { "object.entity": { "name": "AccountLink", + "package": "xpkg::store", "children": [ { "field.long": { diff --git a/fixtures/conformance/xpkg-m2n-through/expected-effective.json b/fixtures/conformance/xpkg-m2n-through/expected-effective.json index ebd7cd49c..1ac8e1401 100644 --- a/fixtures/conformance/xpkg-m2n-through/expected-effective.json +++ b/fixtures/conformance/xpkg-m2n-through/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::blog", "children": [ { "object.entity": { "name": "Post", + "package": "xpkg::blog", "children": [ { "source.rdb": { @@ -38,6 +38,7 @@ { "object.entity": { "name": "Tag", + "package": "xpkg::blog", "children": [ { "source.rdb": { @@ -63,6 +64,7 @@ { "object.entity": { "name": "PostTag", + "package": "xpkg::join", "children": [ { "source.rdb": { @@ -116,4 +118,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-m2n-through/expected.json b/fixtures/conformance/xpkg-m2n-through/expected.json index ebd7cd49c..1ac8e1401 100644 --- a/fixtures/conformance/xpkg-m2n-through/expected.json +++ b/fixtures/conformance/xpkg-m2n-through/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::blog", "children": [ { "object.entity": { "name": "Post", + "package": "xpkg::blog", "children": [ { "source.rdb": { @@ -38,6 +38,7 @@ { "object.entity": { "name": "Tag", + "package": "xpkg::blog", "children": [ { "source.rdb": { @@ -63,6 +64,7 @@ { "object.entity": { "name": "PostTag", + "package": "xpkg::join", "children": [ { "source.rdb": { @@ -116,4 +118,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-origin-passthrough/expected-effective.json b/fixtures/conformance/xpkg-origin-passthrough/expected-effective.json index e31b28377..becb1a339 100644 --- a/fixtures/conformance/xpkg-origin-passthrough/expected-effective.json +++ b/fixtures/conformance/xpkg-origin-passthrough/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::catalog", "children": [ { "object.entity": { "name": "Program", + "package": "xpkg::catalog", "children": [ { "source.rdb": { @@ -35,6 +35,7 @@ { "object.entity": { "name": "Enrollment", + "package": "xpkg::enroll", "children": [ { "source.rdb": { @@ -74,6 +75,7 @@ { "object.projection": { "name": "EnrollmentView", + "package": "xpkg::enroll", "children": [ { "source.rdb": { @@ -116,4 +118,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-origin-passthrough/expected.json b/fixtures/conformance/xpkg-origin-passthrough/expected.json index 9ee18498f..d91b6c688 100644 --- a/fixtures/conformance/xpkg-origin-passthrough/expected.json +++ b/fixtures/conformance/xpkg-origin-passthrough/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::catalog", "children": [ { "object.entity": { "name": "Program", + "package": "xpkg::catalog", "children": [ { "source.rdb": { @@ -35,6 +35,7 @@ { "object.entity": { "name": "Enrollment", + "package": "xpkg::enroll", "children": [ { "source.rdb": { @@ -74,6 +75,7 @@ { "object.projection": { "name": "EnrollmentView", + "package": "xpkg::enroll", "children": [ { "source.rdb": { @@ -113,4 +115,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-relationship-objectref/expected-effective.json b/fixtures/conformance/xpkg-relationship-objectref/expected-effective.json index 03d6837ea..e61675385 100644 --- a/fixtures/conformance/xpkg-relationship-objectref/expected-effective.json +++ b/fixtures/conformance/xpkg-relationship-objectref/expected-effective.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::crm", "children": [ { "object.entity": { "name": "Customer", + "package": "xpkg::crm", "children": [ { "source.rdb": { @@ -35,6 +35,7 @@ { "object.entity": { "name": "Order", + "package": "xpkg::sales", "children": [ { "source.rdb": { @@ -81,4 +82,3 @@ ] } } - diff --git a/fixtures/conformance/xpkg-relationship-objectref/expected.json b/fixtures/conformance/xpkg-relationship-objectref/expected.json index 03d6837ea..e61675385 100644 --- a/fixtures/conformance/xpkg-relationship-objectref/expected.json +++ b/fixtures/conformance/xpkg-relationship-objectref/expected.json @@ -1,10 +1,10 @@ { "metadata.root": { - "package": "xpkg::crm", "children": [ { "object.entity": { "name": "Customer", + "package": "xpkg::crm", "children": [ { "source.rdb": { @@ -35,6 +35,7 @@ { "object.entity": { "name": "Order", + "package": "xpkg::sales", "children": [ { "source.rdb": { @@ -81,4 +82,3 @@ ] } } - diff --git a/fixtures/conformance/yaml-twin-exclusions.json b/fixtures/conformance/yaml-twin-exclusions.json new file mode 100644 index 000000000..ed8349629 --- /dev/null +++ b/fixtures/conformance/yaml-twin-exclusions.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "notes": "Gate 1 (YAML twin, ADR-0059): fixtures whose input is deliberately format-native, so reading the same text as YAML is expected to report a different error. Each entry names why. Every port also skips the provider-extension-* fixtures whose test-only providers it cannot compose.", + "exclusions": { + "error-attr-child-not-object": "Asserts a JSON-shaped body error; the YAML desugar reads a scalar body as { name: } (ADR-0006 rule 2).", + "error-attr-wrong-type": "Asserts a JSON value-type error that the YAML coercion guard (ADR-0006 D2) reports under its own code.", + "error-bare-key-no-concrete-default": "A bare type key is YAML sugar for the default subtype (ADR-0006 rule 1); in canonical JSON it is an error.", + "error-bare-key-type-without-base": "A bare type key is YAML sugar for the default subtype (ADR-0006 rule 1); in canonical JSON it is an error.", + "error-child-not-object": "Asserts a JSON-shaped child error; the YAML desugar reads a scalar body as { name: } (ADR-0006 rule 2).", + "error-parse-malformed-json": "Malformed JSON is a JSON parse error; the YAML parser reports its own.", + "error-provided-not-boolean": "Asserts a JSON value-type error that the YAML coercion guard (ADR-0006 D2) reports under its own code.", + "error-root-not-object": "Asserts a JSON document-shape error; YAML reads the same document differently.", + "error-unknown-bare-child-type": "A bare type key is YAML sugar for the default subtype (ADR-0006 rule 1); in canonical JSON it is an error." + } +} diff --git a/fixtures/template-codegen-conformance/expected/Order.txt b/fixtures/template-codegen-conformance/expected/Order.txt index 5b4a9177e..eb83ca6ed 100644 --- a/fixtures/template-codegen-conformance/expected/Order.txt +++ b/fixtures/template-codegen-conformance/expected/Order.txt @@ -1,4 +1,4 @@ // Order (shop) - id: long - productId: long -rel product -> Product (one) +rel product -> shop::Product (one) diff --git a/fixtures/yaml-conformance/yaml-ref-parent-relative/expected.json b/fixtures/yaml-conformance/yaml-ref-parent-relative/expected.json new file mode 100644 index 000000000..2ed44ad05 --- /dev/null +++ b/fixtures/yaml-conformance/yaml-ref-parent-relative/expected.json @@ -0,0 +1,47 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Base", + "package": "acme", + "children": [ + { + "field.string": { + "name": "baseOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Thing", + "package": "acme", + "children": [ + { + "field.string": { + "name": "label" + } + } + ] + } + }, + { + "object.value": { + "name": "X", + "package": "acme::sub", + "extends": "acme::Base", + "children": [ + { + "field.object": { + "name": "thing", + "@objectRef": "acme::Thing" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/yaml-conformance/yaml-ref-parent-relative/input.yaml b/fixtures/yaml-conformance/yaml-ref-parent-relative/input.yaml new file mode 100644 index 000000000..1fddaa3b2 --- /dev/null +++ b/fixtures/yaml-conformance/yaml-ref-parent-relative/input.yaml @@ -0,0 +1,21 @@ +# `..::` drops one segment of the referrer's package (ADR-0059 R2): from +# acme::sub, `..::Base` and `..::Thing` name acme::Base and acme::Thing. +metadata: + package: acme + children: + - object.value: + name: Base + children: + - field.string: baseOnly + - object.value: + name: Thing + children: + - field.string: label + - object.value: + name: X + package: acme::sub + extends: ..::Base + children: + - field.object: + name: thing + objectRef: ..::Thing diff --git a/fixtures/yaml-conformance/yaml-ref-root-escape/expected.json b/fixtures/yaml-conformance/yaml-ref-root-escape/expected.json new file mode 100644 index 000000000..c83d5894b --- /dev/null +++ b/fixtures/yaml-conformance/yaml-ref-root-escape/expected.json @@ -0,0 +1,52 @@ +{ + "metadata.root": { + "children": [ + { + "object.value": { + "name": "Apple", + "children": [ + { + "field.string": { + "name": "rootOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "Apple", + "package": "acme", + "children": [ + { + "field.string": { + "name": "localOnly" + } + } + ] + } + }, + { + "object.value": { + "name": "RootApple", + "package": "acme", + "extends": "::Apple" + } + }, + { + "object.value": { + "name": "Basket", + "package": "acme", + "children": [ + { + "field.object": { + "name": "fruit", + "@objectRef": "::Apple" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/yaml-conformance/yaml-ref-root-escape/input.yaml b/fixtures/yaml-conformance/yaml-ref-root-escape/input.yaml new file mode 100644 index 000000000..6fa87052b --- /dev/null +++ b/fixtures/yaml-conformance/yaml-ref-root-escape/input.yaml @@ -0,0 +1,24 @@ +# A root-level Apple shadowed by acme::Apple. `::Apple` from package acme must +# reach the root-level object, never the local one (ADR-0059 R2). +metadata: + children: + - object.value: + name: Apple + children: + - field.string: rootOnly + - object.value: + name: Apple + package: acme + children: + - field.string: localOnly + - object.value: + name: RootApple + package: acme + extends: ::Apple + - object.value: + name: Basket + package: acme + children: + - field.object: + name: fruit + objectRef: ::Apple diff --git a/fixtures/yaml-conformance/yaml-reporting-via-of-bare/expected.json b/fixtures/yaml-conformance/yaml-reporting-via-of-bare/expected.json new file mode 100644 index 000000000..8b7375937 --- /dev/null +++ b/fixtures/yaml-conformance/yaml-reporting-via-of-bare/expected.json @@ -0,0 +1,117 @@ +{ + "metadata.root": { + "children": [ + { + "object.entity": { + "name": "Realm", + "package": "acme::world", + "children": [ + { + "source.rdb": { + "@table": "realms" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "name", + "@required": true + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Game", + "package": "acme::play", + "children": [ + { + "source.rdb": { + "@table": "games" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "realmId", + "@required": true + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "fkRealm", + "@fields": [ + "realmId" + ], + "@references": "acme::world::Realm" + } + }, + { + "dimension.attribute": { + "name": "realmName", + "@of": "acme::world::Realm.name", + "@via": "acme::play::Game.fkRealm" + } + }, + { + "measure.aggregate": { + "name": "games", + "@agg": "count", + "@of": [ + "acme::play::Game.id" + ] + } + } + ] + } + }, + { + "object.report": { + "name": "GamesByRealm", + "package": "acme::reports", + "@dimensions": [ + "realmName" + ], + "@from": "acme::play::Game", + "@measures": [ + "games" + ], + "@spine": "acme::play::Game.fkRealm", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_games_by_realm" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/yaml-conformance/yaml-reporting-via-of-bare/input.yaml b/fixtures/yaml-conformance/yaml-reporting-via-of-bare/input.yaml new file mode 100644 index 000000000..55dbe4df5 --- /dev/null +++ b/fixtures/yaml-conformance/yaml-reporting-via-of-bare/input.yaml @@ -0,0 +1,53 @@ +# The rc.5 model written as sigil-free YAML: after `via`, a bare `of` owner +# resolves in the package of the entity the via reaches (ADR-0059 R3). +metadata: + children: + - object.entity: + name: Realm + package: acme::world + children: + - source.rdb: + table: realms + - field.long: id + - field.string: + name: name + required: true + - identity.primary: + name: id + fields: [id] + - object.entity: + name: Game + package: acme::play + children: + - source.rdb: + table: games + - field.long: id + - field.long: + name: realmId + required: true + - identity.primary: + name: id + fields: [id] + - identity.reference: + name: fkRealm + references: acme::world::Realm + fields: [realmId] + - dimension.attribute: + name: realmName + of: Realm.name + via: Game.fkRealm + - measure.aggregate: + name: games + agg: count + of: Game.id + - object.report: + name: GamesByRealm + package: acme::reports + from: acme::play::Game + spine: Game.fkRealm + dimensions: [realmName] + measures: [games] + children: + - source.rdb: + kind: view + view: v_games_by_realm diff --git a/scripts/check-doc-examples.ts b/scripts/check-doc-examples.ts index 8b8482799..37ad016a7 100644 --- a/scripts/check-doc-examples.ts +++ b/scripts/check-doc-examples.ts @@ -162,7 +162,6 @@ const FAIL_CODES = new Set([ "ERR_PROJECTION_SOURCE_WRITABLE", "ERR_PROJECTION_INHERITED_SOURCE", "ERR_FILTERABLE_UNSUPPORTED_SUBTYPE", - "ERR_RELATIVE_REF_IN_CANONICAL", "ERR_TOO_MANY_OCCURRENCES", "ERR_UNKNOWN_EXPR_NODE", "ERR_YAML_COERCION", diff --git a/server/csharp/MetaObjects.Cli/FmtCommand.cs b/server/csharp/MetaObjects.Cli/FmtCommand.cs index 280c75107..db2485929 100644 --- a/server/csharp/MetaObjects.Cli/FmtCommand.cs +++ b/server/csharp/MetaObjects.Cli/FmtCommand.cs @@ -81,7 +81,7 @@ public static FormatFileResult FormatFile(string content, TypeRegistry registry, return new FormatFileResult(false, overlay, null, string.Join("; ", result.Errors.Select(e => e.Message))); } - return new FormatFileResult(true, false, SerializerJson.CanonicalSerialize(result.Root), null); + return new FormatFileResult(true, false, SerializerJson.CanonicalSerialize(result.Root, RefSpelling.Authored), null); } /// diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index c7a333b12..df4cf4f60 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -540,7 +540,7 @@ static int RunVerify(string[] rest) { Console.Error.WriteLine($"dotnet meta verify --codegen: drift ({c.DriftedFiles.Count} file(s) differ from a fresh regen):"); foreach (var line in c.Lines) Console.Error.WriteLine($" {line}"); - Console.Error.WriteLine("Run 'dotnet meta gen' to regenerate, then commit the result."); + foreach (var line in MetaObjects.Codegen.CodegenDrift.Remedy(c)) Console.Error.WriteLine(line); } } diff --git a/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs index 478cbc879..6756ef9f1 100644 --- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs +++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs @@ -442,6 +442,9 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts, out boo // --out reading the SAME root's naming.columns, so this regen agrees with it // by construction rather than needing a flag told to match. ColumnNamingStrategy = load.Root.PhysicalNaming.Columns, + // The manifest gen keeps: an extra file is classified by what gen recorded writing. + GenStateDir = Path.Combine(GenCommand.ProjectRootFor(opts.MetadataDir), ".metaobjects", ".gen-state"), + ProjectRoot = GenCommand.ProjectRootFor(opts.MetadataDir), }; return Codegen.CodegenDrift.Compute(config, load.Root, generators); } @@ -493,8 +496,8 @@ public static Outcome Run(LoadResult load, string templatesRoot) // ADR-0042/#228: a bare @payloadRef resolves in the template's OWN package FIRST — // never whichever same-short-named object.value another package happens to declare // (the loader validates @payloadRef package-locally too; verify must agree). - var referrerPkg = global::MetaObjects.NamingRefs.EffectivePackage(tmpl); - var fields = PayloadFieldTree.Build(load.Root, payloadRef, referrerPkg); + var payloadShape = global::MetaObjects.NamingRefs.RefTarget(tmpl, TEMPLATE_ATTR_PAYLOAD_REF); + var fields = payloadShape is null ? [] : PayloadFieldTree.Build(payloadShape); if (fields.Count == 0) { errors.Add(new Drift(tmpl.Name, kind, ERR_PAYLOAD_REF_UNRESOLVED, payloadRef)); diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossPackageBindingConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossPackageBindingConformanceTests.cs new file mode 100644 index 000000000..87cdf2a3e --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossPackageBindingConformanceTests.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Core.Relationship; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; +using static MetaObjects.Core.Identity.IdentityConstants; +using static MetaObjects.Core.Relationship.RelationshipConstants; + +namespace MetaObjects.Codegen.Tests; + +/// +/// Cross-port codegen conformance (C#) for ADR-0059 Decision 7. Loads +/// fixtures/codegen-conformance/cross-package-binding/ and checks the decision table +/// expected-bindings.json: every reference binds the object the table names, and the +/// M:N / to-one resolution the generated EF Core and route code is built from lands on that +/// object's table, junction and join fields rather than on a same-short-named object in another +/// package (or in the package of the entity that inherits the attribute). +/// +public class CrossPackageBindingConformanceTests +{ + private static string FixtureDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "fixtures"))) + dir = dir.Parent; + Assert.NotNull(dir); + return Path.Combine(dir!.FullName, "fixtures", "codegen-conformance", "cross-package-binding"); + } + + private static MetaRoot Load() + { + var load = MetaDataLoader.FromDirectory(Path.Combine(FixtureDir(), "input")); + Assert.Empty(load.Errors); + return load.Root; + } + + private static JsonElement Expected(string section) + { + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir(), "expected-bindings.json"))); + return doc.RootElement.GetProperty(section).Clone(); + } + + private static (MetaObject Owner, string Member) Split(string address) + { + int dot = address.LastIndexOf('.'); + return (default!, address[(dot + 1)..]); + } + + private static MetaObject Entity(MetaRoot root, string resolutionKey) => + Assert.Single(root.Objects(), o => o.ResolutionKey() == resolutionKey); + + private static MetaObject OwnerOf(MetaRoot root, string address) => + Entity(root, address[..address.LastIndexOf('.')]); + + [Fact] + public void Every_identity_reference_binds_the_object_the_table_names() + { + var root = Load(); + foreach (var entry in Expected("references").EnumerateObject()) + { + var owner = OwnerOf(root, entry.Name); + string member = Split(entry.Name).Member; + var reference = Assert.Single( + owner.ReferenceIdentities(), r => r.Name == member); + var bound = NamingRefs.RefTarget(reference, IDENTITY_REFERENCE_ATTR_REFERENCES); + Assert.True(bound is not null, $"{entry.Name} bound nothing"); + Assert.Equal(entry.Value.GetString(), bound!.ResolutionKey()); + } + } + + [Fact] + public void Every_relationship_resolves_to_the_target_table_junction_and_join_fields_the_table_names() + { + var root = Load(); + foreach (var entry in Expected("relations").EnumerateObject()) + { + var owner = OwnerOf(root, entry.Name); + string member = Split(entry.Name).Member; + var expected = entry.Value; + string label = entry.Name; + + if (expected.GetProperty("cardinality").GetString() == "many") + { + var nav = Assert.Single( + M2MNavigationBuilder.For(owner, root), n => n.Relationship.Name == member); + Assert.Equal(expected.GetProperty("target").GetString(), nav.Target.ResolutionKey()); + Assert.Equal(expected.GetProperty("targetTable").GetString(), nav.Target.DbTable); + Assert.Equal(expected.GetProperty("junction").GetString(), nav.Junction.ResolutionKey()); + Assert.Equal(expected.GetProperty("junctionTable").GetString(), nav.Junction.DbTable); + Assert.Equal(expected.GetProperty("sourceJoinField").GetString(), nav.SourceField); + Assert.Equal(expected.GetProperty("targetJoinField").GetString(), nav.TargetField); + + var fields = M2MDerivation.DeriveM2MFields(nav.Relationship, owner, root); + Assert.Equal(nav.SourceField, fields.SourceField); + Assert.Equal(nav.TargetField, fields.TargetField); + continue; + } + + var rel = Assert.Single(owner.Relationships(), r => r.Name == member); + var target = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF); + Assert.True(target is not null, $"{label} bound nothing"); + Assert.Equal(expected.GetProperty("target").GetString(), target!.ResolutionKey()); + Assert.Equal(expected.GetProperty("targetTable").GetString(), ((MetaObject)target).DbTable); + + var reference = RelationshipReferences.ResolveRelationshipReference( + owner, rel.Name, target, rel.SourceRefField); + Assert.True(reference is not null, $"{label} paired no identity.reference"); + Assert.Equal(expected.GetProperty("fkField").GetString(), reference!.Fields[0]); + } + } + + private static Dictionary Generated(MetaRoot root) + { + var ctx = new GenContext + { + Entities = root.Objects(), + Root = root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Gen" }, + }; + var files = new Dictionary(); + foreach (var gen in new IGenerator[] { new EntityGenerator(), new DbContextGenerator(), new RoutesGenerator() }) + foreach (var file in gen.Generate(ctx)) + files.TryAdd(file.Path, file.Content); + return files; + } + + [Fact] + public void Generated_routes_and_entities_use_the_bound_target_of_each_navigation() + { + var root = Load(); + var files = Generated(root); + string navigating = string.Concat( + files["Post.g.cs"], files["PostRoutes.g.cs"], files["Order.g.cs"], files["OrderRoutes.g.cs"], files["AppDbContext.g.cs"]); + + // The same-short-named objects in the wrong package own a string key and these tables. + Assert.DoesNotContain("a_tags", navigating); + Assert.DoesNotContain("acme_labels", navigating); + Assert.DoesNotContain("acme_customers", navigating); + Assert.DoesNotContain("EF.Property", navigating); + + string post = files["PostRoutes.g.cs"]; + Assert.Contains("/posts/{id}/tags", post); + Assert.Contains("relatedIds.Contains(EF.Property(t, \"Id\"))", post); + Assert.DoesNotContain("\"Code\"", post); + Assert.Contains("ICollection Tags", files["Post.g.cs"]); + Assert.Contains("FeaturedTagId", files["Post.g.cs"]); + + string order = files["OrderRoutes.g.cs"]; + Assert.Contains("/orders/{id}/labels", order); + Assert.Contains("relatedIds.Contains(EF.Property(t, \"Id\"))", order); + Assert.DoesNotContain("\"Code\"", order); + string orderEntity = files["Order.g.cs"]; + Assert.Contains("ICollection public bool EmitAbstractShapes { get; init; } = false; + /// + /// Delete, after the writes, each file under this run's output directories that the hash + /// manifest records writing and that no generator emitted this run, when it is still + /// byte-for-byte what was recorded; an edited one is refused by name + /// (). Needs and , + /// and never runs under Baseline = "adopt". + /// + /// Only a run of the WHOLE generator list may sweep: a subset leaves out the files of the + /// generators it did not run, and they would all look orphaned. dotnet meta gen sets + /// it on a run with no --generators narrowing. Defaults to false, so a + /// programmatic caller, which may be running any subset, deletes nothing it did not ask to. + /// + /// + public bool SweepOrphans { get; init; } = false; + /// /// The C# type name of the EF Core DbContext that callable (stored-proc / table-function) /// wrappers take as their first parameter. Defaults to AppDbContext (the name the @@ -296,6 +311,49 @@ public interface IGenerator /// under --out. /// string? OutDir => null; + + /// + /// What a run does with a file of this entry's that was edited by hand since it was + /// generated. , the default, refuses to replace it (and + /// refuses to delete it once no generator emits it). + /// replaces it with fresh output and records the new content, and lets the orphan sweep + /// delete it, naming each file it discards. Set it on an entry in + /// codegen/Generators.cs with . + /// + HandEdits HandEdits => HandEdits.Keep; +} + +/// A generator entry's handEdits setting: what a run does with a hand-edited file. +public enum HandEdits +{ + /// Refuse to replace or delete a hand-edited file (the default). + Keep, + /// Replace a hand-edited file with fresh output, and delete a hand-edited orphan. + Overwrite, +} + +/// Per-entry settings for any . +public static class GeneratorExtensions +{ + /// The same entry with its setting replaced: + /// new EntityGenerator().WithHandEdits(HandEdits.Overwrite). + public static IGenerator WithHandEdits(this IGenerator generator, HandEdits handEdits) => + new HandEditsGenerator(generator is HandEditsGenerator h ? h.Inner : generator, handEdits); +} + +/// An entry with its handEdits setting; everything else is the wrapped entry's. +internal sealed class HandEditsGenerator(IGenerator inner, HandEdits handEdits) : IGenerator +{ + /// The wrapped entry. + public IGenerator Inner { get; } = inner; + /// + public string Name => Inner.Name; + /// + public string? OutDir => Inner.OutDir; + /// + public HandEdits HandEdits { get; } = handEdits; + /// + public IEnumerable Generate(GenContext ctx) => Inner.Generate(ctx); } /// One-file-per-entity convenience base (entity / queries / routes generators). diff --git a/server/csharp/MetaObjects.Codegen/Generators/CallableGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/CallableGenerator.cs index ff98c3b6f..fa4a19313 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/CallableGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/CallableGenerator.cs @@ -84,8 +84,7 @@ protected override EmittedFile GenerateOne(MetaObject entity, GenContext ctx) MetaObject? argsObject = null; if (argsObjectName is not null) { - argsObject = global::MetaObjects.NamingRefs.ResolveObjectRef( - ctx.Root, argsObjectName, global::MetaObjects.NamingRefs.EffectivePackage(entity)) as MetaObject; + argsObject = global::MetaObjects.NamingRefs.RefTarget(source, SOURCE_ATTR_PARAMETER_REF) as MetaObject; if (argsObject is null) ctx.Warn( $"{Name}: callable \"{entity.Name}\" @parameterRef \"{argsObjectName}\" did not resolve — " + diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs index 45add1e90..8ba5e1567 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs @@ -11,6 +11,7 @@ using MetaObjects.Persistence.Db; using static MetaObjects.Core.Field.FieldConstants; using static MetaObjects.Core.Relationship.RelationshipConstants; +using static MetaObjects.Core.Identity.IdentityConstants; namespace MetaObjects.Codegen.Generators; @@ -494,11 +495,8 @@ private static string FkKey(MetaReferenceIdentity reference) => private string? ReferenceConfig( string owner, MetaObject entity, MetaReferenceIdentity reference, GenContext ctx) { - if (reference.TargetEntity is not { } targetName) return null; - // ADR-0042: a bare @references resolves in the DECLARING owner's package. - var target = NamingRefs.ResolveObjectRef( - ctx.Root, targetName, - NamingRefs.EffectivePackage(reference.Parent ?? entity)) as MetaObject; + if (reference.TargetEntity is null) return null; + var target = NamingRefs.RefTarget(reference, IDENTITY_REFERENCE_ATTR_REFERENCES) as MetaObject; if (target is null || !target.IsEntity() || !InstanceArtifacts.EmitsInstanceArtifacts(target)) return null; diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index e67be7609..93dd62dd9 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -19,6 +19,7 @@ using static MetaObjects.Core.Field.FieldConstants; using static MetaObjects.Core.Validator.ValidatorConstants; using static MetaObjects.Persistence.Origin.OriginConstants; +using static MetaObjects.Persistence.Source.SourceConstants; namespace MetaObjects.Codegen.Generators; @@ -1485,7 +1486,7 @@ protected string MapProperty(MetaObject owner, MetaField field, GenContext ctx, if (agg.Of is not { } of) return null; var dot = of.LastIndexOf('.'); if (dot <= 0 || dot == of.Length - 1) return null; - var sourceObj = ctx.Root.FindObject(CSharpNaming.StripPkg(of[..dot])); + var sourceObj = global::MetaObjects.NamingRefs.RefTarget(agg, ORIGIN_AGGREGATE_ATTR_OF) as MetaObject; var sourceField = sourceObj?.FindField(of[(dot + 1)..]); return sourceField is null ? null : CSharpNaming.ScalarForField(sourceField); } @@ -1530,9 +1531,8 @@ protected static List ReferencedValueObjects(IReadOnlyList 0) { diff --git a/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs b/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs index 763f2df89..14f77a65d 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs @@ -41,31 +41,13 @@ public static class ExtractDelegateEmitter // ========================================================================= /// - /// Resolve an OBJECT reference under the ADR-0042 package-local contract (FQN-exact when - /// qualified; else the referrer's own package, else root-level) — never a bare-tail - /// fallback (the #219/#228 "wrong node" class: under a cross-package short-name collision, - /// a bare-tail match binds WHICHEVER same-named object happens to load first, regardless of - /// which package actually points at). - /// - public static MetaData? FindObject(MetaData root, string name, string referrerPkg) => - global::MetaObjects.NamingRefs.ResolveObjectRef(root, name, referrerPkg); - - /// - /// The @objectRef target VO for a nested-object field, or null when unresolvable. - /// The referrer package is the FIELD's own declaring package (ADR-0042 — the field's parent - /// object, not necessarily the walk's root VO, since a field may be inherited via extends - /// from an abstract VO declared in a different package). Shared with + /// The @objectRef target VO for a nested-object field, or null when unresolvable: the + /// target the loader bound (ADR-0059 R7), resolved in the package of the node that declared the + /// attribute even when the field is inherited via extends. Shared with /// (the extract tier walks the same VO graph). /// - public static MetaData? RefVo(MetaData field, MetaData root) - { - // ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr). - if (field.Attr(FIELD_ATTR_OBJECT_REF) is not string objectRef) return null; - var referrerPkg = field.Parent is not null - ? global::MetaObjects.NamingRefs.EffectivePackage(field.Parent) - : ""; - return FindObject(root, objectRef, referrerPkg); - } + public static MetaData? RefVo(MetaData field, MetaData root) => + global::MetaObjects.NamingRefs.RefTarget(field, FIELD_ATTR_OBJECT_REF); /// /// True iff the field is a nested object reference (field.object) — distinct from the @@ -217,7 +199,7 @@ public static string DelegatingMembers(MetaData vo, MetaData root, GenConfig con // duplicate-type error, since they land in different namespaces), yet // root.FindObject(bareName) at RUNTIME silently binds whichever one loaded first — // reachable, compiling, silent wrong-node extraction. Bake the FULL ResolutionKey (FQN) - // and resolve via the canonical NamingRefs.ResolveObjectRef matcher (FQN-exact, + // and resolve via the canonical NamingRefs.LookupCanonical lookup (ADR-0059 R7) (FQN-exact, // load-order-independent) ONLY when this payload's bare name is actually AMBIGUOUS at // the metadata root (more than one root-level object.* shares it — the SAME domain // MetaRoot.FindObject itself searches). A UNIQUE (the overwhelmingly common) payload @@ -256,11 +238,11 @@ public static string DelegatingMembers(MetaData vo, MetaData root, GenConfig con sb.AppendLine($" public static global::MetaObjects.Render.Extract.ExtractionResult<{rootMirror}> ExtractLenient("); sb.AppendLine($" global::MetaObjects.Meta.MetaRoot root, string text, ExtractOptions? opts = null)"); sb.AppendLine(" {"); - // NamingRefs.ResolveObjectRef returns MetaData?; ExtractLenient(MetaObject, ...) needs a + // NamingRefs.LookupCanonical returns MetaData?; ExtractLenient(MetaObject, ...) needs a // MetaObject — the "as" narrows (never a hard cast throw) matching root.FindObject's own // MetaObject? return type on the unique path. sb.AppendLine(payloadNameAmbiguous - ? " var mo = global::MetaObjects.NamingRefs.ResolveObjectRef(root, PAYLOAD_FQN, \"\") as global::MetaObjects.Meta.MetaObject" + ? " var mo = global::MetaObjects.NamingRefs.LookupCanonical(root, PAYLOAD_FQN) as global::MetaObjects.Meta.MetaObject" : " var mo = root.FindObject(PAYLOAD_FQN)"); sb.AppendLine(" ?? throw new global::System.InvalidOperationException("); sb.AppendLine(" $\"payload object \\\"{PAYLOAD_FQN}\\\" not found in the loaded metadata\");"); diff --git a/server/csharp/MetaObjects.Codegen/Generators/FindInbound.cs b/server/csharp/MetaObjects.Codegen/Generators/FindInbound.cs index cf814b7f7..0338c12a1 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/FindInbound.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/FindInbound.cs @@ -77,9 +77,6 @@ public static List InboundTemplates(MetaData root) => { // ADR-0039: resolving — @responseRef may be inherited via an abstract template base. if (tmpl.Attr(TEMPLATE_ATTR_RESPONSE_REF) is not string reference) return null; - // ADR-0042/#228: resolve package-aware — a bare ref binds the template's OWN package first, - // never a bare-tail/global scan that could bind the WRONG package's same-short-named object. - var referrerPkg = global::MetaObjects.NamingRefs.EffectivePackage(tmpl); // Resolve through the SAME payload-target resolver @payloadRef obeys (object.value, or a // sourceless object.projection) — NOT the any-object ResolveObjectRef. // @@ -94,7 +91,7 @@ public static List InboundTemplates(MetaData root) => // Java is immune by construction — its FindInbound.responseShape resolves value-only // too. This makes C# fail closed the same way: an unresolvable-or-wrong-typed ref yields // no parser rather than an uncompilable one. - var vo = ValueObjectNames.ResolveValueObject(root, reference, referrerPkg); + var vo = ValueObjectNames.PayloadShapeOf(tmpl, TEMPLATE_ATTR_RESPONSE_REF); if (vo is null) return null; return new InboundShape(vo, reference, ResponseFormatOf(tmpl)); } diff --git a/server/csharp/MetaObjects.Codegen/Generators/M2MNavigation.cs b/server/csharp/MetaObjects.Codegen/Generators/M2MNavigation.cs index 672aaab19..14978137f 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/M2MNavigation.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/M2MNavigation.cs @@ -107,16 +107,8 @@ public static IReadOnlyList For(MetaObject entity, MetaRoot root, private static M2MNavigation? Build(MetaObject source, MetaRelationship rel, MetaRoot root, Action? onWarn = null) { if (rel.ObjectRef is not { } targetRef || rel.Through is not { } throughRef) return null; - // Resolve by the SAME rule the derivation uses (FQN-exact when qualified, bare - // short name otherwise), or IsSelfJoin below can disagree with the FK derivation: - // StripPkg + FindObject binds "b::Account" to a same-short-named `a::Account`, - // which then compares identity-equal to the source and reports a cross-package - // hetero M:N as a self-join. Falls back to the previous resolution when the - // qualified name does not resolve exactly, so nothing that bound before stops. - var target = M2MDerivation.ResolveEntity(root, targetRef) - ?? root.FindObject(CSharpNaming.StripPkg(targetRef)); - var junction = M2MDerivation.ResolveEntity(root, throughRef) - ?? root.FindObject(CSharpNaming.StripPkg(throughRef)); + var target = global::MetaObjects.NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF) as MetaObject; + var junction = global::MetaObjects.NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_THROUGH) as MetaObject; if (target is null || junction is null) return null; // Owner ruling 2026-09-20: an un-pairable junction is a LOAD ERROR diff --git a/server/csharp/MetaObjects.Codegen/Generators/OutputParserGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/OutputParserGenerator.cs index e90f19555..05d82b7b1 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/OutputParserGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/OutputParserGenerator.cs @@ -107,8 +107,7 @@ protected virtual EmittedFile EmitParser(MetaData tmpl, string responseRef, GenC // cross-package collision (the #219/#244 "wrong node" class). The loader validates // @payloadRef through this SAME canonical resolver; codegen must never silently walk // a DIFFERENT node than what was validated. - var referrerPkg = global::MetaObjects.NamingRefs.EffectivePackage(tmpl); - var vo = global::MetaObjects.NamingRefs.ResolveObjectRef(ctx.Root, payloadRef, referrerPkg); + var vo = global::MetaObjects.NamingRefs.RefTarget(tmpl, TEMPLATE_ATTR_RESPONSE_REF); // ADR-0056: the parsed type IS the value object's own POCO, named the way every generator // names it (never the raw ref string, which may be an FQN). var ns = ctx.Config.Namespace; diff --git a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs index 753e7dc9e..2c283de85 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs @@ -110,8 +110,7 @@ public virtual IEnumerable Generate(GenContext ctx) // @payloadRef must resolve to an object.value or sourceless // object.projection (#210; same contract as the parser/prompt // generators). Its POCO, emitted by EntityGenerator, is the payload type. - // ADR-0042: a bare @payloadRef resolves in the template's package. - var vo = ValueObjectNames.ResolveValueObject(ctx.Root, payloadRef, global::MetaObjects.NamingRefs.EffectivePackage(tmpl)); + var vo = ValueObjectNames.PayloadShapeOf(tmpl, TEMPLATE_ATTR_PAYLOAD_REF); if (vo is null) continue; // EmitHelper runs the build-time drift gate first and THROWS (fails @@ -144,9 +143,8 @@ public static bool AppliesTo(MetaData tmpl, MetaRoot root) { if (!IsRenderable(tmpl)) return false; // ADR-0039: resolving — @payloadRef may be inherited via an abstract template base. - if (tmpl.Attr(TEMPLATE_ATTR_PAYLOAD_REF) is not string payloadRef) return false; - // ADR-0042: a bare @payloadRef resolves in the template's package. - return ValueObjectNames.ResolveValueObject(root, payloadRef, global::MetaObjects.NamingRefs.EffectivePackage(tmpl)) is not null; + if (tmpl.Attr(TEMPLATE_ATTR_PAYLOAD_REF) is not string) return false; + return ValueObjectNames.PayloadShapeOf(tmpl, TEMPLATE_ATTR_PAYLOAD_REF) is not null; } protected virtual EmittedFile EmitHelper(MetaData tmpl, MetaData vo, string payloadRef, GenContext ctx) @@ -159,7 +157,7 @@ protected virtual EmittedFile EmitHelper(MetaData tmpl, MetaData vo, string payl // Payload field tree — reused by the build-time gate AND baked into the // emitted RenderRequest.Verify so the runtime check matches the gate. - var fields = DerivePayloadFieldTree(ctx.Root, vo, new HashSet(StringComparer.Ordinal)); + var fields = DerivePayloadFieldTree(vo, new HashSet(StringComparer.Ordinal)); var ft = FieldTreeLiteral(fields); // ADR-0039: resolving — @kind may be inherited via an abstract template base. @@ -254,15 +252,14 @@ private void GateRef(string templateName, string reference, IReadOnlyList DerivePayloadFieldTree( - MetaRoot root, MetaData vo, HashSet seen) + MetaData vo, HashSet seen) { if (vo is null || !seen.Add(vo.ResolutionKey())) return []; var fields = new List(); @@ -275,18 +272,12 @@ private static IReadOnlyList DerivePayloadFieldTree( var isArray = f.ResolvedIsArray(); if (f.SubType == FIELD_SUBTYPE_OBJECT && // ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr). - f.Attr(FIELD_ATTR_OBJECT_REF) is string refName) + f.Attr(FIELD_ATTR_OBJECT_REF) is string) { - // ADR-0042: a nested @objectRef resolves in the DECLARING field's package — - // which differs from this VO's when the field is inherited via extends from an - // abstract VO in another package (the bare ref was authored there). - var fieldPkg = f.Parent is not null - ? global::MetaObjects.NamingRefs.EffectivePackage(f.Parent) - : global::MetaObjects.NamingRefs.EffectivePackage(vo); - var target = ResolveNestedObjectRef(root, refName, fieldPkg); + var target = global::MetaObjects.NamingRefs.RefTarget(f, FIELD_ATTR_OBJECT_REF); if (target is not null && target.SubType == OBJECT_SUBTYPE_VALUE) { - var children = DerivePayloadFieldTree(root, target, new HashSet(seen, StringComparer.Ordinal)); + var children = DerivePayloadFieldTree(target, new HashSet(seen, StringComparer.Ordinal)); fields.Add(new PayloadField(f.Name, children, isArray)); continue; } @@ -296,22 +287,6 @@ private static IReadOnlyList DerivePayloadFieldTree( return fields; } - /// - /// Resolve a field.object's @objectRef to its target object.value. - /// ADR-0042: package-local — an FQN ref (contains ::) resolves EXACTLY on its - /// resolution key (never a bare-tail fallback that would bind a same-named - /// object.value in the WRONG package); a bare ref resolves in - /// (the declaring field's package), else a root-level - /// object. Shares the single NamingRefs.ResolveObjectRef matcher (loader + codegen must - /// not drift). Returns null when no match is found. - /// - private static MetaData? ResolveNestedObjectRef(MetaRoot root, string reference, string referrerPkg) - { - if (string.IsNullOrEmpty(reference)) return null; - var obj = global::MetaObjects.NamingRefs.ResolveObjectRef(root, reference, referrerPkg); - return obj is not null && obj.SubType == OBJECT_SUBTYPE_VALUE ? obj : null; - } - /// /// Emit a IReadOnlyList<PayloadField> as a deterministic C# array /// literal: new PayloadField[] { new PayloadField("name"), diff --git a/server/csharp/MetaObjects.Codegen/Generators/ReverseFinders.cs b/server/csharp/MetaObjects.Codegen/Generators/ReverseFinders.cs index e84759772..d68a6965c 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/ReverseFinders.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/ReverseFinders.cs @@ -20,6 +20,7 @@ // (M2MNavigation) and is unaffected. using MetaObjects.Meta; +using static MetaObjects.Core.Identity.IdentityConstants; namespace MetaObjects.Codegen.Generators; @@ -31,8 +32,10 @@ namespace MetaObjects.Codegen.Generators; public sealed record ReverseFk( /// The local FK field name on the referencing entity (e.g. "currentSceneId"). string FkField, - /// The bare target entity name the FK references (e.g. "Scene"). - string TargetEntity); + /// The bare target entity name the FK references (e.g. "Scene"); a display name only. + string TargetEntity, + /// The entity the loader bound the reference to (null when it did not resolve). + MetaObject? Target = null); /// /// Builds descriptors and renders the reverse finders for @@ -54,7 +57,8 @@ public static IReadOnlyList For(MetaObject entity) var fkField = reference.Fields.Count > 0 ? reference.Fields[0] : null; var target = reference.TargetEntity; if (string.IsNullOrEmpty(fkField) || string.IsNullOrEmpty(target)) continue; - result.Add(new ReverseFk(fkField!, CSharpNaming.StripPkg(target!))); + var bound = global::MetaObjects.NamingRefs.RefTarget(reference, IDENTITY_REFERENCE_ATTR_REFERENCES) as MetaObject; + result.Add(new ReverseFk(fkField!, bound?.Name ?? CSharpNaming.StripPkg(target!), bound)); } return result; } @@ -69,7 +73,7 @@ public static IReadOnlyList For(MetaObject entity) /// private static string FkValueType(MetaObject entity, ReverseFk fk, MetaRoot root) { - var target = root.FindObject(fk.TargetEntity); + var target = fk.Target; var targetPkField = target?.PrimaryIdentity()?.Fields is { Count: 1 } pk ? target.FindField(pk[0]) : null; diff --git a/server/csharp/MetaObjects.Codegen/HashManifest.cs b/server/csharp/MetaObjects.Codegen/HashManifest.cs index 7b32ef213..0024ffa96 100644 --- a/server/csharp/MetaObjects.Codegen/HashManifest.cs +++ b/server/csharp/MetaObjects.Codegen/HashManifest.cs @@ -50,35 +50,68 @@ public static string ContentHash(string content) => /// public static bool Exists(string genStateDir) => File.Exists(PathFor(genStateDir)); - /// Load the manifest; an absent or corrupt one reads as EMPTY, which fails - /// closed (every file then refuses rather than being assumed ours). - public static Dictionary Load(string genStateDir) + private static Dictionary ReadRaw(string genStateDir) { var path = PathFor(genStateDir); - if (!File.Exists(path)) return new Dictionary(StringComparer.Ordinal); + if (!File.Exists(path)) return new Dictionary(StringComparer.Ordinal); try { - var parsed = JsonSerializer.Deserialize>(File.ReadAllText(path)); + var parsed = JsonSerializer.Deserialize>(File.ReadAllText(path)); return parsed is null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(parsed, StringComparer.Ordinal); + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(parsed, StringComparer.Ordinal); } catch (Exception e) when (e is JsonException or IOException or UnauthorizedAccessException) { - return new Dictionary(StringComparer.Ordinal); + return new Dictionary(StringComparer.Ordinal); } } + private static string? EntryHash(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Object when value.TryGetProperty("hash", out var h) && h.ValueKind == JsonValueKind.String + => h.GetString(), + _ => null, + }; + + /// Load the manifest; an absent or corrupt one reads as EMPTY, which fails + /// closed (every file then refuses rather than being assumed ours). An object-valued + /// entry written by another port reads as its hash field. + public static Dictionary Load(string genStateDir) + { + var hashes = new Dictionary(StringComparer.Ordinal); + foreach (var (key, value) in ReadRaw(genStateDir)) + if (EntryHash(value) is { } hash) hashes[key] = hash; + return hashes; + } + /// Persist the manifest with SORTED keys. /// /// Sorted because the file is committed: insertion order would make the diff — and /// any merge conflict between two people who both regenerated — depend on which /// generator happened to run first. + /// + /// + /// An entry that is currently an object keeps its other fields; only its + /// hash is replaced. /// public static void Save(string genStateDir, Dictionary hashes) { Directory.CreateDirectory(genStateDir); - var ordered = new SortedDictionary(hashes, StringComparer.Ordinal); + var raw = ReadRaw(genStateDir); + var ordered = new SortedDictionary(StringComparer.Ordinal); + foreach (var (key, hash) in hashes) + { + if (raw.TryGetValue(key, out var existing) && existing.ValueKind == JsonValueKind.Object) + { + var entry = new Dictionary(StringComparer.Ordinal); + foreach (var prop in existing.EnumerateObject()) entry[prop.Name] = prop.Value; + entry["hash"] = hash; + ordered[key] = entry; + } + else ordered[key] = hash; + } var json = JsonSerializer.Serialize(ordered, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(PathFor(genStateDir), json + "\n"); } @@ -121,4 +154,8 @@ public static void Record( public static bool IsPristine( string genStateDir, string relPath, string current, string? legacyRelPath = null) => Recorded(genStateDir, relPath, legacyRelPath) == ContentHash(current); + + /// Whether we recorded writing at all, whatever it holds now. + public static bool IsRecorded(string genStateDir, string relPath, string? legacyRelPath = null) => + Recorded(genStateDir, relPath, legacyRelPath) is not null; } diff --git a/server/csharp/MetaObjects.Codegen/OrphanSweep.cs b/server/csharp/MetaObjects.Codegen/OrphanSweep.cs new file mode 100644 index 000000000..737b0617e --- /dev/null +++ b/server/csharp/MetaObjects.Codegen/OrphanSweep.cs @@ -0,0 +1,169 @@ +// The orphan sweep: delete a file gen wrote earlier and no longer generates. +// +// The rule every port shares (docs/features/own-your-codegen.md, "Files that are no longer +// generated"; codegen-ts runner.ts / orphan-sweep.ts, Python codegen/orphan_sweep.py): +// +// - A candidate is a file under an output directory of a FULL run that the hash manifest +// records writing and that no generator of the run emitted. A file the manifest does not +// record is not ours, whatever its name, and is never touched. +// - An untouched candidate (its content still hashes to what was recorded) is deleted and its +// manifest entry dropped. +// - An edited candidate is REFUSED by name and stays recorded, so the refusal repeats until +// someone deletes the file or moves the edits. +// - A recorded path whose file is already gone is forgotten. +// +// `verify --codegen` convicts an extra file on the same evidence (Classify below), so gen +// removes exactly the untouched files the gate reports, which is what makes the gate's +// "regenerate" remedy work. The caller decides whether a run may sweep at all: a run narrowed +// by --generators produces a subset of the output by construction, so every file of the +// unselected part would look orphaned. + +namespace MetaObjects.Codegen; + +/// Finds and removes generated files no generator emits any more. +public static class OrphanSweep +{ + /// How a committed file that no generator emitted relates to the manifest. + public enum Verdict + { + /// The manifest does not record it: never ours, never touched. + Stranger, + /// Recorded and byte-for-byte what was recorded: safe to delete. + Pristine, + /// Recorded but edited since: refused by name. + Edited, + } + + /// The outcome of one sweep. + /// Files deleted, as the run reports paths (shown label + dir-relative path). + /// Project-relative paths of edited orphans, left in place. + /// Edited orphans deleted anyway, because every generator writing into + /// their directory declares (also in ). + public sealed record Result(IReadOnlyList Removed, IReadOnlyList Refused, IReadOnlyList Discarded); + + /// + /// Classify one committed file no generator emitted. is its absolute + /// path, its path relative to the output directory it lives in (the + /// legacy manifest spelling). A file outside is never recorded, + /// so it is always a stranger. + /// + public static Verdict Classify( + IReadOnlyDictionary hashes, string projectRoot, string full, string dirRel) + { + var key = KeyFor(projectRoot, full); + if (key is null) return Verdict.Stranger; + string? recorded = hashes.TryGetValue(key, out var h) ? h + : hashes.TryGetValue(dirRel, out var legacy) ? legacy + : null; + if (recorded is null) return Verdict.Stranger; + string current; + try { current = File.ReadAllText(full); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return Verdict.Edited; } + return recorded == HashManifest.ContentHash(current) ? Verdict.Pristine : Verdict.Edited; + } + + /// The project-relative manifest key of , or null when it + /// lies outside the project. Both sides via Path.GetFullPath, as + /// keys a write. + internal static string? KeyFor(string projectRoot, string full) + { + var key = Path.GetRelativePath(Path.GetFullPath(projectRoot), Path.GetFullPath(full)); + return key.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(key) ? null : key; + } + + private static bool IsUnder(string path, string dir) => + Path.GetFullPath(path).StartsWith( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)) + Path.DirectorySeparatorChar, + StringComparison.Ordinal); + + /// + /// Delete the untouched files under that the manifest records and + /// (absolute paths this run produced, refused ones included) does + /// not hold, and forget every recorded path under those directories whose file is gone. + /// Each directory comes with the label the run reports its files under. In a directory of + /// (absolute paths) an edited orphan is deleted too. + /// + public static Result Sweep( + string genStateDir, string projectRoot, + IReadOnlyList<(string Dir, string Label)> dirs, IReadOnlySet emitted, + IReadOnlySet? overwriteDirs = null) + { + var removed = new List(); + var refused = new List(); + var discarded = new List(); + if (!HashManifest.Exists(genStateDir)) return new Result(removed, refused, discarded); + var hashes = HashManifest.Load(genStateDir); + var forget = new HashSet(StringComparer.Ordinal); + var root = Path.GetFullPath(projectRoot); + + var distinct = dirs.DistinctBy(d => Path.GetFullPath(d.Dir)).ToList(); + var all = distinct.Select(d => Path.GetFullPath(d.Dir)).ToList(); + foreach (var (dir, label) in distinct) + { + var full = Path.GetFullPath(dir); + var nested = all.Where(o => o != full && IsUnder(o, full)).ToList(); + var prefix = KeyFor(root, full); + if (prefix is null) continue; // nothing outside the project is ours to delete + var overwrite = overwriteDirs?.Contains(full) == true; + if (Directory.Exists(full)) + { + foreach (var path in Directory.EnumerateFiles(full, "*", SearchOption.AllDirectories) + .OrderBy(p => p, StringComparer.Ordinal)) + { + if (emitted.Contains(Path.GetFullPath(path))) continue; + if (nested.Any(n => IsUnder(path, n))) continue; + var dirRel = Path.GetRelativePath(full, path); + if (dirRel.Split(Path.DirectorySeparatorChar).Contains(".metaobjects")) continue; + var key = KeyFor(root, path)!; + switch (Classify(hashes, root, path, dirRel)) + { + case Verdict.Pristine: + File.Delete(path); + // Only the spelling it was recorded under: a legacy dir-relative key + // is otherwise someone else's record in another directory. + forget.Add(hashes.ContainsKey(key) ? key : dirRel); + removed.Add(label + dirRel.Replace(Path.DirectorySeparatorChar, '/')); + break; + case Verdict.Edited when overwrite: + File.Delete(path); + forget.Add(hashes.ContainsKey(key) ? key : dirRel); + removed.Add(label + dirRel.Replace(Path.DirectorySeparatorChar, '/')); + discarded.Add(key.Replace(Path.DirectorySeparatorChar, '/')); + break; + case Verdict.Edited: + refused.Add(key.Replace(Path.DirectorySeparatorChar, '/')); + break; + } + } + } + // Recorded under this directory, not emitted, and already gone: forget it, so a + // stranger's file created later under that name is not mistaken for ours. + var under = prefix == "." ? "" : prefix + Path.DirectorySeparatorChar; + foreach (var key in hashes.Keys) + { + if (!key.StartsWith(under, StringComparison.Ordinal)) continue; + var path = Path.GetFullPath(Path.Combine(root, key)); + if (emitted.Contains(path) || File.Exists(path)) continue; + if (nested.Any(n => IsUnder(path, n))) continue; + forget.Add(key); + } + } + + if (forget.Count > 0) + { + foreach (var key in forget) hashes.Remove(key); + HashManifest.Save(genStateDir, hashes); + } + return new Result(removed, refused.Distinct().Order(StringComparer.Ordinal).ToList(), + discarded.Distinct().Order(StringComparer.Ordinal).ToList()); + } + + /// The warning for edited orphans. The same wording as codegen-ts's + /// refusedOrphanMessage and Python's refused_orphan_message: what happened, why + /// nothing was deleted, and the two ways out. + public static string RefusedMessage(IReadOnlyList paths, string owner = "dotnet meta gen") => + $"{owner}: {paths.Count} generated file(s) are no longer generated but have been edited by hand, " + + $"so they were NOT deleted: {string.Join(", ", paths)}. Delete each one, or, if what produced it " + + "was renamed, move your edits into the newly generated file first and then delete it " + + "(regeneration cannot follow a rename)."; +} diff --git a/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs b/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs index b0ebbcfe1..34e6d266e 100644 --- a/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs +++ b/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs @@ -9,7 +9,6 @@ using MetaObjects.Meta; using MetaObjects.Render; using static MetaObjects.Shared.BaseTypes; -using static MetaObjects.Shared.Structural; using static MetaObjects.Core.Field.FieldConstants; namespace MetaObjects.Codegen; @@ -18,41 +17,30 @@ namespace MetaObjects.Codegen; public static class PayloadFieldTree { /// - /// Resolve a payload object reference: FQN-exact when qualified (ADR-0041/0042); package-local - /// when bare (the referrer's own package, else root-level) — falling back to a GLOBAL short-name - /// scan when the package-local match fails, so an implicit-package top-level name (no - /// supplied) still resolves. A fully-qualified ref never reaches - /// the fallback, and ADR-0042 already makes the loader reject a bare ref meant to cross a - /// package boundary. + /// Derive the verify field tree (the input to Verify.Check) from the object.value a + /// CANONICAL reference names, resolved exactly (ADR-0059 R7): pkg::Name binds that + /// package's object, ::Name and a bare Name bind the root-level object. No + /// short-name scan and no package applied; empty when nothing matches exactly. Prefer + /// with the target the loader bound + /// (ValueObjectNames.PayloadShapeOf). is ignored: a + /// canonical value is absolute. /// - private static MetaData? Resolve(MetaData root, string reference, string referrerPkg) - { - var resolved = global::MetaObjects.NamingRefs.ResolveObjectRef(root, reference, referrerPkg); - if (resolved is not null) return resolved; - if (reference.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) return null; // FQN already resolved exactly above - return root.Children().FirstOrDefault(c => c.Type == TYPE_OBJECT && c.Name == reference); - } + public static IReadOnlyList Build(MetaData root, string voName, string referrerPkg = "") => + global::MetaObjects.NamingRefs.LookupCanonical(root, voName, TYPE_OBJECT) is { } vo ? Build(vo) : []; /// - /// Derive the verify field tree (the input to Verify.Check) from an object.value - /// view-object: scalars become leaves, object-ref fields recurse into nested element trees. - /// This is the bridge a `dotnet meta verify` command uses to drift-check a template against - /// its @payloadRef. (ADR-0042, #228) is the declaring - /// template's effective package — a bare resolves there FIRST, so a - /// template whose bare @payloadRef collides with a same-short-named object.value in - /// ANOTHER package binds its OWN package's object — never whichever one loads first. + /// The verify field tree of an already-bound payload object. Nested @objectRefs are + /// read from the targets the loader bound (ADR-0059 R7), never re-resolved by name. /// - public static IReadOnlyList Build(MetaData root, string voName, string referrerPkg = "") => - BuildTree(root, voName, referrerPkg, new HashSet(StringComparer.Ordinal)); + public static IReadOnlyList Build(MetaData vo) => + BuildTree(vo, new HashSet(StringComparer.Ordinal)); - private static IReadOnlyList BuildTree(MetaData root, string voName, string referrerPkg, HashSet visiting) + private static IReadOnlyList BuildTree(MetaData? vo, HashSet visiting) { - var vo = Resolve(root, voName, referrerPkg); // ADR-0039: dedupe/cycle-guard by ResolutionKey (FQN), never the bare ref string — two // DIFFERENT same-short-named nodes reached via different bare refs must not collapse // onto "already visiting" (the #219-class dedupe defect). if (vo is null || !visiting.Add(vo.ResolutionKey())) return []; - var voPkg = global::MetaObjects.NamingRefs.EffectivePackage(vo); var fields = new List(); foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD)) { @@ -61,16 +49,10 @@ private static IReadOnlyList BuildTree(MetaData root, string voNam // array-of-scalars (no `-last`) from a single scalar, and an array-of-objects from // a single nested object (both carry child Fields; only IsArray disambiguates). var isArray = f.ResolvedIsArray(); - // ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr). - if (f.SubType == FIELD_SUBTYPE_OBJECT && f.Attr(FIELD_ATTR_OBJECT_REF) is string refName) + if (f.SubType == FIELD_SUBTYPE_OBJECT && f.Attr(FIELD_ATTR_OBJECT_REF) is string) { - // ADR-0042: a nested @objectRef resolves in the FIELD's OWN declaring package, - // which may differ from this VO's when the field is inherited via extends from - // an abstract VO in another package. - var fieldPkg = f.Parent is not null - ? global::MetaObjects.NamingRefs.EffectivePackage(f.Parent) - : voPkg; - fields.Add(new PayloadField(f.Name, BuildTree(root, refName, fieldPkg, visiting), isArray)); + var nested = global::MetaObjects.NamingRefs.RefTarget(f, FIELD_ATTR_OBJECT_REF); + fields.Add(new PayloadField(f.Name, BuildTree(nested, visiting), isArray)); } else fields.Add(new PayloadField(f.Name, IsArray: isArray)); diff --git a/server/csharp/MetaObjects.Codegen/Runtime/ExtractObject.cs b/server/csharp/MetaObjects.Codegen/Runtime/ExtractObject.cs index 8a2fc3b4c..a63834922 100644 --- a/server/csharp/MetaObjects.Codegen/Runtime/ExtractObject.cs +++ b/server/csharp/MetaObjects.Codegen/Runtime/ExtractObject.cs @@ -300,44 +300,12 @@ private static NormalizeMode ResolveNormalize(MetaField field, MetaObject owner) private static bool IsArrayType(MetaField field) => field.ResolvedIsArray(); /// - /// Resolve a field's @objectRef to its target by matching the - /// objectRef value against each object's (the canonical - /// form an objectRef carries), falling back to a bare short-name match. Searches the whole - /// tree from the root. Returns null when unresolved. + /// Resolve a field's @objectRef to its target : the target the + /// loader bound (ADR-0059 R7), else the value resolved from the package of the node that + /// declared it. Never a short-name match. Returns null when unresolved. /// - private static MetaObject? ResolveObjectRef(MetaField field) - { - if (field.ObjectRef is not { } objectRef) return null; - var root = field.Root(); - MetaObject? byKey = null; - MetaObject? byName = null; - string shortName = StripPkg(objectRef); - foreach (MetaObject mo in DescendantObjects(root)) - { - if (mo.ResolutionKey() == objectRef) { byKey = mo; break; } - if (byName is null && mo.Name == shortName) byName = mo; - } - return byKey ?? byName; - } - - /// Strip the package prefix from a resolution-key / objectRef value, leaving the short name. - private static string StripPkg(string nameOrKey) - { - int idx = nameOrKey.LastIndexOf("::", System.StringComparison.Ordinal); - return idx >= 0 ? nameOrKey[(idx + 2)..] : nameOrKey; - } - - /// All nodes in the tree rooted at . - private static IEnumerable DescendantObjects(MetaData node) - { - if (node is MetaObject mo) yield return mo; - // ADR-0039: OwnChildren — a PHYSICAL tree walk to enumerate every declared object node - // exactly once. Resolving Children() would fold each node's super-inherited children into - // the traversal, double-visiting objects; this walk needs the declaration structure only. - foreach (MetaData child in node.OwnChildren()) - foreach (MetaObject d in DescendantObjects(child)) - yield return d; - } + private static MetaObject? ResolveObjectRef(MetaField field) => + global::MetaObjects.NamingRefs.RefTarget(field, FIELD_ATTR_OBJECT_REF) as MetaObject; /// A fresh identity-based visited set for the cycle guard. private static HashSet NewVisited() => diff --git a/server/csharp/MetaObjects.Codegen/Runtime/M2MResolver.cs b/server/csharp/MetaObjects.Codegen/Runtime/M2MResolver.cs index ce3546f38..a7cf2dfb7 100644 --- a/server/csharp/MetaObjects.Codegen/Runtime/M2MResolver.cs +++ b/server/csharp/MetaObjects.Codegen/Runtime/M2MResolver.cs @@ -43,6 +43,7 @@ using MetaObjects.Core.Field; using MetaObjects.Core.Relationship; using MetaObjects.Meta; +using static MetaObjects.Core.Relationship.RelationshipConstants; namespace MetaObjects.Codegen.Runtime; @@ -82,9 +83,9 @@ public static class M2MResolver // junction's two identity.reference children (hetero / directed / symmetric). var fields = M2MDerivation.DeriveM2MFields(rel, source, root); - var junction = root.FindObject(rel.Through!) + var junction = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_THROUGH) as MetaObject ?? throw new InvalidOperationException($"relate: junction '{rel.Through}' not found"); - var target = root.FindObject(rel.ObjectRef!) + var target = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF) as MetaObject ?? throw new InvalidOperationException($"relate: target '{rel.ObjectRef}' not found"); var junctionTable = TableOf(junction); diff --git a/server/csharp/MetaObjects.Codegen/ValueObjectNames.cs b/server/csharp/MetaObjects.Codegen/ValueObjectNames.cs index 407b7263f..cba9ddaa8 100644 --- a/server/csharp/MetaObjects.Codegen/ValueObjectNames.cs +++ b/server/csharp/MetaObjects.Codegen/ValueObjectNames.cs @@ -68,27 +68,35 @@ public static string TypeRef(MetaData obj, MetaData root, GenConfig config, stri } /// - /// The object a field's @objectRef names, resolved FQN-exact / package-local (ADR-0042) - /// in the FIELD's declaring package — which differs from the owner's when the field is - /// inherited through extends. Never a bare short-name scan: that binds whichever - /// same-named object loaded first. + /// The object a field's @objectRef names (ADR-0059 R7): the target the loader bound, + /// resolved in the package of the node that declared the attribute even when the field is + /// inherited through extends. Never a bare short-name scan. /// public static MetaObject? ResolveFieldRef(MetaData field, MetaData root) => - field.Attr(MetaObjects.Core.Field.FieldConstants.FIELD_ATTR_OBJECT_REF) is string oref && oref.Length > 0 - ? global::MetaObjects.NamingRefs.ResolveObjectRef( - root, oref, field.Parent is { } p ? global::MetaObjects.NamingRefs.EffectivePackage(p) : "") as MetaObject - : null; - - /// The payload shape a @payloadRef names, or null. ADR-0042: - /// package-local — an FQN binds the exact package; a bare ref binds - /// (the template's package) FIRST, else a root-level object. + global::MetaObjects.NamingRefs.RefTarget(field, MetaObjects.Core.Field.FieldConstants.FIELD_ATTR_OBJECT_REF) as MetaObject; + + /// The payload shape a CANONICAL @payloadRef value names, resolved exactly + /// (ADR-0059 R7), or null: pkg::Name binds that package's object, ::Name and a + /// bare Name bind the root-level object; no short-name scan and no package applied. + /// Prefer on the template node. is + /// ignored: a canonical value is absolute. /// #210 — a template-level payload target is an object.value OR a SOURCELESS /// object.projection ("sourceless" per the #248 persistability contract: no /// declared/inherited source.* child; nested @objectRef targets stay /// value-only — RenderHelperGenerator.ResolveNestedObjectRef). - public static MetaData? ResolveValueObject(MetaRoot root, string payloadRef, string referrerPkg = "") + public static MetaData? ResolveValueObject(MetaRoot root, string payloadRef, string referrerPkg = "") => + AsPayloadShape(global::MetaObjects.NamingRefs.LookupCanonical(root, payloadRef, TYPE_OBJECT)); + + /// + /// The payload shape the reference attribute of + /// (@payloadRef / @responseRef) is bound to, or null when it is unbound or not + /// a payload shape (ADR-0059 R7). + /// + public static MetaData? PayloadShapeOf(MetaData tmpl, string attr) => + AsPayloadShape(global::MetaObjects.NamingRefs.RefTarget(tmpl, attr)); + + private static MetaData? AsPayloadShape(MetaData? obj) { - var obj = global::MetaObjects.NamingRefs.ResolveObjectRef(root, payloadRef, referrerPkg); if (obj is null) return null; if (obj.SubType == OBJECT_SUBTYPE_VALUE) return obj; if (obj.SubType != OBJECT_SUBTYPE_PROJECTION) return null; diff --git a/server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs b/server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs index 54cce49f6..e4ee22b96 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs @@ -151,6 +151,19 @@ public static class ConformanceAdapter /// Load a fixture's input directory using the specified provider ids. /// public static LoadOutcome LoadFixture(string inputDir, IReadOnlyList providers) + => LoadFixtureCore(inputDir, providers, sources: null); + + /// + /// ADR-0059 gates — load in-memory texts (in order) instead of a directory, so the same + /// document can be read as JSON or as YAML without touching disk. Strict, like + /// . + /// + public static LoadOutcome LoadSources( + IReadOnlyList sources, IReadOnlyList providers) + => LoadFixtureCore("", providers, sources); + + private static LoadOutcome LoadFixtureCore( + string inputDir, IReadOnlyList providers, IReadOnlyList? sources) { var resolved = providers .Select(id => Providers.TryGetValue(id, out var p) @@ -180,7 +193,9 @@ public static LoadOutcome LoadFixture(string inputDir, IReadOnlyList pro // ADR-0023 — the library (and its conformance corpora) load strict: an // authored @-attr declared by no registered provider -> ERR_UNKNOWN_ATTR. // The public Strict option stays default-false so downstream can loosen. - var result = MetaDataLoader.FromDirectory(inputDir, registry, strict: true); + var result = sources is null + ? MetaDataLoader.FromDirectory(inputDir, registry, strict: true) + : new MetaDataLoader(registry, strict: true).Load(sources); // FR5a — surface the full envelope per error. Normalize files[] to // be relative to inputDir (the harness's portable file token). @@ -228,7 +243,7 @@ IReadOnlyList Rel(IEnumerable files) => private static string RelativizeFile(string filePath, string inputDir) { - if (filePath.StartsWith(inputDir, StringComparison.Ordinal)) + if (inputDir.Length > 0 && filePath.StartsWith(inputDir, StringComparison.Ordinal)) { var rel = System.IO.Path.GetRelativePath(inputDir, filePath); return rel.Replace('\\', '/'); diff --git a/server/csharp/MetaObjects.Conformance.Tests/IdentityReferenceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/IdentityReferenceTests.cs index 271cbc5cb..c8a253d33 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/IdentityReferenceTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/IdentityReferenceTests.cs @@ -42,13 +42,13 @@ public void Reference_identities_parse_target_fields_and_enforce() Assert.Equal(2, refs.Count); var bare = refs.Single(r => r.Name == "refBare"); - Assert.Equal("Org", bare.TargetEntity); + Assert.Equal("t::Org", bare.TargetEntity); Assert.Empty(bare.TargetFields); // bare -> target's primary identity Assert.True(bare.Enforce); // default true Assert.Equal(["authorId"], bare.Fields); // local FK column var dotted = refs.Single(r => r.Name == "refDotted"); - Assert.Equal("Org", dotted.TargetEntity); + Assert.Equal("t::Org", dotted.TargetEntity); Assert.Equal(["id"], dotted.TargetFields); // explicit target field Assert.False(dotted.Enforce); // @enforce: false -> logical-only } diff --git a/server/csharp/MetaObjects.Conformance.Tests/InheritedReferenceBindingTests.cs b/server/csharp/MetaObjects.Conformance.Tests/InheritedReferenceBindingTests.cs new file mode 100644 index 000000000..8fcbb01d5 --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/InheritedReferenceBindingTests.cs @@ -0,0 +1,77 @@ +// ADR-0059 R3/R7 — an attribute inherited through `extends` means what it meant where it was +// written. A root-level base writes `Customer` bare; the entity inheriting it from package +// `acme` (which has its own `Customer`) must still bind the ROOT `Customer`. + +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; +using static MetaObjects.Core.Identity.IdentityConstants; +using static MetaObjects.Core.Relationship.RelationshipConstants; + +namespace MetaObjects.Conformance.Tests; + +public class InheritedReferenceBindingTests +{ + private const string AcmeFile = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": { "@table": "acme_customers" } }, + { "field.string": { "name": "code" } }, + { "identity.primary": { "name": "id", "@fields": "code" } } + ]}}, + { "object.entity": { "name": "Order", "extends": "::BaseOrder", "children": [ + { "source.rdb": { "@table": "orders" } } + ]}} + ]}} + """; + + private const string RootFile = """ + { "metadata.root": { "children": [ + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ]}}, + { "object.entity": { "name": "BaseOrder", "abstract": true, "children": [ + { "field.long": { "name": "id" } }, + { "field.long": { "name": "customerId" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "customerRef", "@fields": "customerId", "@references": "Customer" } }, + { "relationship.association": { "name": "customer", "@cardinality": "one", "@objectRef": "Customer" } } + ]}} + ]}} + """; + + private static MetaRoot Load() + { + var result = new MetaDataLoader().Load( + [ + new InMemoryStringSource(AcmeFile, id: "meta.acme.json"), + new InMemoryStringSource(RootFile, id: "meta.root.json"), + ]); + Assert.Empty(result.Errors); + return result.Root; + } + + [Fact] + public void Inherited_relationship_and_reference_bind_the_root_object_not_the_inheritors_package() + { + var root = Load(); + var order = Assert.Single(root.Objects(), o => o.ResolutionKey() == "acme::Order"); + + var rel = Assert.Single(order.Relationships(), r => r.Name == "customer"); + var reference = Assert.Single(order.ReferenceIdentities(), r => r.Name == "customerRef"); + + var relTarget = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF); + var refTarget = NamingRefs.RefTarget(reference, IDENTITY_REFERENCE_ATTR_REFERENCES); + Assert.Equal("Customer", relTarget?.ResolutionKey()); + Assert.Equal("Customer", refTarget?.ResolutionKey()); + Assert.Equal("customers", ((MetaObject)relTarget!).DbTable); + + Assert.Equal("Customer", NamingRefs.LookupCanonical(root, (string)rel.Attr(RELATIONSHIP_ATTR_OBJECT_REF)!)?.ResolutionKey()); + Assert.Equal("Customer", NamingRefs.LookupCanonical(root, (string)reference.Attr(IDENTITY_REFERENCE_ATTR_REFERENCES)!)?.ResolutionKey()); + + Assert.Same(relTarget, NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF)); + Assert.Equal("acme::Customer", Assert.Single(root.Objects(), o => o.ResolutionKey() == "acme::Customer").ResolutionKey()); + } +} diff --git a/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs index 8bffebb5e..6960f176a 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/Issue368RelationshipReferenceLadderTests.cs @@ -47,10 +47,13 @@ private static MetaObject LoadMatch() return result.Root.FindObject("Match")!; } + private static MetaObject TeamOf(MetaObject holder) => ((MetaRoot)holder.Root()).FindObject("Team")!; + [Fact] public void Enumerates_every_candidate_reference_for_the_target() { - var candidates = RelationshipReferences.ReferenceCandidatesFor(LoadMatch(), "Team"); + var match = LoadMatch(); + var candidates = RelationshipReferences.ReferenceCandidatesFor(match, TeamOf(match)); Assert.Equal(["homeTeamRef", "awayTeamRef"], candidates.Select(c => c.Name)); } @@ -58,8 +61,8 @@ public void Enumerates_every_candidate_reference_for_the_target() public void Name_pairing_resolves_each_association_to_its_own_reference() { var match = LoadMatch(); - Assert.Equal("homeTeamRef", RelationshipReferences.ResolveRelationshipReference(match, "homeTeam", "Team")?.Name); - Assert.Equal("awayTeamRef", RelationshipReferences.ResolveRelationshipReference(match, "awayTeam", "Team")?.Name); + Assert.Equal("homeTeamRef", RelationshipReferences.ResolveRelationshipReference(match, "homeTeam", TeamOf(match))?.Name); + Assert.Equal("awayTeamRef", RelationshipReferences.ResolveRelationshipReference(match, "awayTeam", TeamOf(match))?.Name); } [Fact] @@ -68,7 +71,7 @@ public void Source_ref_field_wins_over_name_pairing() var match = LoadMatch(); Assert.Equal( "awayTeamRef", - RelationshipReferences.ResolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")?.Name); + RelationshipReferences.ResolveRelationshipReference(match, "homeTeam", TeamOf(match), "awayTeamId")?.Name); } [Fact] @@ -92,7 +95,7 @@ public void A_single_candidate_resolves_regardless_of_name() var result = LoadInline(doc); Assert.Empty(result.Errors); var match = result.Root.FindObject("Match")!; - Assert.Equal("anythingAtAll", RelationshipReferences.ResolveRelationshipReference(match, "champion", "Team")?.Name); + Assert.Equal("anythingAtAll", RelationshipReferences.ResolveRelationshipReference(match, "champion", TeamOf(match))?.Name); } [Fact] @@ -122,7 +125,7 @@ public void Unpairable_names_return_null_rather_than_guessing() var result = LoadInline(doc); Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code)); var match = result.Root.FindObject("Match")!; - Assert.Null(RelationshipReferences.ResolveRelationshipReference(match, "winner", "Team")); + Assert.Null(RelationshipReferences.ResolveRelationshipReference(match, "winner", TeamOf(match))); } [Fact] @@ -149,6 +152,6 @@ public void Suffix_stripping_never_applies_to_the_relationship_name() var result = LoadInline(doc); Assert.Equal([ErrorCode.ERR_INVALID_RELATIONSHIP], result.Errors.Select(e => e.Code)); var match = result.Root.FindObject("Match")!; - Assert.Null(RelationshipReferences.ResolveRelationshipReference(match, "valid", "Team")); + Assert.Null(RelationshipReferences.ResolveRelationshipReference(match, "valid", TeamOf(match))); } } diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs index 735969c40..6465f377f 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs @@ -504,7 +504,7 @@ public void Under_spine_a_dimension_whose_via_does_not_resolve_throws_naming_the AcmeFile(Catalog, Program, Purchase(subtitleVia: "Program.catalog"), SpineSales), id: "meta.inline.json")]); Assert.Single(result.Errors); var ex = Assert.Throws(() => Shape(result.Root, "SpineSales")); - Assert.Equal("report 'SpineSales': dimension 'programSubtitle' @via 'Program.catalog' does not resolve.", ex.Message); + Assert.Equal("report 'SpineSales': dimension 'programSubtitle' @via 'acme::Program.catalog' does not resolve.", ex.Message); } [Fact] diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs index 41fbfd086..a242244cf 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportingAccessorsTests.cs @@ -57,7 +57,7 @@ public void ReportFrom_and_ReportMeasureNames_read_the_report_attrs() { var r = LoadPositiveFixture(); var report = Root(r, "DailyRevenue"); - Assert.Equal("Purchase", ReportAccessors.ReportFrom(report)); + Assert.Equal("acme::shop::Purchase", ReportAccessors.ReportFrom(report)); Assert.Equal(["purchases", "revenue"], ReportAccessors.ReportMeasureNames(report)); } @@ -76,13 +76,13 @@ public void MeasureOfColumns_bare_string_is_a_one_element_list_and_a_list_is_the { var r = LoadPositiveFixture(); var revenue = Assert.IsType(Root(r, "Purchase").Children().Single(c => c.Name == "revenue")); - Assert.Equal(["Purchase.amountCents"], revenue.OfColumns()); + Assert.Equal(["acme::shop::Purchase.amountCents"], revenue.OfColumns()); Assert.Equal(AGG_SUM, revenue.Agg()); Assert.Equal("active", revenue.SegmentName()); var days = Assert.IsType(Root(r, "WorkoutEvent").Children().Single(c => c.Name == "daysEngaged")); Assert.Equal( - ["WorkoutEvent.programId", "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", "WorkoutEvent.dayNumber"], + ["acme::shop::WorkoutEvent.programId", "acme::shop::WorkoutEvent.customerEmail", "acme::shop::WorkoutEvent.weekNumber", "acme::shop::WorkoutEvent.dayNumber"], days.OfColumns()); Assert.True(days.Distinct()); @@ -101,7 +101,7 @@ public void Same_named_dimension_and_relationship_are_distinct_nodes() var purchase = Root(r, "Purchase"); var dim = Assert.IsType(purchase.ChildByTypeAndName(TYPE_DIMENSION, "program")); Assert.False(dim.IsTime()); - Assert.Equal("Purchase.programId", dim.Of()); + Assert.Equal("acme::shop::Purchase.programId", dim.Of()); Assert.Equal(TYPE_RELATIONSHIP, purchase.ChildByTypeAndName(TYPE_RELATIONSHIP, "program")!.Type); } @@ -111,7 +111,7 @@ public void Time_dimension_grains_and_via_read_through() var r = LoadPositiveFixture(); var dim = Assert.IsType(Root(r, "Purchase").ChildByTypeAndName(TYPE_DIMENSION, "programCreatedAt")); Assert.True(dim.IsTime()); - Assert.Equal("Purchase.program", dim.Via()); + Assert.Equal("acme::shop::Purchase.program", dim.Via()); Assert.Equal([GRAIN_MONTH, GRAIN_YEAR], dim.Grains()); } @@ -170,8 +170,8 @@ public void MeasureDefaultValue_reads_an_integral_number_as_its_integer_and_a_fr public void ReportSpine_is_the_declared_path_and_null_when_the_report_declares_none() { var r = LoadFixture("reporting-spine-and-default"); - Assert.Equal("Purchase.program", ReportAccessors.ReportSpine(Root(r, "ProgramSales"))); - Assert.Equal("Purchase.program.catalog", ReportAccessors.ReportSpine(Root(r, "CatalogSales"))); + Assert.Equal("acme::shop::Purchase.program", ReportAccessors.ReportSpine(Root(r, "ProgramSales"))); + Assert.Equal("acme::shop::Purchase.program.catalog", ReportAccessors.ReportSpine(Root(r, "CatalogSales"))); Assert.Null(ReportAccessors.ReportSpine(Root(r, "SalesByDay"))); } diff --git a/server/csharp/MetaObjects.Conformance.Tests/TemplateToolcallTests.cs b/server/csharp/MetaObjects.Conformance.Tests/TemplateToolcallTests.cs index 587d65a2d..be13bc1fb 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/TemplateToolcallTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/TemplateToolcallTests.cs @@ -114,7 +114,7 @@ public void Template_toolcall_loads_with_required_attrs() .Single(c => c.Type == TYPE_TEMPLATE && c.SubType == TEMPLATE_SUBTYPE_TOOLCALL); Assert.Equal("lookupWeather", toolcall.Name); Assert.Equal("lookup_weather", toolcall.OwnAttr(TEMPLATE_ATTR_TOOL_NAME)); - Assert.Equal("WeatherToolOutput", toolcall.OwnAttr(TEMPLATE_ATTR_PAYLOAD_REF)); + Assert.Equal("acme::tools::WeatherToolOutput", toolcall.OwnAttr(TEMPLATE_ATTR_PAYLOAD_REF)); } [Fact] diff --git a/server/csharp/MetaObjects.Conformance.Tests/YamlTwinConformanceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/YamlTwinConformanceTests.cs new file mode 100644 index 000000000..b5bf90514 --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/YamlTwinConformanceTests.cs @@ -0,0 +1,89 @@ +// ADR-0059 conformance gates, in every port: +// +// Gate 1 — YAML twin. Every conformance fixture's input loads once as JSON and once with the +// same texts read as YAML, in the same order. Both loads report the same ordered error +// codes and, when clean, serialize identically (effective serialization too, where the +// fixture has one). Exclusions live in the shared fixtures/conformance/ +// yaml-twin-exclusions.json, one reason per entry; there are no per-port divergences. +// Gate 2 — Round trip. Every fixture's expected.json loads as the only input and serializes +// back to itself. Exclusions live in fixtures/conformance/round-trip-exclusions.json. +// +// Mirrors server/typescript/packages/metadata/test/conformance-twin-roundtrip.test.ts. + +using System.Text.Json; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Conformance.Tests; + +public class YamlTwinConformanceTests +{ + private static IReadOnlyDictionary ReadExclusions(string fileName) + { + var path = Path.Combine(CorpusRoot.Path, fileName); + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement.GetProperty("exclusions").EnumerateObject() + .ToDictionary(p => p.Name, p => p.Value.GetString() ?? "", StringComparer.Ordinal); + } + + private static readonly IReadOnlyDictionary TwinExclusions = ReadExclusions("yaml-twin-exclusions.json"); + private static readonly IReadOnlyDictionary RoundTripExclusions = ReadExclusions("round-trip-exclusions.json"); + + private static IEnumerable Runnable() => + FixtureDiscovery.DiscoverFixtures(CorpusRoot.Path).Where(f => !f.IsDocsOnly && !f.IsContractOnly); + + public static IEnumerable TwinFixtures() => + Runnable().Where(f => !TwinExclusions.ContainsKey(f.Name)).Select(f => new object[] { f }); + + public static IEnumerable RoundTripFixtures() => + Runnable().Where(f => f.HasExpected && !RoundTripExclusions.ContainsKey(f.Name)).Select(f => new object[] { f }); + + /// The fixture's input files in load order, as in-memory sources of the given format + /// (null keeps each file's own format; Yaml reads every file as YAML). + private static List Sources(string inputDir, MetaDataFormat? forceFormat) => + new DirectorySource(inputDir).Expand() + .Select(f => (IMetaDataSource)new InMemoryStringSource( + f.Read(), f.Id, forceFormat is { } fmt && f.Format == MetaDataFormat.Json ? fmt : f.Format)) + .ToList(); + + [Fact] + public void ExclusionsNameRealFixtures() + { + var names = FixtureDiscovery.DiscoverFixtures(CorpusRoot.Path).Select(f => f.Name).ToHashSet(StringComparer.Ordinal); + foreach (var name in TwinExclusions.Keys.Concat(RoundTripExclusions.Keys)) + Assert.True(names.Contains(name), $"exclusion \"{name}\" names no fixture"); + } + + [Theory] + [MemberData(nameof(TwinFixtures))] + public void YamlTwin(Fixture fix) + { + var asJson = ConformanceAdapter.LoadSources(Sources(fix.InputDir, null), fix.Providers); + var asYaml = ConformanceAdapter.LoadSources(Sources(fix.InputDir, MetaDataFormat.Yaml), fix.Providers); + + Assert.Equal(asJson.ErrorCodes, asYaml.ErrorCodes); + if (asJson.ErrorCodes.Count != 0 || asJson.Tree is null) return; + + Assert.Equal( + ConformanceAdapter.CanonicalSerialize(asJson.Tree).Trim(), + ConformanceAdapter.CanonicalSerialize(asYaml.Tree).Trim()); + if (fix.HasExpectedEffective) + { + Assert.Equal( + ConformanceAdapter.CanonicalSerializeEffective(asJson.Tree).Trim(), + ConformanceAdapter.CanonicalSerializeEffective(asYaml.Tree).Trim()); + } + } + + [Theory] + [MemberData(nameof(RoundTripFixtures))] + public void RoundTrip(Fixture fix) + { + string expected = File.ReadAllText(Path.Combine(fix.Dir, "expected.json")); + var outcome = ConformanceAdapter.LoadSources( + [new InMemoryStringSource(expected, "expected.json", MetaDataFormat.Json)], fix.Providers); + + Assert.Empty(outcome.ErrorCodes); + Assert.Equal(expected.Trim(), ConformanceAdapter.CanonicalSerialize(outcome.Tree).Trim()); + } +} diff --git a/server/csharp/MetaObjects/Core/Relationship/M2MFields.cs b/server/csharp/MetaObjects/Core/Relationship/M2MFields.cs index b6beee440..15b802472 100644 --- a/server/csharp/MetaObjects/Core/Relationship/M2MFields.cs +++ b/server/csharp/MetaObjects/Core/Relationship/M2MFields.cs @@ -78,75 +78,6 @@ private static string StripPackage(string name) return idx < 0 ? name : name[(idx + PACKAGE_SEPARATOR.Length)..]; } - /// - /// , exposed so a codegen DESCRIPTOR can resolve an entity - /// reference by the SAME rule this derivation uses. - /// - /// - /// M2MNavigation.IsSelfJoin compares the descriptor's target against - /// its source by identity; while M2MNavigationBuilder.Build resolved that - /// target by a package-stripped name and the derivation resolved it exactly, the two - /// disagreed — b::Account bound to a same-short-named a::Account, which - /// then compared identity-equal to the source and reported a cross-package hetero M:N - /// as a self-join, and the EF wiring follows the descriptor. Build now calls this. - /// So this is additive to the DERIVATION — no derivation behaviour changed — but it - /// deliberately DID change resolution at that one call site, which is the point. - /// NOT the port's general reference resolver. It is deliberately narrow (see - /// ) and has no referrer-package awareness: a bare name is - /// resolved against every root object, not against the referrer's package first. - /// NamingRefs.ResolveObjectRef is the package-aware resolver the loader uses; - /// prefer it anywhere that is not matching a junction reference to an entity, and see - /// issue #174 for the bare-collision case both leave open. - /// - public static MetaObject? ResolveEntity(MetaRoot root, string? name) => FindEntity(root, name); - - /// - /// The root entity a reference name denotes, or null — resolved by the ONE - /// matcher the loader uses (, ADR-0041/0042): - /// an FQN binds exactly on its resolution key, a BARE name binds in the REFERRER's - /// package. Used only INTERNALLY by — - /// (no referrer) is the pre-existing, deliberately narrower matcher - /// exposes to codegen; the two are NOT unified here, to avoid changing that public - /// contract in the same change as the loader/derivation alignment. - /// - /// This exists so the SUBJECT comparison can be made on object IDENTITY. A bare-name - /// compare cannot tell a::NodeBase from b::NodeBase, which made a - /// genuine cross-package hetero M:N read as a self-join the moment the subject set - /// held two names — and the first-match-wins bare arm this used to carry (a bare - /// @objectRef on an abstract base bound to whichever same-short-named entity - /// happened to load first, which is load-ORDER dependence, not a naming rule). - /// Callers pass the referrer that owns the name they are resolving — the DECLARING - /// entity for the relationship's own @objectRef/@through, the JUNCTION - /// for its references' @references — because those are different packages - /// whenever a base is inherited across one. - /// - private static MetaObject? FindEntity(MetaRoot root, string? name, string referrerPkg) - { - if (string.IsNullOrEmpty(name)) return null; - return NamingRefs.ResolveObjectRef(root, name, referrerPkg) as MetaObject; - } - - /// - /// The root entity a reference name denotes, or null. Mirrors the Java - /// reference's M2MFields.findObject exactly: a FULLY-QUALIFIED name (one - /// containing ::) resolves EXACTLY on the object's package-folded key, never - /// a bare-tail fallback; a bare name matches a short name, first match wins (the - /// bare-collision case is the deferred follow-up Java records as issue #174). - /// - /// This is the matcher exposes to codegen — deliberately - /// narrower than above, which the - /// DERIVATION uses internally and which IS referrer-package aware (ADR-0041/0042). - /// - private static MetaObject? FindEntity(MetaRoot root, string? name) - { - if (string.IsNullOrEmpty(name)) return null; - var objects = root.Objects(); - if (name.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) - return objects.FirstOrDefault(o => string.Equals(o.ResolutionKey(), name, StringComparison.Ordinal)); - var bare = StripPackage(name); - return objects.FirstOrDefault(o => string.Equals(o.Name, bare, StringComparison.Ordinal)); - } - /// /// Derive the source/target junction FK fields for a M:N relationship. /// @@ -176,7 +107,6 @@ public static M2MFields DeriveM2MFields(MetaRelationship rel, MetaObject source, // self-join and refused to derive. bool IsSubject(MetaObject? entity) => entity is not null && (ReferenceEquals(entity, declaring) || ReferenceEquals(entity, source)); - bool IsSubjectName(string? name) => name is not null && subjectNames.Contains(StripPackage(name)); string? throughName = rel.Through; if (throughName is null) @@ -185,22 +115,7 @@ bool IsSubject(MetaObject? entity) => $"relationship \"{declaring.Name}.{rel.Name}\" is missing @through (required for M:N derivation)"); } - // ADR-0041/0042 — resolve @through through the SAME matcher the loader uses - // (NamingRefs.ResolveObjectRef, via ValidationPasses' IsJunctionShapeErrorOwnedByRuleD - // and rule (c)'s FindObject): an FQN resolves exactly on its resolution key, a bare - // name resolves in the DECLARING entity's package. - // - // This used to be `root.FindObject(throughName)`, keyed by BARE name only (an FQN - // input never matched, since a node's own `Name` is always bare), so a package- - // qualified @through always missed and any bare collision took the FIRST entity - // with that short name — the wrong package's whenever two share one. - // `xpkg-m2n-collision`-shaped models (two same-short-named junctions in different - // packages) silently derived against the wrong one, whose references point nowhere - // near the navigating entity — the pairing then failed, so a valid cross-package - // M:N emitted no traversal route (this port and TS) or failed the build (Java, - // Kotlin, Python). - string referrerPkg = NamingRefs.EffectivePackage(declaring); - var junction = NamingRefs.ResolveObjectRef(root, throughName, referrerPkg) as MetaObject; + var junction = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_THROUGH) as MetaObject; if (junction is null) { throw new M2MDerivationException( @@ -222,35 +137,21 @@ bool IsSubject(MetaObject? entity) => $"identity.reference children (found {refs.Count})"); } - // Defensive bare fallback when @objectRef does not resolve — loader validation - // normally guarantees it does. Same carve-out the Java reference makes. - // @objectRef resolves in the DECLARING entity's package (referrerPkg), same as - // @through above. - var targetEntityNode = FindEntity(root, targetName, referrerPkg); - bool isSelfJoin = targetEntityNode is not null - ? IsSubject(targetEntityNode) - : IsSubjectName(targetName); - - // The junction owns its references' @references names, so those resolve in the - // JUNCTION's package; the relationship's own @objectRef resolves in the - // DECLARING entity's (referrerPkg, above). Under `extends` across packages these - // differ, and using one for both is what bound a bare @objectRef to the wrong - // same-named entity. - string junctionPkg = NamingRefs.EffectivePackage(junction); + var targetEntityNode = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF) as MetaObject; + bool isSelfJoin = IsSubject(targetEntityNode); if (!isSelfJoin) { // Hetero: match each reference by the ENTITY OBJECT it resolves to. - var sourceRef = refs.FirstOrDefault(r => IsSubject(FindEntity(root, r.TargetEntity, junctionPkg))); + var sourceRef = refs.FirstOrDefault(r => IsSubject(NamingRefs.RefTarget(r, IDENTITY_REFERENCE_ATTR_REFERENCES) as MetaObject)); // Identity here too. The two searches are INDEPENDENT — nothing excludes // sourceRef from this one, unlike the directed self-join branch below — so a // bare compare could match the SOURCE-side reference again whenever the // target's short name equals the source's, and silently return (srcFk, srcFk). // Java matches identity on both sides (findRefToSubject + findRefToObject). var targetRef = targetEntityNode is not null - ? refs.FirstOrDefault(r => ReferenceEquals(FindEntity(root, r.TargetEntity, junctionPkg), targetEntityNode)) - : refs.FirstOrDefault( - r => r.TargetEntity is not null && StripPackage(r.TargetEntity) == StripPackage(targetName)); + ? refs.FirstOrDefault(r => ReferenceEquals(NamingRefs.RefTarget(r, IDENTITY_REFERENCE_ATTR_REFERENCES), targetEntityNode)) + : null; var sourceField = sourceRef is not null ? RefFkField(sourceRef) : null; var targetField = targetRef is not null ? RefFkField(targetRef) : null; if (sourceField is null || targetField is null) diff --git a/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs b/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs index a8987af59..cbb048656 100644 --- a/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs +++ b/server/csharp/MetaObjects/Core/Relationship/RelationshipReferences.cs @@ -81,19 +81,14 @@ void Add(string? value) } /// - /// Every identity.reference on whose @references targets - /// . Package-insensitive on both sides: @references - /// and @objectRef may each be bare or fully qualified. + /// Every identity.reference on whose @references is bound to + /// (ADR-0059 R7: identity of the bound target, never a short name). /// - public static List ReferenceCandidatesFor(MetaObject holder, string targetEntity) - { - string target = StripPackage(targetEntity); - // ADR-0039: resolving — ReferenceIdentities() honors references inherited via extends. - return holder.ReferenceIdentities() - .Where(r => StripPackage(r.TargetEntity) == target) + public static List ReferenceCandidatesFor(MetaObject holder, MetaData target) => + holder.ReferenceIdentities() .Where(r => RefFkField(r) is not null) + .Where(r => ReferenceEquals(NamingRefs.RefTarget(r, IDENTITY_REFERENCE_ATTR_REFERENCES), target)) .ToList(); - } /// /// Which identity.reference does this @cardinality: one relationship @@ -107,11 +102,15 @@ public static List ReferenceCandidatesFor(MetaObject hold /// /// Returns null for "no candidate" and "cannot choose" alike; callers that need to /// tell them apart use . + /// Candidates are the references bound to the relationship's target node. /// public static MetaReferenceIdentity? ResolveRelationshipReference( - MetaObject holder, string relationshipName, string targetEntity, string? sourceRefField = null) + MetaObject holder, string relationshipName, MetaData target, string? sourceRefField = null) => + ResolveFromCandidates(ReferenceCandidatesFor(holder, target), relationshipName, sourceRefField); + + private static MetaReferenceIdentity? ResolveFromCandidates( + List candidates, string relationshipName, string? sourceRefField) { - var candidates = ReferenceCandidatesFor(holder, targetEntity); if (candidates.Count == 0) return null; if (candidates.Count == 1) return candidates[0]; diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs index cef88eea9..338edda66 100644 --- a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs +++ b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs @@ -130,7 +130,7 @@ public static (MetaObject Named, MetaField Field)? ResolveDimensionOf(MetaDimens /// The target entity of one hop of a @via/@spine path, walked as the loader /// walks it: the relationship.* (through its @objectRef) or /// identity.reference (through its @references) of - /// named , resolved in 's package. Null + /// named , resolved in the package of the node that declares the hop's reference attribute (ADR-0059 R3). Null /// for a hop that is neither, or whose target does not resolve. /// private static MetaObject? ResolveViaHopTarget(MetaObject current, string hopName, MetaRoot root) @@ -138,9 +138,9 @@ public static (MetaObject Named, MetaField Field)? ResolveDimensionOf(MetaDimens // ADR-0039: resolving Relationships()/ReferenceIdentities(), so an inherited hop is found. var rel = current.Relationships().FirstOrDefault(r => r.Name == hopName); var refNode = rel is null ? current.ReferenceIdentities().FirstOrDefault(r => r.Name == hopName) : null; - string? targetRef = rel is not null ? rel.ObjectRef : refNode?.ReferencesRaw; - if (string.IsNullOrEmpty(targetRef)) return null; - return NamingRefs.ResolveObjectRef(root, targetRef, NamingRefs.EffectivePackage(current)) as MetaObject; + MetaData? hop = rel ?? (MetaData?)refNode; + if (hop is null) return null; + return NamingRefs.RefTarget(hop, rel is not null ? RELATIONSHIP_ATTR_OBJECT_REF : IDENTITY_REFERENCE_ATTR_REFERENCES) as MetaObject; } /// @@ -230,15 +230,15 @@ private static bool ViaJoinsInner(IReadOnlyList hops, MetaObject from, M // ADR-0039: resolving Relationships()/ReferenceIdentities(), so an inherited hop is found. var rel = current.Relationships().FirstOrDefault(r => r.Name == hopName); var refNode = rel is null ? current.ReferenceIdentities().FirstOrDefault(r => r.Name == hopName) : null; - string? targetRef = rel is not null ? rel.ObjectRef : refNode?.ReferencesRaw; - if (string.IsNullOrEmpty(targetRef)) return false; - if (NamingRefs.ResolveObjectRef(root, targetRef, NamingRefs.EffectivePackage(current)) is not MetaObject target) + MetaData? hop = rel ?? (MetaData?)refNode; + if (hop is null) return false; + if (NamingRefs.RefTarget(hop, rel is not null ? RELATIONSHIP_ATTR_OBJECT_REF : IDENTITY_REFERENCE_ATTR_REFERENCES) is not MetaObject target) return false; // The reference the hop crosses, as the lowering resolves it. A has-many hop, whose FK // sits on the child, matches none of the current entity's own references. var reference = refNode - ?? RelationshipReferences.ResolveRelationshipReference(current, hopName, target.Name, rel!.SourceRefField); + ?? RelationshipReferences.ResolveRelationshipReference(current, hopName, target, rel!.SourceRefField); if (reference is null) return false; string? fkName = RelationshipReferences.RefFkField(reference); var fk = fkName is null ? null : current.FindField(fkName); @@ -381,7 +381,8 @@ private static (string SubType, MetaField? TypeSource)? DeriveMeasure(MetaMeasur public static ReportShape Of(MetaObject report, MetaRoot root) { string fromName = ReportAccessors.ReportFrom(report) ?? throw Unresolved(report.Name, "@from"); - var from = NamingRefs.ResolveObjectRef(root, fromName, NamingRefs.EffectivePackage(report)) as MetaObject + var from = (NamingRefs.RefTarget(report, OBJECT_REPORT_ATTR_FROM) + ?? NamingRefs.ResolveObjectRef(root, fromName, NamingRefs.ReferrerPackageFor(report, OBJECT_REPORT_ATTR_FROM))) as MetaObject ?? throw Unresolved(report.Name, $"@from '{fromName}'"); string[]? spine = ReportSpineHops(report, from, root); var fields = new List(); diff --git a/server/csharp/MetaObjects/Errors.cs b/server/csharp/MetaObjects/Errors.cs index df640c696..c2072756f 100644 --- a/server/csharp/MetaObjects/Errors.cs +++ b/server/csharp/MetaObjects/Errors.cs @@ -75,10 +75,6 @@ public enum ErrorCode ERR_MALFORMED_YAML, ERR_YAML_COERCION, ERR_RESERVED_ATTR, - // FR-032 (ADR-0032) — a ref-bearing value in canonical JSON used a relative - // form (leading "::" or "..::"). Relative refs are YAML-authoring sugar the - // desugar expands; they must never appear in canonical (interchange) JSON. - ERR_RELATIVE_REF_IN_CANONICAL, ERR_INVALID_ORIGIN, // FR-024 (ADR-0029) — origin @via inference + cardinality checks. Vocabulary-only // here until FR-024 Phase E (the C# loader does not run the inference yet): diff --git a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs index 150a7ef74..cb834014f 100644 --- a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs +++ b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs @@ -418,6 +418,15 @@ public LoadResult Load(IReadOnlyList sources) Files: Array.Empty(), Referrer: failure.NodeFqn, Target: failure.Ref); + if (failure.Kind == SuperResolve.SuperFailureKind.OverDrop) + { + // ADR-0059 R2 — `..::` dropped more segments than the package has. + errors.Add(new MetaError( + $"the extends ref '{failure.Ref}' on {failure.NodeFqn} climbs above the root package — each '..::' drops one segment of the declaring package", + ErrorCode.ERR_BAD_ATTR_VALUE, + Envelope: envelope)); + continue; + } if (failure.Kind == SuperResolve.SuperFailureKind.TargetMismatch) { // FR-024 — a dotted child-targeting ref resolved, but the target's @@ -612,6 +621,10 @@ public LoadResult Load(IReadOnlyList sources) // @references for core; a downstream provider's type carries its own) + validator, // run as one recursive walk over a built-once symbol table. errors.AddRange(MetaObjects.Validation.RegisteredValidation.Run(root, _registry)); + + // ADR-0059 R5 — after the last validation pass, before freeze: resolve every + // reference once, record its target and rewrite it to its canonical spelling. + ResolveReferences.Run(root); } // If nothing parsed successfully, synthesize an empty root so callers diff --git a/server/csharp/MetaObjects/Loader/ResolveReferences.cs b/server/csharp/MetaObjects/Loader/ResolveReferences.cs new file mode 100644 index 000000000..c44a7e051 --- /dev/null +++ b/server/csharp/MetaObjects/Loader/ResolveReferences.cs @@ -0,0 +1,119 @@ +// ADR-0059 R5 — resolve every reference once, after validation and before freeze. +// +// Sources do not interpret references (R1): YAML and canonical JSON hand the loader each +// reference string exactly as written. The validation passes resolve those authored strings +// through the shared resolver and report failures with their own codes. This pass then walks +// the physical tree once and, for every reference that resolves, records the target and +// rewrites the value to its canonical spelling (R6), keeping the authored string beside it. +// A reference that does not resolve is left exactly as written: validation already reported it. +// +// Mirrors typescript/packages/metadata/src/loader/resolve-references.ts. + +using MetaObjects.Meta; + +namespace MetaObjects.Loader; + +internal static class ResolveReferences +{ + /// + /// Resolve every own reference attribute (ADR-0059 R4) and every extends in the tree, + /// record the targets, and rewrite resolved values to their canonical spelling. Must run after + /// the last validation pass and before Freeze(). + /// + public static void Run(MetaData root) => Visit(root, root); + + private static void Visit(MetaData root, MetaData node) + { + if (!ReferenceEquals(node, root)) + { + ResolveExtends(root, node); + // R3 default: the effective package of the top-level node that physically declares + // the attribute. Own attributes are declared here, by definition. + string declPkg = NamingRefs.EffectivePackageOf(NamingRefs.TopLevelNodeOf(node)); + // ADR-0039: own — this pass rewrites the declared-here layer; an inherited attribute + // is resolved once, on the node that declares it. + foreach (string name in node.OwnAttrs().Keys.ToList()) + { + if (NamingRefs.RefBearingAttrNames.Contains(name)) ResolveAttr(root, node, name, declPkg); + } + } + // ADR-0039: own — structural walk over the physical declaration tree. + foreach (var child in node.OwnChildren()) Visit(root, child); + } + + private sealed record Resolved(MetaData Target, string Canonical); + + /// Resolve one reference string's owner in and spell it canonically. + private static Resolved? ResolveOne(MetaData root, string value, string refPkg, string declPkg) + { + var (owner, tail) = NamingRefs.SplitChildTail(value); + var target = NamingRefs.ResolveObjectRef(root, owner, refPkg); + if (target is null) return null; + return new Resolved(target, NamingRefs.SpellRef(target, refPkg.Length > 0 ? refPkg : declPkg, tail)); + } + + /// R3 — the package an own reference attribute of resolves in. + private static string ReferrerPackage(MetaData root, MetaData node, string attr, string declPkg) + { + // `@of` on a dimension that walks `@via`: the package of the entity the walk reaches. + if (node.Type == TYPE_DIMENSION && attr == REPORTING_ATTR_OF) + { + // ADR-0039: resolving — the @via that governs this @of may be inherited. + if (node.Attr(REPORTING_ATTR_VIA) is string via && via.Length > 0 && node.Parent is { } declaring) + { + var terminal = ValidationPasses.DimensionViaTerminal(root, declaring, via); + return terminal is not null ? NamingRefs.EffectivePackageOf(terminal) : declPkg; + } + } + // `@spine` on a report: the package of the `@from` entity. + if (node.Type == TYPE_OBJECT && node.SubType == OBJECT_SUBTYPE_REPORT && attr == OBJECT_REPORT_ATTR_SPINE) + { + // ADR-0039: resolving — @from may be inherited. + if (node.Attr(OBJECT_REPORT_ATTR_FROM) is string from && from.Length > 0) + { + var fromNode = NamingRefs.ResolveObjectRef(root, from, declPkg); + if (fromNode is not null) return NamingRefs.EffectivePackageOf(fromNode); + } + } + return declPkg; + } + + private static void ResolveAttr(MetaData root, MetaData node, string attr, string declPkg) + { + object? value = node.OwnAttr(attr); + string refPkg = ReferrerPackage(root, node, attr, declPkg); + if (value is string s) + { + if (s.Length == 0) return; + var r = ResolveOne(root, s, refPkg, declPkg); + if (r is null) return; + node.SetResolvedTarget(attr, r.Target); + if (r.Canonical != s) node.SetCanonicalAttr(attr, r.Canonical); + return; + } + if (value is IReadOnlyList items) + { + var resolved = items.Select(v => v.Length == 0 ? null : ResolveOne(root, v, refPkg, declPkg)).ToList(); + if (resolved.All(r => r is null)) return; + node.SetResolvedTarget(attr, resolved.Select(r => r?.Target).ToList()); + var canonical = items.Select((v, i) => resolved[i]?.Canonical ?? v).ToList(); + if (!canonical.SequenceEqual(items)) node.SetCanonicalAttr(attr, canonical); + } + } + + /// Canonical extends: the owner spelled from the declaring node's package, tail kept. + private static void ResolveExtends(MetaData root, MetaData node) + { + string? reference = node.SuperRef; + if (reference is null || node.SuperData is null) return; + string pkg = NamingRefs.EffectivePackageOf(node); + var (owner, tail) = NamingRefs.SplitChildTail(reference); + MetaData? ownerNode; + try { ownerNode = SuperResolve.ResolveSuperRef(owner, pkg, root); } + catch (RefOverDropException) { return; } // an over-drop never resolved, so SuperData would be unset + if (ownerNode is null) return; + string canonical = NamingRefs.SpellRef(ownerNode, pkg, tail); + if (canonical == reference) return; + node.SetCanonicalSuper(canonical); + } +} diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs index b808d4cb6..9a47befa6 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs @@ -401,6 +401,14 @@ private static ToOneWalk D2Walk(MetaData declaring) "can never multiply the measured rows."); } + /// + /// The entity a dimension's @via reaches, walked from with D2's + /// rules, or null when the walk fails (D2 reports why). ADR-0059 R3: a dimension's @of + /// resolves in this entity's package, so the loader's reference pass reuses the walk. + /// + internal static MetaData? DimensionViaTerminal(MetaData root, MetaData declaring, string via) => + WalkToOneVia(root, declaring, via, _ => { }, D2Walk(declaring)); + /// R8 — a report's @spine, started at @from; Owner resolves in @from's package /// (it must be @from or an entity @from extends), whatever package the report is in. private static ToOneWalk SpineWalk(MetaData from, string fromKey) => @@ -458,12 +466,7 @@ private static ToOneWalk SpineWalk(MetaData from, string fromKey) => walk.ToOneReason); return null; } - // ADR-0039: resolving — the hop target attr may be inherited via extends. - var targetRef = hop.Attr(isReference ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF); - // ADR-0042 — a hop target resolves in the package of the entity declaring the hop. - var target = targetRef is string tr - ? NamingRefs.ResolveObjectRef(root, tr, NamingRefs.EffectivePackage(current)) - : null; + var target = NamingRefs.RefTarget(hop, isReference ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF); if (target is null) { err($"{named} hop '{hopName}' on '{NameOf(current)}' targets no object."); @@ -795,7 +798,7 @@ void Err(string message, MetaData? node = null, ErrorCode code = ErrorCode.ERR_I // the @filter have nothing to resolve against, so they are skipped. string? fromRef = ReportAccessors.ReportFrom(report); if (fromRef is null) return; // missing @from is ERR_MISSING_REQUIRED_ATTR - var from = NamingRefs.ResolveObjectRef(root, fromRef, NamingRefs.EffectivePackage(report)); + var from = NamingRefs.ResolveObjectRef(root, fromRef, NamingRefs.ReferrerPackageFor(report, OBJECT_REPORT_ATTR_FROM)); if (from is null) { Err($": @from '{fromRef}' does not resolve to an object."); diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 08f636d74..54792f4a1 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -650,8 +650,7 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) // entity — the #270 shape, this time baked into DDL. // ADR-0042 — a bare @objectRef resolves in the DECLARING owner's // package (an inherited field resolves in the package that declared it). - var refTarget = FindObject(root, objectRef, - NamingRefs.EffectivePackage(field.Parent ?? obj)); + var refTarget = NamingRefs.RefTarget(field, FIELD_ATTR_OBJECT_REF); if (refTarget is not null && refTarget.SubType != OBJECT_SUBTYPE_VALUE) { errors.Add(new MetaError( @@ -1173,9 +1172,7 @@ private static bool IsReferenceHop(MetaData hop) return null; } - // ADR-0042 — the hop target (@objectRef/@references) resolves in the package of - // the entity that DECLARES the relationship/reference, i.e. currentObj. - var nextObj = FindObject(root, refStr, NamingRefs.EffectivePackage(currentObj)); + var nextObj = NamingRefs.RefTarget(rel, IsReferenceHop(rel) ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF); if (nextObj is null) { // FR5d — the hop's target points at a missing entity. @@ -1308,12 +1305,7 @@ private static bool IsBaseRelationTarget(MetaData target, MetaData baseEntity, M { var candidates = baseEntity.Children() .Where(c => c.Type == TYPE_RELATIONSHIP) - .Where(rel => - { - // ADR-0039: resolving — a relationship may inherit @objectRef via extends (TS validation-passes.ts:709). - var r = rel.Attr(RELATIONSHIP_ATTR_OBJECT_REF); - return r is string rs && StripPackageName(rs) == targetEntity.Name; - }) + .Where(rel => ReferenceEquals(NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF), targetEntity)) .ToList(); string referrer = $"{obj.Fqn()}::{fieldName}"; if (candidates.Count == 1) return new List { candidates[0] }; @@ -1336,12 +1328,6 @@ private static bool IsBaseRelationTarget(MetaData target, MetaData baseEntity, M return null; } - private static string StripPackageName(string name) - { - int idx = name.LastIndexOf("::", StringComparison.Ordinal); - return idx >= 0 ? name[(idx + 2)..] : name; - } - /// ADR-0029 decision 6 — a passthrough via-path must be effectively to-one at /// EVERY hop. A hop is to-many only when it DECLARES @cardinality "many". private static void CheckPassthroughCardinality( @@ -3233,8 +3219,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root) // ADR-0042: resolve @objectRef and compare NODE IDENTITY — a bare "Widget" in this // package is self, but an FQN "other::Widget" (a different same-short-name entity) // is NOT (comparing stripped short names would misclassify it). - bool isSelfJoin = objectRef is string objRefStr && - ReferenceEquals(NamingRefs.ResolveObjectRef(root, objRefStr, referrerPkg), declaringEntity); + bool isSelfJoin = ReferenceEquals(NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF), declaringEntity); if (symmetric && !isSelfJoin) { errors.Add(new MetaError( @@ -3245,8 +3230,7 @@ public static IReadOnlyList ValidateRelationships(MetaData root) } // Rule (c): @through must name an entity declaring exactly two identity.reference children. - // ADR-0042 — a bare @through resolves in the declaring entity's package. - var junction = FindObject(root, (string)through!, referrerPkg); + var junction = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_THROUGH); if (junction is null) { errors.Add(new MetaError( @@ -3344,7 +3328,9 @@ public static IReadOnlyList ValidateOneSideReferenceResolution(MetaRo if (rel.Attr(RELATIONSHIP_ATTR_CARDINALITY) is not string cardinality || cardinality != CARDINALITY_ONE) continue; if (rel.Attr(RELATIONSHIP_ATTR_OBJECT_REF) is not string objectRef || objectRef.Length == 0) continue; - var candidates = RelationshipReferences.ReferenceCandidatesFor(obj, objectRef); + var objectRefTarget = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF); + if (objectRefTarget is null) continue; + var candidates = RelationshipReferences.ReferenceCandidatesFor(obj, objectRefTarget); var sourceRefField = rel.Attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); string? declared = sourceRefField is string srf && srf.Length > 0 ? srf : null; @@ -3373,7 +3359,7 @@ public static IReadOnlyList ValidateOneSideReferenceResolution(MetaRo // candidates — ResolveRelationshipReference's name-pairing step // (ladder step 3) decides. if (candidates.Count <= 1) continue; - var resolved = RelationshipReferences.ResolveRelationshipReference(obj, rel.Name, objectRef); + var resolved = RelationshipReferences.ResolveRelationshipReference(obj, rel.Name, objectRefTarget); if (resolved is not null) continue; errors.Add(new MetaError( @@ -3489,8 +3475,7 @@ private static bool IsJunctionShapeErrorOwnedByRuleD(MetaRelationship rel, MetaR // NamingRefs.ResolveObjectRef matcher, referred from the DECLARING entity's // package. A second resolution rule here is what made this helper disagree // with the derivation and emit rule (d)'s finding a second time. - string referrerPkg = rel.Parent is { } declaring ? NamingRefs.EffectivePackage(declaring) : ""; - var junction = NamingRefs.ResolveObjectRef(root, through, referrerPkg) as MetaObject; + var junction = NamingRefs.RefTarget(rel, RELATIONSHIP_ATTR_THROUGH) as MetaObject; if (junction is null) return true; if (junction.SubType != OBJECT_SUBTYPE_ENTITY) return true; if (CountJunctionReferences(junction) != 2) return true; @@ -3587,7 +3572,7 @@ public static IReadOnlyList ValidateTemplatePayloadRefs(MetaData root // an FQN resolves exactly. Shares the single NamingRefs.ResolveObjectRef matcher. // #210 — a template-level payload target widened to object.value OR a // sourceless object.projection (a SOURCED projection stays illegal). - var payload = NamingRefs.ResolveObjectRef(root, payloadRef, NamingRefs.EffectivePackage(tmpl)); + var payload = NamingRefs.RefTarget(tmpl, TEMPLATE_ATTR_PAYLOAD_REF); if (payload is null || !IsLegalPayloadTarget(payload)) { // FR5d — @payloadRef is a reference; emit format=resolved with @@ -3611,7 +3596,7 @@ public static IReadOnlyList ValidateTemplatePayloadRefs(MetaData root // ADR-0039: resolving — a template may inherit @responseRef via extends. if (tmpl.Attr(TEMPLATE_ATTR_RESPONSE_REF) is string responseRef) { - var resVo = NamingRefs.ResolveObjectRef(root, responseRef, NamingRefs.EffectivePackage(tmpl)); + var resVo = NamingRefs.RefTarget(tmpl, TEMPLATE_ATTR_RESPONSE_REF); if (resVo is null || !IsLegalPayloadTarget(resVo)) { errors.Add(new MetaError( @@ -3697,8 +3682,7 @@ private static void CheckNestedPayloadRefsValueOnly( if (field.Attr(FIELD_ATTR_OBJECT_REF) is not string @ref || @ref == "") continue; // ADR-0042: a bare ref resolves in the DECLARING owner's package (an // inherited field resolves in the package that declared it). - var owner = field.Parent ?? payload; - var target = NamingRefs.ResolveObjectRef(root, @ref, NamingRefs.EffectivePackage(owner)); + var target = NamingRefs.RefTarget(field, FIELD_ATTR_OBJECT_REF); if (target is null) continue; // dangling — reported by the @objectRef resolution check if (target.SubType != OBJECT_SUBTYPE_VALUE) { @@ -4225,7 +4209,6 @@ public static IReadOnlyList ValidateSourceParameterRef(MetaData root) // else root-level); an FQN resolves exactly. Shares the single // NamingRefs.ResolveObjectRef matcher — NO bare-name-anywhere fallback (which would // silently bind a same-named value-object in another package). - string referrerPkg = NamingRefs.EffectivePackage(obj); foreach (var source in obj.OwnChildren() .Where(c => c.Type == TYPE_SOURCE && c.SubType == SOURCE_SUBTYPE_RDB && c is MetaSource) .Cast()) @@ -4244,7 +4227,7 @@ public static IReadOnlyList ValidateSourceParameterRef(MetaData root) continue; } - var target = NamingRefs.ResolveObjectRef(root, refName, referrerPkg); + var target = NamingRefs.RefTarget(source, SOURCE_ATTR_PARAMETER_REF); if (target is null) { errors.Add(new MetaError( diff --git a/server/csharp/MetaObjects/Meta/MetaData.cs b/server/csharp/MetaObjects/Meta/MetaData.cs index ee77afb3e..f4a86651f 100644 --- a/server/csharp/MetaObjects/Meta/MetaData.cs +++ b/server/csharp/MetaObjects/Meta/MetaData.cs @@ -33,10 +33,78 @@ public abstract class MetaData /// private string? _fileDefaultPackage; + /// + /// ADR-0059 R10 — the package of the file this node was parsed from: "" when that file + /// declares none, null for a node built in code with no file at all. + /// + public string? FileDefaultPackage => _fileDefaultPackage; + /// Raw super reference string, pre-resolution. private string? _superRef; public string? SuperRef => _superRef; + /// + /// ADR-0059 R5 — the extends value exactly as authored, kept when the loader's + /// ResolveReferences pass rewrites to its canonical spelling. + /// + public string? AuthoredSuperRef { get; private set; } + + private Dictionary? _resolvedTargets; + private Dictionary? _authoredAttrs; + + /// + /// ADR-0059 R5 — the object an own reference attribute named, bound once by the loader. + /// Null when the attribute is a list, did not resolve, or was never bound. + /// + public MetaData? ResolvedTargetOf(string attrName) => + _resolvedTargets is not null && _resolvedTargets.TryGetValue(attrName, out var t) ? t as MetaData : null; + + /// The bound target of each element of an own list-valued reference attribute (null entries where unresolved). + public IReadOnlyList? ResolvedTargetsOf(string attrName) => + _resolvedTargets is not null && _resolvedTargets.TryGetValue(attrName, out var t) ? t as IReadOnlyList : null; + + /// Loader-internal: record the bound target(s) of an own reference attribute. + public void SetResolvedTarget(string attrName, object target) + { + AssertNotFrozen(); + (_resolvedTargets ??= new Dictionary())[attrName] = target; + } + + /// + /// Loader-internal: rewrite an own reference attribute to its canonical value, keeping the + /// authored value (first write wins). + /// + public void SetCanonicalAttr(string name, object? canonical) + { + AssertNotFrozen(); + _authoredAttrs ??= new Dictionary(); + if (!_authoredAttrs.ContainsKey(name)) _authoredAttrs[name] = _attrs.GetValueOrDefault(name); + _attrs[name] = canonical; + } + + /// Own attr value as the author wrote it (the canonical value when it was never rewritten). + public object? OwnAuthoredAttr(string name) + { + if (_authoredAttrs is not null && _authoredAttrs.TryGetValue(name, out var v)) return v; + return OwnAttr(name); + } + + /// + /// ADR-0059 — effective attr value as the author WROTE it: for a reference the loader rewrote + /// to canonical, the authored string; otherwise the value. For human-facing text only — code + /// that binds a reference reads . + /// + public object? AuthoredAttr(string name) + { + var seen = new HashSet(ReferenceEqualityComparer.Instance); + for (MetaData? n = this; n is not null && seen.Add(n); n = n._superData) + { + // ADR-0039: own — locating the layer that declares the attr, walking the super chain. + if (n._attrs.ContainsKey(name)) return n.OwnAuthoredAttr(name); + } + return null; + } + /// Post-resolution super pointer; set by SetSuperResolved() after parsing. private MetaData? _superData; @@ -153,7 +221,9 @@ public string ResolutionKey() // neither is present (e.g. programmatic construction) fall back to the // nearest-ancestor package walk. var pkg = _package ?? _fileDefaultPackage; - if (string.IsNullOrEmpty(pkg)) + // ADR-0059 R10: a recorded file package ("" included) is final — a file with no package + // declares root-level nodes and must not borrow the merged root's package. + if (_package is null && _fileDefaultPackage is null) { var node = _parent; while (node is not null) @@ -221,6 +291,14 @@ public void SetSuper(string superRef) _superRef = superRef; } + /// Loader-internal: rewrite extends to its canonical spelling, keeping the authored one. + public void SetCanonicalSuper(string canonical) + { + AssertNotFrozen(); + AuthoredSuperRef ??= _superRef; + _superRef = canonical; + } + // --------------------------------------------------------------------------- // IsArray // --------------------------------------------------------------------------- diff --git a/server/csharp/MetaObjects/Meta/MetaRoot.cs b/server/csharp/MetaObjects/Meta/MetaRoot.cs index 9cb0ae734..d2cab0667 100644 --- a/server/csharp/MetaObjects/Meta/MetaRoot.cs +++ b/server/csharp/MetaObjects/Meta/MetaRoot.cs @@ -48,11 +48,17 @@ public IReadOnlyList RootTemplates() .ToArray()); } - /// Find an object by name. + /// + /// Find an object by name, or by a canonical reference (ADR-0059): a value with a package + /// separator (acme::Program, ::Program) binds the object whose resolution key + /// matches exactly. A bare name matches a root-level child by name, as before. + /// public MetaObject? FindObject(string name) { return Cached($"findObject:{name}", () => { + if (name.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) + return NamingRefs.LookupCanonical(this, name, TYPE_OBJECT) as MetaObject; var child = OwnChildByTypeAndName(TYPE_OBJECT, name); return child as MetaObject; }); diff --git a/server/csharp/MetaObjects/NamingRefs.cs b/server/csharp/MetaObjects/NamingRefs.cs index 433d0af15..059db0a17 100644 --- a/server/csharp/MetaObjects/NamingRefs.cs +++ b/server/csharp/MetaObjects/NamingRefs.cs @@ -1,64 +1,247 @@ -// NamingRefs — ADR-0042 package-local object-reference resolution. +// NamingRefs — the one reference resolver every reference form goes through (ADR-0059). // -// Ported 1:1 from typescript/packages/metadata/src/naming-refs.ts -// (resolveObjectRef / refMatchesObject / didYouMeanHint). The SINGLE matcher every -// object-ref site (loader passes AND codegen) shares, so the contract is uniform. +// Sources do not interpret references: YAML, canonical JSON and any later source build +// the tree with every reference string exactly as authored. This module gives each form +// its meaning, once, against the built tree: +// +// `::Rest` root-absolute — the node whose resolution key is exactly `Rest`. +// `..::Rest` parent-relative — drop one segment of the referrer's package per `..::`, +// then the exact key `::Rest`. More `..::` than the package has +// segments is ERR_BAD_ATTR_VALUE. +// `pkg::Name` exact. +// `Name` the referrer's own package (`::Name`), else the root-level +// `Name` (ADR-0042 §2). Never another package. +// +// No form falls back to another and none matches on a short name. A FR-024 dotted child +// tail (`Owner.child.grand`) starts at the first `.` after the last `::`; only the owner is +// resolved by form. `SpellRef` is the inverse: the one canonical spelling of a resolved +// target, which the loader's `ResolveReferences` pass writes back. +// +// Mirrors typescript/packages/metadata/src/naming-refs.ts. using MetaObjects.Meta; namespace MetaObjects; +/// A parent-relative reference with more ..:: than its referrer's package has segments. +public sealed class RefOverDropException(string message) : Exception(message); + /// -/// ADR-0042 — resolve a metadata OBJECT reference package-locally. -/// -/// FQN ref (contains ::) → EXACT match on . -/// No bare-tail fallback, so an FQN pointing at one package never binds a same-named -/// object in another. -/// bare ref (no ::) → the referrer's OWN package -/// (<referrerPkg>::<ref>), else a root-level (empty-package) object -/// whose resolution key IS the bare name. No cross-package bare resolution, no -/// globally-unique scan. -/// -/// Mirrors TS naming-refs.ts. There is NO ambiguous outcome (bare = package-local, so -/// ambiguity is unreachable — ERR_AMBIGUOUS_REF is retired). +/// ADR-0059 R2 — resolve metadata references. See the file header for the forms. /// public static class NamingRefs { + private const string ParentPrefix = PACKAGE_PARENT + PACKAGE_SEPARATOR; // "..::" + + /// + /// The inline attribute names whose VALUE is a metadata object reference (ADR-0059 R4). + /// The structural extends key is handled separately. @sourceRefField (a FK + /// field name) and @implementedBy (which may name a member) are NOT in the set. + /// + public static readonly IReadOnlySet RefBearingAttrNames = new HashSet(StringComparer.Ordinal) + { + RELATIONSHIP_ATTR_OBJECT_REF, FIELD_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH, + IDENTITY_REFERENCE_ATTR_REFERENCES, + ORIGIN_PASSTHROUGH_ATTR_FROM, ORIGIN_PASSTHROUGH_ATTR_VIA, + ORIGIN_AGGREGATE_ATTR_OF, ORIGIN_AGGREGATE_ATTR_VIA, + SOURCE_ATTR_PARAMETER_REF, TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_RESPONSE_REF, + OBJECT_REPORT_ATTR_FROM, OBJECT_REPORT_ATTR_SPINE, + }; + + /// + /// Split a ref into its owner part and any FR-024 dotted child tail. The . that marks + /// a child can only appear in the FINAL ::-segment, so the owner ends at the first + /// . AFTER the last ::. Tail includes the leading . ("" when none). + /// + public static (string Owner, string Tail) SplitChildTail(string raw) + { + int lastSep = raw.LastIndexOf(PACKAGE_SEPARATOR, StringComparison.Ordinal); + int segStart = lastSep == -1 ? 0 : lastSep + PACKAGE_SEPARATOR.Length; + int dot = raw.IndexOf(CHILD_REF_SEPARATOR, segStart, StringComparison.Ordinal); + return dot == -1 ? (raw, "") : (raw[..dot], raw[dot..]); + } + + /// Expand the two relative forms against a package; throws on over-drop. + private static string ExpandOwner(string owner, string packageContext) + { + if (owner.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal)) + return owner[PACKAGE_SEPARATOR.Length..]; + + if (owner.StartsWith(ParentPrefix, StringComparison.Ordinal)) + { + string rest = owner; + int levels = 0; + while (rest.StartsWith(ParentPrefix, StringComparison.Ordinal)) + { + levels++; + rest = rest[ParentPrefix.Length..]; + } + string[] pkgParts = packageContext.Length == 0 + ? [] + : packageContext.Split(PACKAGE_SEPARATOR, StringSplitOptions.None); + if (levels > pkgParts.Length) + { + throw new RefOverDropException( + $"Relative reference '{owner}' over-drops: {levels} parent level(s) " + + $"but the package context '{packageContext}' has only {pkgParts.Length} segment(s)"); + } + string reduced = string.Join(PACKAGE_SEPARATOR, pkgParts[..(pkgParts.Length - levels)]); + return reduced.Length != 0 ? reduced + PACKAGE_SEPARATOR + rest : rest; + } + + if (owner.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) return owner; + return packageContext.Length != 0 ? packageContext + PACKAGE_SEPARATOR + owner : owner; + } + + /// + /// The resolution keys an OWNER reference (no child tail) may bind, in the order to try them + /// (ADR-0059 R2). Every form but bare is one exact key; bare is the referrer's own package, + /// then root-level. Throws on over-drop. + /// + public static IReadOnlyList OwnerCandidateKeys(string owner, string referrerPkg) + { + if (owner.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal)) + return [owner[PACKAGE_SEPARATOR.Length..]]; + if (owner.StartsWith(ParentPrefix, StringComparison.Ordinal)) + return [ExpandOwner(owner, referrerPkg)]; + if (owner.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) return [owner]; + return referrerPkg.Length > 0 ? [$"{referrerPkg}{PACKAGE_SEPARATOR}{owner}", owner] : [owner]; + } + /// - /// Does object satisfy reference - /// declared by a node whose effective package is ? - /// See the type summary for the matcher. defaults to - /// "" — the fail-closed FQN-exact-plus-root-level behavior (never binds a bare ref - /// across a package boundary), safe for codegen resolvers running on already-validated - /// FQN metadata. + /// Does object satisfy reference (no child + /// tail) declared by a node whose package is ? Agrees with + /// ; never matches on a short name. /// public static bool RefMatchesObject(MetaData node, string reference, string referrerPkg = "") { - string key = node.ResolutionKey(); - if (reference.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) return key == reference; - if (referrerPkg.Length > 0 && key == $"{referrerPkg}{PACKAGE_SEPARATOR}{reference}") return true; - return key == reference; // root-level (empty-package) object whose key is the bare name + MetaData? resolved = ResolveObjectRef(node.Root(), reference, referrerPkg); + return resolved is not null && ReferenceEquals(resolved, node); + } + + private static MetaData? BindOwner(IEnumerable candidates, string owner, string referrerPkg) + { + IReadOnlyList keys; + try { keys = OwnerCandidateKeys(owner, referrerPkg); } + catch (RefOverDropException) { return null; } + var list = candidates as IReadOnlyList ?? candidates.ToList(); + foreach (string key in keys) + { + foreach (var c in list) + if (c.ResolutionKey() == key) return c; + } + return null; + } + + private static IEnumerable TopLevelOfType(MetaData root, string? type) => + type is null ? root.Children() : root.Children().Where(c => c.Type == type); + + /// + /// Resolve a metadata OBJECT reference (no child tail) under ADR-0059 R2. + /// is the referrer's package (R3). Returns the object, or null + /// when nothing matches (an over-dropping ..:: also yields null; see + /// to tell the two apart). The single object resolver. + /// + public static MetaData? ResolveObjectRef(MetaData root, string reference, string referrerPkg) => + BindOwner(TopLevelOfType(root, TYPE_OBJECT), reference, referrerPkg); + + /// + /// Resolve a reference that may carry a dotted child tail: bind the owner among the root's + /// top-level nodes (of when given), then walk the tail by child + /// name. Intermediate segments must be unique among the current node's effective children; + /// the last is scoped to when given. + /// + public static (MetaData? Node, MetaData? Owner) ResolveRef( + MetaData root, string reference, string referrerPkg, string? ownerType = null, string? lastType = null) + { + var (owner, tail) = SplitChildTail(reference); + MetaData? bound = BindOwner(TopLevelOfType(root, ownerType), owner, referrerPkg); + if (bound is null) return (null, null); + if (tail.Length == 0) return (bound, bound); + string[] path = tail[CHILD_REF_SEPARATOR.Length..].Split(CHILD_REF_SEPARATOR); + if (path.Any(p => p.Length == 0)) return (null, null); + MetaData current = bound; + for (int i = 0; i < path.Length - 1; i++) + { + var matches = current.Children().Where(c => c.Name == path[i]).ToList(); + if (matches.Count != 1) return (null, bound); + current = matches[0]; + } + string last = path[^1]; + var target = current.Children().FirstOrDefault(c => c.Name == last && (lastType is null || c.Type == lastType)); + return (target, bound); + } + + /// + /// ADR-0059 R7 — resolve a CANONICAL reference with no referrer: strip one leading ::, + /// match a top-level node's resolution key exactly, then walk any dotted tail. Never matches + /// on a short name. + /// + public static MetaData? LookupCanonical(MetaData root, string value, string? ownerType = null, string? lastType = null) + { + var (owner, tail) = SplitChildTail(value); + string exact = owner.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal) ? owner : PACKAGE_SEPARATOR + owner; + return ResolveRef(root, exact + tail, "", ownerType, lastType).Node; + } + + /// + /// ADR-0059 R6 — the canonical spelling of a resolved owner as written from a referrer in + /// package : pkg::Name for a packaged target, ::Name + /// for a root-level target from a packaged referrer, Name from a root-level referrer. + /// + public static string SpellRef(MetaData target, string referrerPkg, string tail = "") + { + string key = target.ResolutionKey(); + bool rootLevel = !key.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal); + return (rootLevel && referrerPkg.Length > 0 ? PACKAGE_SEPARATOR + key : key) + tail; + } + + /// Effective package of a node: its own package, else its file's (ADR-0059 R10). + public static string EffectivePackageOf(MetaData node) => node.Package ?? node.FileDefaultPackage ?? ""; + + /// The top-level node (a direct child of the root) that physically contains . + public static MetaData TopLevelNodeOf(MetaData node) + { + MetaData current = node; + while (current.Parent is not null && current.Parent.Parent is not null) current = current.Parent; + return current; + } + + /// + /// ADR-0059 R3 default — the package a reference attribute resolves in: the effective package + /// of the top-level node that PHYSICALLY declares the attribute (walks the super chain to the + /// layer that owns it), so an inherited reference means what it meant where it was written. + /// + public static string ReferrerPackageFor(MetaData node, string attrName) + { + MetaData? declarer = node; + var seen = new HashSet(ReferenceEqualityComparer.Instance); + // ADR-0039: own — locating the layer that physically declares the attr. + while (declarer is not null && !declarer.OwnHasAttr(attrName) && seen.Add(declarer)) + declarer = declarer.SuperData; + return EffectivePackageOf(TopLevelNodeOf(declarer ?? node)); } /// - /// Resolve an OBJECT reference under the ADR-0042 package-local contract (see the type - /// summary for the matcher). is the effective package of - /// the node carrying the ref. Returns the resolved object, or null when nothing matches. - /// Mirrors TS resolveObjectRef(root, ref, referrerPkg).node. + /// ADR-0059 R7 — the object a reference attribute on names: the owner + /// the loader bound once, read RESOLVING so an inherited attribute works (ADR-0039). A tree + /// built without the loader has no recorded target, so the value is resolved here from the + /// package of the node that declared it. Null when the attribute is absent, a list, or names + /// nothing. /// - public static MetaData? ResolveObjectRef(MetaData root, string reference, string referrerPkg) + public static MetaData? RefTarget(MetaData node, string attrName) { - var objects = root.Children().Where(c => c.Type == TYPE_OBJECT); - if (reference.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) - return objects.FirstOrDefault(c => c.ResolutionKey() == reference); - - // Bare: PREFER the referrer's own package, THEN a root-level (empty-package) object — - // mirroring the loader symbol table's `byKey.get(localKey) ?? get(ref)` so both - // resolvers agree when a root-level object shares the bare name. - string localKey = referrerPkg.Length > 0 ? $"{referrerPkg}{PACKAGE_SEPARATOR}{reference}" : reference; - var own = objects.FirstOrDefault(c => c.ResolutionKey() == localKey); - if (own is not null) return own; - return localKey != reference ? objects.FirstOrDefault(c => c.ResolutionKey() == reference) : null; + var seen = new HashSet(ReferenceEqualityComparer.Instance); + for (MetaData? n = node; n is not null && seen.Add(n); n = n.SuperData) + { + // ADR-0039: own — locating the layer that declares the attr, walking the super chain. + if (!n.OwnHasAttr(attrName)) continue; + if (n.ResolvedTargetOf(attrName) is { } recorded) return recorded; + if (n.OwnAttr(attrName) is not string value || value.Length == 0) return null; + var (owner, _) = SplitChildTail(value); + return ResolveObjectRef(node.Root(), owner, EffectivePackageOf(TopLevelNodeOf(n))); + } + return null; } /// diff --git a/server/csharp/MetaObjects/Parser.cs b/server/csharp/MetaObjects/Parser.cs index 009c830f8..fe71f4d24 100644 --- a/server/csharp/MetaObjects/Parser.cs +++ b/server/csharp/MetaObjects/Parser.cs @@ -294,53 +294,6 @@ private static void ReportProblem( st.Warnings.Add(msg); } - // ----------------------------------------------------------------------- - // FR-032 (ADR-0032) — relative-ref guard for canonical JSON. - // - // The bare (sigil-free) inline attribute names whose VALUE is a metadata - // reference. In canonical JSON these are @-prefixed (@objectRef, …). Mirrors - // YamlDesugar.RefBearingAttrNames + the Java/TS REF_BEARING_ATTR_NAMES. The - // structural `extends` key is a reference too, but is guarded separately (it - // is the bare RESERVED_KEY_EXTENDS body key, not an @-prefixed attr). - // ----------------------------------------------------------------------- - - private static readonly HashSet RefBearingAttrNames = new(StringComparer.Ordinal) - { - "objectRef", "references", "from", "of", "via", - "payloadRef", "responseRef", "parameterRef", - "through", // ADR-0042: the M:N junction ref joins the desugar+resolution set. - }; - - /// - /// FR-032 (ADR-0032) — guard a ref-bearing value against relative forms. - /// Canonical JSON is the self-contained interchange form: every ref-bearing - /// attribute MUST be fully qualified. A relative authoring form (leading - /// :: or ..::) surviving into canonical JSON is - /// . The C# canonical - /// parser only ever handles canonical JSON (the YAML desugar expands these - /// forms before the parser sees them), so no format check is needed. Throwing - /// halts the parse so exactly one error is produced, mirroring the - /// rejection style. - /// - private static void GuardRelativeRefInCanonical(string refLabel, string? rawValue, ParseState st) - { - if (rawValue is null) return; - string parentPrefix = PACKAGE_PARENT + PACKAGE_SEPARATOR; // "..::" - if (!rawValue.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal) - && !rawValue.StartsWith(parentPrefix, StringComparison.Ordinal)) - { - return; - } - string path = st.Builder.ToString(); - string msg = - $"relative reference '{rawValue}' on {refLabel} at {path} is not allowed in " + - $"canonical JSON — canonical JSON must be fully-qualified. Relative forms " + - $"(leading '{PACKAGE_SEPARATOR}' or '{parentPrefix}') are YAML-authoring sugar " + - $"that the desugar expands."; - throw new ParseException( - msg, ErrorCode.ERR_RELATIVE_REF_IN_CANONICAL, st.Source, path, st.CurrentSource()); - } - // ----------------------------------------------------------------------- // splitTypeKey — split a fused wrapper key into (type, subType, explicit). // @@ -613,8 +566,9 @@ private static ParseResult BuildTree(JsonElement parsed, ParseOptions opts) // The JSON root's own package/super/reserved-keys are not re-applied // to the existing root. BUT: children from the NEW JSON should inherit // from the NEW root's package, not the existing root's. - string contextPkg = TryGetString(rootData, RESERVED_KEY_PACKAGE) - ?? opts.IntoRoot.Package ?? ""; + // ADR-0059 R10: the default package is per file — a document with no `package` + // declares root-level nodes, whatever was loaded before it. + string contextPkg = TryGetString(rootData, RESERVED_KEY_PACKAGE) ?? ""; ParseNodeInto(rootData, opts.IntoRoot, opts.IntoRoot, contextPkg, st); return Finish(st, opts, opts.IntoRoot); } @@ -730,10 +684,9 @@ private static MetaData ParseNodeFresh( // EFFECTIVE qualified key :: even after the // node merges (with no own package) into an accumulating root carrying a // DIFFERENT file's package. Mirrors the TS parser's setFileDefaultPackage. - if (model.Package is null && inheritedContextPkg != "") - { - model.SetFileDefaultPackage(inheritedContextPkg); - } + // Recorded even when empty: "" says the file declares no package (ADR-0059 R10), + // which is not the same as a node built in code with no file at all. + model.SetFileDefaultPackage(inheritedContextPkg); // --- Determine the effective context package for super resolution --- string effectivePkg = model.Package ?? inheritedContextPkg; @@ -744,8 +697,20 @@ private static MetaData ParseNodeFresh( { // FR-024: thread the referrer's type so dotted `Entity.child` refs resolve // type-scoped — kept consistent with the deferred path (SuperResolve.cs). - MetaData? superModel = SuperResolve.ResolveSuperRef( - model.SuperRef, effectivePkg, accumRoot, new SuperResolve.ReferrerScope(model.Type)); + MetaData? superModel; + try + { + superModel = SuperResolve.ResolveSuperRef( + model.SuperRef, effectivePkg, accumRoot, new SuperResolve.ReferrerScope(model.Type)); + } + catch (RefOverDropException) + { + // ADR-0059 R2 — `..::` dropped more segments than the package has. + throw new ParseException( + $"the extends ref '{model.SuperRef}' on {model.Fqn()} climbs above the root package — each '..::' drops one segment of the declaring package", + ErrorCode.ERR_BAD_ATTR_VALUE, st.Source, st.Builder.ToString(), + ResolvedSource.From(st.CurrentSource(), model.Fqn(), model.SuperRef)); + } if (superModel is not null) { // FR-024 — a dotted child-targeting ref must resolve to a node of the @@ -1363,8 +1328,6 @@ private static void ApplyReservedKeys( } else { - // FR-032 — reject a relative `extends` ref in canonical JSON. - GuardRelativeRefInCanonical($"\"{RESERVED_KEY_EXTENDS}\"", rawExtends.GetString(), st); model.SetSuper(rawExtends.GetString()!); } } @@ -1584,15 +1547,6 @@ private static void ApplyInlineAttrsAndUnknownKeys( continue; } - // FR-032 — reject a relative ref value on a ref-bearing inline @-attr - // (@objectRef/@references/@from/@of/@via/@payloadRef/@responseRef/ - // @parameterRef) in canonical JSON. Only string values can be relative. - if (RefBearingAttrNames.Contains(attrName) - && rawVal.ValueKind == JsonValueKind.String) - { - GuardRelativeRefInCanonical($"\"{ATTR_PREFIX}{attrName}\"", rawVal.GetString(), st); - } - AttrSchema? attrSpec = st.Registry .AttrsOf(model.Type, model.SubType) .FirstOrDefault(a => a.Name == attrName); diff --git a/server/csharp/MetaObjects/ParserYaml.cs b/server/csharp/MetaObjects/ParserYaml.cs index 18998e829..19d6cb56c 100644 --- a/server/csharp/MetaObjects/ParserYaml.cs +++ b/server/csharp/MetaObjects/ParserYaml.cs @@ -84,6 +84,9 @@ public static ParseResult ParseYaml(string content, ParseOptions opts) SourceName = opts.SourceName, IntoRoot = opts.IntoRoot, DeferSuperResolution = opts.DeferSuperResolution, + // ADR-0055 — forward the loader's overlay deferral so YAML overlays merge after + // every file loads, exactly as JSON ones do. + DeferOverlays = opts.DeferOverlays, SourceFormat = MetaDataFormat.Yaml, YamlPositionsByPath = collected.PositionsByPath, }; diff --git a/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs b/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs index 67d3383e8..fb0e3f323 100644 --- a/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs +++ b/server/csharp/MetaObjects/Persistence/Db/ReferentialActions.cs @@ -36,7 +36,7 @@ public static class ReferentialActions /// right where the FK is. /// A correlated sibling relationship on the same entity — matched /// package-aware against the resolved @references target - /// ( / ADR-0042, so bare and FQN forms + /// (bound target identity / ADR-0059, so bare and FQN forms /// pair correctly); an M:N relationship (@through) never correlates with a /// direct FK. Its explicit @onDelete, else its subtype default /// (composition→cascade, aggregation→set-null, association→restrict); @@ -71,11 +71,7 @@ public static ResolvedReferentialActions Resolve(MetaObject entity, MetaReferenc // @references resolves in the DECLARING owner's package). Both relationship tiers // then correlate against the resolved NODE, so a bare @references pairs correctly // with an FQN @objectRef and vice versa. - var root = entity.Parent; - var refOwner = reference.Parent ?? entity; - var targetObj = root is not null - ? NamingRefs.ResolveObjectRef(root, target, NamingRefs.EffectivePackage(refOwner)) as MetaObject - : null; + var targetObj = NamingRefs.RefTarget(reference, IDENTITY_REFERENCE_ATTR_REFERENCES) as MetaObject; // (2) A sibling relationship on the FK-owning entity. An M:N relationship // (@through) never correlates — it describes the junction path, not this @@ -100,11 +96,10 @@ public static ResolvedReferentialActions Resolve(MetaObject entity, MetaReferenc var objectRef = r.ObjectRef; if (objectRef is null) return false; if (targetObj is null) return objectRef == target; - if (!NamingRefs.RefMatchesObject( - targetObj, objectRef, NamingRefs.EffectivePackage(r.Parent ?? entity))) + if (!ReferenceEquals(NamingRefs.RefTarget(r, RELATIONSHIP_ATTR_OBJECT_REF), targetObj)) return false; return ReferenceEquals( - RelationshipReferences.ResolveRelationshipReference(entity, r.Name, objectRef, r.SourceRefField), + RelationshipReferences.ResolveRelationshipReference(entity, r.Name, targetObj, r.SourceRefField), reference); }); @@ -178,19 +173,12 @@ reverse.OnDelete is null private static MetaRelationship? FindReverseRelationship( MetaObject entity, MetaReferenceIdentity reference, MetaObject targetObj) { - var root = entity.Parent; - if (root is null) return null; - // Ambiguity guard: exactly one enforced reference from `entity` to this target, // and it must be `reference` itself. var refsToTarget = entity.ReferenceIdentities().Where(r => { if (!r.Enforce) return false; - var t = r.TargetEntity; - if (t is null) return false; - return ReferenceEquals( - NamingRefs.ResolveObjectRef(root, t, NamingRefs.EffectivePackage(r.Parent ?? entity)), - targetObj); + return ReferenceEquals(NamingRefs.RefTarget(r, IDENTITY_REFERENCE_ATTR_REFERENCES), targetObj); }).ToList(); if (refsToTarget.Count != 1 || !ReferenceEquals(refsToTarget[0], reference)) return null; @@ -205,8 +193,7 @@ reverse.OnDelete is null if (r.Through is not null) return false; // M:N — junction path, not this FK var objectRef = r.ObjectRef; if (objectRef is null) return false; - return NamingRefs.RefMatchesObject( - entity, objectRef, NamingRefs.EffectivePackage(r.Parent ?? targetObj)); + return ReferenceEquals(NamingRefs.RefTarget(r, RELATIONSHIP_ATTR_OBJECT_REF), entity); }).ToList(); return reverseCandidates.Count == 1 ? reverseCandidates[0] : null; } diff --git a/server/csharp/MetaObjects/SerializerJson.cs b/server/csharp/MetaObjects/SerializerJson.cs index 41c7999ab..cc489aa86 100644 --- a/server/csharp/MetaObjects/SerializerJson.cs +++ b/server/csharp/MetaObjects/SerializerJson.cs @@ -14,6 +14,16 @@ namespace MetaObjects; +/// +/// How references are written (ADR-0059 R8). +/// (default): each reference as the loader resolved it (pkg::Name, +/// ::Name for a root-level target from a packaged referrer) and the document package rule; +/// the output loads back to the same model from any source. : each reference, +/// extends included, exactly as written, and every package where it was written. Only +/// fmt uses it, so formatting never rewrites what an author wrote. +/// +public enum RefSpelling { Canonical, Authored } + /// /// Canonical JSON serializer. Produces byte-identical output to the TypeScript /// canonicalSerialize / canonicalSerializeEffective functions. @@ -29,9 +39,9 @@ public static class SerializerJson /// Output is deterministic: attrs sorted alphabetically, trailing LF. /// Byte-identical to the TypeScript canonicalSerialize. /// - public static string CanonicalSerialize(MetaData model) + public static string CanonicalSerialize(MetaData model, RefSpelling refs = RefSpelling.Canonical) { - var nodeObj = SerializeNode(model, effective: false); + var nodeObj = SerializeNode(model, effective: false, refs, null); // FR-016 / ADR-0018: rewrite legacy @table → kind-matching alias on // source.rdb wrappers (run before alphabetical sort so the rewritten // key sorts in its canonical position). @@ -48,7 +58,7 @@ public static string CanonicalSerialize(MetaData model) /// public static string CanonicalSerializeEffective(MetaData model) { - var nodeObj = SerializeNode(model, effective: true); + var nodeObj = SerializeNode(model, effective: true, RefSpelling.Canonical, null); RewriteSourceRdbPhysicalNames(nodeObj); var sorted = SortAttrKeys(nodeObj)!; return Stringify(sorted) + "\n"; @@ -139,15 +149,42 @@ private static string FusedKey(string type, string subType) // Serialize a single node → { ".": { ...body } } // --------------------------------------------------------------------------- - private static JsonObject SerializeNode(MetaData model, bool effective) + private static JsonObject SerializeNode(MetaData model, bool effective, RefSpelling refs, string? pkgSlot) { - var inner = SerializeNodeInner(model, effective); + var inner = SerializeNodeInner(model, effective, refs, pkgSlot); var wrapper = new JsonObject(); wrapper.Add(FusedKey(model.Type, model.SubType), inner); return wrapper; } - private static JsonObject SerializeNodeInner(MetaData model, bool effective) + /// Effective package of a top-level node (ADR-0059 R10): own, else its file's. A node + /// built in code has no file, so it takes the root's package, as re-loading the output would. + private static string TopLevelPackage(MetaData node, MetaData root) => + node.Package ?? node.FileDefaultPackage ?? root.Package ?? ""; + + /// + /// ADR-0059 R8 document package rule, for a metadata.root in canonical mode. Every named + /// top-level node in one non-empty package P: the root carries P (a node keeps a package it + /// carries itself). When the root states no package and every node names P itself, the root + /// package stays absent. Otherwise no root package and each named top-level node carries its + /// effective package. A root with no named top-level node keeps its own package. + /// + private static (string? RootPkg, bool PerNode) DocumentPackages(MetaData root) + { + // ADR-0039: own — the serializer writes the declared layer; the root has no super. + var named = root.OwnChildren().Where(c => c.Name != "").ToList(); + if (named.Count == 0) return (root.Package, false); + var pkgs = named.Select(c => TopLevelPackage(c, root)).Distinct().ToList(); + if (pkgs.Count == 1) + { + string only = pkgs[0]; + bool selfNamed = root.Package is null && named.All(c => c.Package == only); + if (only != "") return (selfNamed ? null : only, false); + } + return (null, true); + } + + private static JsonObject SerializeNodeInner(MetaData model, bool effective, RefSpelling refs, string? pkgSlot) { // Canonical body-key order: // 1. name 2. package 3. extends 4. abstract @@ -161,14 +198,21 @@ private static JsonObject SerializeNodeInner(MetaData model, bool effective) obj.Add(RESERVED_KEY_NAME, JsonValue.Create(model.Name)); } - if (model.Package is not null && model.Package != "") + bool isDocRoot = model.Type == TYPE_METADATA && model.SubType == SUBTYPE_ROOT; + (string? RootPkg, bool PerNode)? doc = isDocRoot && refs == RefSpelling.Canonical ? DocumentPackages(model) : null; + + string? pkg = doc is not null ? doc.Value.RootPkg : pkgSlot ?? model.Package; + if (!string.IsNullOrEmpty(pkg)) { - obj.Add(RESERVED_KEY_PACKAGE, JsonValue.Create(model.Package)); + obj.Add(RESERVED_KEY_PACKAGE, JsonValue.Create(pkg)); } - if (model.SuperRef is not null) + // After the loader's ResolveReferences pass SuperRef is canonical (ADR-0059 R5); the + // authored spelling is kept beside it for `fmt`. + string? superRef = refs == RefSpelling.Authored ? model.AuthoredSuperRef ?? model.SuperRef : model.SuperRef; + if (superRef is not null) { - obj.Add(RESERVED_KEY_EXTENDS, JsonValue.Create(model.SuperRef)); + obj.Add(RESERVED_KEY_EXTENDS, JsonValue.Create(superRef)); } if (model.IsAbstract) @@ -204,13 +248,21 @@ private static JsonObject SerializeNodeInner(MetaData model, bool effective) foreach (var child in childList) { if (child.Type == TYPE_ATTR) continue; - serializedChildren.Add(SerializeNode(child, effective)); + // A top-level node's package follows the document rule; deeper nodes keep their own. + // In a one-package document a node keeps any package it carries itself, so + // single-package output is unchanged. + string? childSlot = doc is not null && doc.Value.PerNode + ? (child.Name != "" ? TopLevelPackage(child, model) : "") + : null; + serializedChildren.Add(SerializeNode(child, effective, refs, childSlot)); } // Inline @-attrs — always emitted as @name; attrs have no child form. foreach (var (attrName, attrValue) in attrMap) { - obj.Add($"{ATTR_PREFIX}{attrName}", AttrValueToJsonNode(attrValue)); + // ADR-0039: own — the authored value lives on the declaring node. + object? shown = refs == RefSpelling.Authored && !effective ? model.OwnAuthoredAttr(attrName) : attrValue; + obj.Add($"{ATTR_PREFIX}{attrName}", AttrValueToJsonNode(shown)); } // children — emit only if non-empty diff --git a/server/csharp/MetaObjects/SuperResolve.cs b/server/csharp/MetaObjects/SuperResolve.cs index 7e3064eab..8316596b1 100644 --- a/server/csharp/MetaObjects/SuperResolve.cs +++ b/server/csharp/MetaObjects/SuperResolve.cs @@ -1,17 +1,16 @@ -// super reference resolution — package navigation helpers +// super reference resolution (ADR-0059) // -// Ported from typescript/packages/metadata/src/super-resolve.ts. +// An `extends` ref resolves through the same rules as every other reference +// (NamingRefs.OwnerCandidateKeys): `::Rest` root-absolute, `..::Rest` parent-relative, +// `pkg::Name` exact, bare `Name` in the declaring node's own file package, then root-level +// — never another package. The package context is the declaring node's effective package +// (`Package ?? FileDefaultPackage`). // -// Reference syntax (Java metaobjects-core conventions): -// Bare name "Fruit" → resolve in contextPackage, then root fallback -// Absolute "::fishstore::Fish" → strip leading ::, walk from root -// Relative parent "..::common::id" → up one package level, then resolve "common::id" -// Multi-level "..::..::shared::User" → up two levels, then resolve "shared::User" -// Same-package "common::id" → try contextPackage prepend first, then root-rooted +// FR-024: a ref whose final `::`-segment is dotted (`pkg::Customer.id`) targets a child +// nested in the owner object — resolved type-scoped to the referrer. // -// Super resolution is IMMEDIATE during parse (Java behavior). This module -// provides only the lookup helper; the parser calls it inline. The deferred -// multi-pass walker (resolveDeferredSupers) is added in Task 5.1. +// Super resolution is deferred by the loader (ResolveDeferredSupers); the parser calls +// ResolveSuperRef inline only when deferral is off. using MetaObjects.Meta; using MetaObjects.Shared; @@ -29,31 +28,17 @@ internal static class SuperResolve // ----------------------------------------------------------------------- /// - /// Recursively searches the tree for a node whose - /// matches. Returns the first match, or null. + /// The top-level node (a direct child of ) whose resolution key is + /// exactly — <package | fileDefaultPackage>::<name>, or + /// the bare name for a root-level node. The key is captured at parse time, so the match does + /// not depend on load order or post-merge tree shape. Exact only (ADR-0059 R2). /// - private static MetaData? FindInTree(MetaData root, string fqn) + private static MetaData? FindTopLevel(MetaData root, string key) { - if (root.Fqn() == fqn) return root; - // Match by EFFECTIVE qualified key (file-default package folded onto the - // bare name, captured at parse time) for nodes carrying no own package — - // the cross-PACKAGE cross-file case. Mirrors the TS findInTree second - // clause. The parser keeps an object's Fqn() bare for the FR5d - // referrer-envelope contract, so this clause is what lets a fully-qualified - // `extends: acme::common::BaseEntity` resolve against a `BaseEntity` - // declared under `package: acme::common` in a separate file. - if (root.Package is null && root.Name != "" && root.ResolutionKey() == fqn) - { - return root; - } - // ADR-0039: OwnChildren — super-resolution tree walk. This IS the extends-resolution - // machinery, so it must traverse only the physically-declared children (resolving - // Children() would recurse through the very super chain being built). + // ADR-0039: OwnChildren — super-resolution reads the PHYSICAL declaration layer; the + // root has no super, so its own children are its children. foreach (MetaData child in root.OwnChildren()) - { - MetaData? found = FindInTree(child, fqn); - if (found is not null) return found; - } + if (child.Name != "" && child.ResolutionKey() == key) return child; return null; } @@ -160,6 +145,7 @@ private static (string OwnerRef, string[] Path)? ParseChildTargetingRef(string r /// The root MetaData of the accumulating tree to search within. /// FR-024: the extending node's type — required to resolve dotted refs. /// The resolved MetaData, or null if the reference cannot be resolved. + /// A ..:: ref drops more segments than the package has. public static MetaData? ResolveSuperRef( string reference, string contextPackage, @@ -197,58 +183,17 @@ private static (string OwnerRef, string[] Path)? ParseChildTargetingRef(string r } // --------------------------------------------------------------------- - // 1. Absolute reference: leading "::" - // --------------------------------------------------------------------- - if (reference.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal)) - { - string absolutePath = reference[PACKAGE_SEPARATOR.Length..]; - return FindInTree(root, absolutePath); - } - - // --------------------------------------------------------------------- - // 2. Relative reference: one or more leading "..::" - // --------------------------------------------------------------------- - if (reference.StartsWith(PACKAGE_PARENT + PACKAGE_SEPARATOR, - StringComparison.Ordinal)) - { - string[] parts = Split(reference); - int levels = 0; - while (levels < parts.Length && parts[levels] == PACKAGE_PARENT) - { - levels++; - } - - string[] pkgParts = contextPackage != "" ? Split(contextPackage) : []; - string[] remainder = parts.Skip(levels).ToArray(); - if (pkgParts.Length < levels || remainder.Length == 0) - { - return null; - } - - IEnumerable allParts = pkgParts.Take(pkgParts.Length - levels).Concat(remainder); - return FindInTree(root, string.Join(PACKAGE_SEPARATOR, allParts)); - } - - // --------------------------------------------------------------------- - // 3. Bare name OR same-package shorthand (contains "::" but no leading - // "::" or "..::"): try contextPackage prepend first, then root-rooted. + // A plain ref binds one top-level node by its form (ADR-0059 R2). An + // over-dropping `..::` throws RefOverDropException; the deferred pass reports it. // --------------------------------------------------------------------- - if (contextPackage != "") + foreach (string key in NamingRefs.OwnerCandidateKeys(reference, contextPackage)) { - MetaData? found = FindInTree(root, - $"{contextPackage}{PACKAGE_SEPARATOR}{reference}"); + MetaData? found = FindTopLevel(root, key); if (found is not null) return found; } - return FindInTree(root, reference); + return null; } - /// - /// Split a package path on the :: separator. Mirrors the JS - /// String.prototype.split on a multi-char separator. - /// - private static string[] Split(string value) => - value.Split(PACKAGE_SEPARATOR, StringSplitOptions.None); - // ----------------------------------------------------------------------- // Deferred resolution — second pass after all files parsed // ----------------------------------------------------------------------- @@ -267,6 +212,9 @@ public enum SuperFailureKind { Unresolved, TargetMismatch, + /// A ..:: ref drops more package segments than the declaring node's package has + /// (ADR-0059 R2; loader emits ERR_BAD_ATTR_VALUE). + OverDrop, } /// A node's type + subType identity, for the target-mismatch message. @@ -315,18 +263,23 @@ public static IReadOnlyList ResolveDeferredSupers(MetaData { var failures = new List(); - // Every SuperRef-bearing node over the PHYSICAL declaration tree, plus each - // node's effective context-package (node.Package ?? nearest-ancestor - // package) — the same value the pre-order walk threaded. + // Every SuperRef-bearing node over the PHYSICAL declaration tree. Each node's package + // context is its own (ADR-0059 R3): `Package ?? FileDefaultPackage`, so a node resolves + // in the file that declared it, not in the first file's package. var pending = new List(); - var effectivePkgOf = new Dictionary(ReferenceEqualityComparer.Instance); - Walk(root, "", (node, ctxPkg) => + Walk(root, node => { - if (node.SuperRef is null) return; - effectivePkgOf[node] = node.Package ?? ctxPkg; - pending.Add(node); + if (node.SuperRef is not null) pending.Add(node); }); + // ADR-0059 R2: an over-dropping `..::` is a bad value, not a missing target. + const string overDrop = "over-drop"; + object? TryResolve(string reference, string pkg, ReferrerScope? scope = null) + { + try { return ResolveSuperRef(reference, pkg, root, scope); } + catch (RefOverDropException) { return overDrop; } + } + // Nodes already attempted this pass. On-demand resolution can reach a node // via the `pending` loop AND via owner/target recursion — `attempted` makes // each node resolve (and, on failure, report) EXACTLY ONCE, restoring the @@ -342,7 +295,7 @@ void ResolveNode(MetaData node) { if (node.SuperRef is null || node.SuperData is not null) return; if (!attempted.Add(node)) return; // already resolved-or-failed this pass — never re-report - string effectivePkg = effectivePkgOf.TryGetValue(node, out var pkg) ? pkg : (node.Package ?? ""); + string effectivePkg = NamingRefs.EffectivePackageOf(node); // For a dotted child-targeting ref, ResolveSuperRef reads the OWNER's // effective Children() — which includes inherited members only once the @@ -353,14 +306,19 @@ void ResolveNode(MetaData node) (string OwnerRef, string[] Path)? parsed = ParseChildTargetingRef(node.SuperRef); if (parsed is not null) { - MetaData? ownerNode = ResolveSuperRef(parsed.Value.OwnerRef, effectivePkg, root); - if (ownerNode is not null) ResolveNode(ownerNode); + if (TryResolve(parsed.Value.OwnerRef, effectivePkg) is MetaData ownerNode) ResolveNode(ownerNode); } } // FR-024: thread the referrer's type so dotted `Entity.child` refs resolve // type-scoped (a field ref selects fields; an identity ref identities). - MetaData? target = ResolveSuperRef(node.SuperRef, effectivePkg, root, new ReferrerScope(node.Type)); + object? resolved = TryResolve(node.SuperRef, effectivePkg, new ReferrerScope(node.Type)); + if (ReferenceEquals(resolved, overDrop)) + { + failures.Add(new DeferredSuperFailure(node.Fqn(), node.SuperRef, node, SuperFailureKind.OverDrop)); + return; + } + MetaData? target = resolved as MetaData; if (target is not null) { @@ -400,15 +358,14 @@ void ResolveNode(MetaData node) return failures.AsReadOnly(); } - private static void Walk(MetaData node, string ctxPkg, Action visit) + private static void Walk(MetaData node, Action visit) { - visit(node, ctxPkg); - string nextCtx = node.Package ?? ctxPkg; + visit(node); // ADR-0039: OwnChildren — this is a declaration-structure walk internal to // super-resolution; it must visit only physically-declared children. foreach (MetaData child in node.OwnChildren()) { - Walk(child, nextCtx, visit); + Walk(child, visit); } } } diff --git a/server/csharp/MetaObjects/Validation/RegisteredValidation.cs b/server/csharp/MetaObjects/Validation/RegisteredValidation.cs index 491cadc45..8eb41ddca 100644 --- a/server/csharp/MetaObjects/Validation/RegisteredValidation.cs +++ b/server/csharp/MetaObjects/Validation/RegisteredValidation.cs @@ -24,18 +24,12 @@ public static class RegisteredValidation public static IReadOnlyList Run(MetaData root, TypeRegistry registry) { var ctx = new ValidationContext(SymbolTable.Build(root)); - Walk(root, "", registry, ctx); + Walk(root, registry, ctx); return ctx.Errors; } - private static void Walk(MetaData node, string referrerPkg, TypeRegistry registry, ValidationContext ctx) + private static void Walk(MetaData node, TypeRegistry registry, ValidationContext ctx) { - // ADR-0042: a top-level object establishes the package context for its subtree; nested - // ref-bearing nodes (relationship / field.object / identity.reference) resolve BARE - // refs against it. Nested nodes carry no `package`, so they inherit the enclosing - // object's effective package (via file-default, else the threaded context). - string pkg = node.Type == TYPE_OBJECT ? NamingRefs.EffectivePackage(node) : referrerPkg; - var def = registry.Find(node.Type, node.SubType); if (def is not null) { @@ -46,6 +40,16 @@ private static void Walk(MetaData node, string referrerPkg, TypeRegistry registr if (node.Attr(desc.Attr) is not string raw || raw.Length == 0) continue; int dot = raw.IndexOf('.'); var entityRef = (desc.DottedFieldPath && dot >= 0) ? raw[..dot] : raw; + // ADR-0059 R3: an inherited reference resolves in the package of the top-level + // node that physically declares it, not the inheritor's. + string pkg = NamingRefs.ReferrerPackageFor(node, desc.Attr); + try { NamingRefs.OwnerCandidateKeys(entityRef, pkg); } + catch (RefOverDropException) + { + ctx.Error("ERR_BAD_ATTR_VALUE", node, + $"{node.Type}.{node.SubType} \"{node.Name}\" @{desc.Attr} \"{raw}\" climbs above the root package — each '..::' drops one segment of the declaring package."); + continue; + } var target = ctx.Symbols.ResolveObject(entityRef, pkg); // Qualify the node name with its owning entity (e.g. "Order.items") so the // error is locatable from the message alone, not just the source envelope. @@ -68,7 +72,7 @@ private static void Walk(MetaData node, string referrerPkg, TypeRegistry registr } def.Validate?.Invoke(node, ctx); } - foreach (var child in node.OwnChildren()) Walk(child, pkg, registry, ctx); + foreach (var child in node.OwnChildren()) Walk(child, registry, ctx); } private sealed class SymbolTable : ISymbolTable @@ -88,14 +92,17 @@ public static SymbolTable Build(MetaData root) return t; } - /// ADR-0042 package-local resolution: FQN → exact resolution-key match; bare → - /// the referrer's own package (<referrerPkg>::<ref>), else a root-level object. + /// ADR-0059 R2 resolution: ::Rest root-absolute, ..::Rest parent-relative, + /// pkg::Name exact, bare Name in the referrer's own package then root-level. Exact keys + /// only. An over-dropping ..:: resolves to nothing here; the walk reports it. public MetaData? ResolveObject(string reference, string referrerPkg) { - if (reference.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) - return _byKey.GetValueOrDefault(reference); - string localKey = referrerPkg.Length > 0 ? $"{referrerPkg}{PACKAGE_SEPARATOR}{reference}" : reference; - return _byKey.GetValueOrDefault(localKey) ?? _byKey.GetValueOrDefault(reference); + IReadOnlyList keys; + try { keys = NamingRefs.OwnerCandidateKeys(reference, referrerPkg); } + catch (RefOverDropException) { return null; } + foreach (string key in keys) + if (_byKey.TryGetValue(key, out var hit)) return hit; + return null; } } diff --git a/server/csharp/MetaObjects/YamlDesugar.cs b/server/csharp/MetaObjects/YamlDesugar.cs index fe7167eb0..7be745a3a 100644 --- a/server/csharp/MetaObjects/YamlDesugar.cs +++ b/server/csharp/MetaObjects/YamlDesugar.cs @@ -64,179 +64,6 @@ public static class YamlDesugar /// The `[]` suffix on a body key — strips to isArray: true. public const string ARRAY_SUFFIX = "[]"; - // ----------------------------------------------------------------------- - // FR-032 (ADR-0032) — reference expansion - // ----------------------------------------------------------------------- - - /// - /// FR-032 — the bare (sigil-free) inline attribute names whose VALUE is a metadata - /// reference subject to FQN expansion. In canonical JSON these are @-prefixed - /// (@objectRef, …). The structural extends key is handled separately - /// (it is not @-prefixed). @from/@of/@via carry an entity - /// head with a possible dotted relationship/field tail — - /// preserves the tail. Mirrors TS REF_BEARING_ATTR_NAMES / Java equivalent. - /// The package attr is intentionally NOT here — it is the node's identity. - /// - private static readonly HashSet RefBearingAttrNames = new(StringComparer.Ordinal) - { - "objectRef", "references", "from", "of", "via", - "payloadRef", "responseRef", "parameterRef", - "through", // ADR-0042: the M:N junction ref joins the desugar+resolution set. - }; - - /// - /// FR-032 (ADR-0032) — compute a node's effective package from its raw - /// package body key (inheriting when absent). - /// Mirrors TS effectivePackageFor / Parser's expandPackageForPath: a - /// leading :: on a child package prepends the parent; otherwise it is used - /// as-is. The package attr itself is never ref-expanded — it is identity. - /// - private static string EffectivePackageFor(YamlNode? rawBody, string parentPkg) - { - if (rawBody is not YamlMappingNode map) return parentPkg; - foreach (var kvp in map.Children) - { - if (kvp.Key is YamlScalarNode ks && ks.Value == RESERVED_KEY_PACKAGE) - { - if (kvp.Value is YamlScalarNode vs && vs.Value is string rawPkg) - { - if (parentPkg != "" && rawPkg.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal)) - return parentPkg + rawPkg; - return rawPkg; - } - return parentPkg; - } - } - return parentPkg; - } - - /// - /// FR-032 — true when is a ref-bearing key whose string - /// value must be FQN-expanded: the reserved extends key, or an - /// @-prefixed inline attr whose bare name is in . - /// - private static bool IsRefBearingKey(string outKey) - { - if (outKey == RESERVED_KEY_EXTENDS) return true; - if (outKey.StartsWith(ATTR_PREFIX, StringComparison.Ordinal)) - return RefBearingAttrNames.Contains(outKey[ATTR_PREFIX.Length..]); - return false; - } - - /// - /// FR-032 — expand an authored metadata reference to its fully-qualified canonical - /// form (ADR-0032 §2.1). C# mirror of TS expandRef(raw, packageContext); the - /// single ref-expansion routine the YAML desugar threads over every ref-bearing - /// attr so canonical JSON is FQN-only. Deterministic, NO root fallback: - /// - /// bare Name → <P>::Name (current package only; stays bare when P empty). - /// qualified pkg::Name (contains ::, not leading) → unchanged. - /// ::Rest → strip leading ::; remainder absolute from root. - /// ..::Rest → drop one package segment from P per ..::, then resolve - /// Rest against the reduced package. Over-drop throws. - /// - /// The trailing FR-024 dotted child suffix (.child) is preserved verbatim. - /// - public static string ExpandRef(string raw, string packageContext) - { - // Split off any FR-024 dotted child tail; expand only the owner. - int lastSep = raw.LastIndexOf(PACKAGE_SEPARATOR, StringComparison.Ordinal); - int segStart = lastSep == -1 ? 0 : lastSep + PACKAGE_SEPARATOR.Length; - int dotInSeg = raw.IndexOf(CHILD_REF_SEPARATOR, segStart, StringComparison.Ordinal); - string owner = dotInSeg == -1 ? raw : raw[..dotInSeg]; - string tail = dotInSeg == -1 ? "" : raw[dotInSeg..]; - return ExpandOwner(owner, packageContext) + tail; - } - - /// FR-032 — expand a reference's OWNER part (no child tail) to its FQN. - /// Mirrors TS expandOwner. Throws on parent-relative over-drop. - private static string ExpandOwner(string owner, string packageContext) - { - string pkg = packageContext ?? ""; - - // root-absolute: leading "::" → strip; the remainder is absolute from root. - if (owner.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal)) - return owner[PACKAGE_SEPARATOR.Length..]; - - // parent-relative: one or more leading "..::". - string parentPrefix = PACKAGE_PARENT + PACKAGE_SEPARATOR; // "..::" - if (owner.StartsWith(parentPrefix, StringComparison.Ordinal)) - { - string rest = owner; - int levels = 0; - while (rest.StartsWith(parentPrefix, StringComparison.Ordinal)) - { - levels++; - rest = rest[parentPrefix.Length..]; - } - string[] pkgParts = pkg.Length == 0 - ? Array.Empty() - : pkg.Split(PACKAGE_SEPARATOR, StringSplitOptions.None); - if (levels > pkgParts.Length) - { - throw new InvalidOperationException( - $"Relative reference '{owner}' over-drops: {levels} parent level(s) " + - $"but the package context '{pkg}' has only {pkgParts.Length} segment(s)"); - } - string reduced = string.Join(PACKAGE_SEPARATOR, pkgParts[..(pkgParts.Length - levels)]); - return reduced.Length != 0 ? reduced + PACKAGE_SEPARATOR + rest : rest; - } - - // qualified: contains "::" (not leading) → absolute, unchanged. - if (owner.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) - return owner; - - // bare name → current package (only). Stays bare when context is empty/root. - return pkg.Length != 0 ? pkg + PACKAGE_SEPARATOR + owner : owner; - } - - /// - /// FR-032 — expand a ref-bearing value to FQN when is - /// ref-bearing; otherwise return it unchanged. A ref value is a single string (most - /// refs) or a string-array (@references can carry multiple). A parent-relative - /// over-drop is collected as ERR_BAD_ATTR_VALUE and the raw value passes through. - /// - private static JsonNode? MaybeExpandRef( - string outKey, JsonNode? value, string nodePkg, - List errors, string path) - { - if (!IsRefBearingKey(outKey)) return value; - return ExpandRefValue(value, nodePkg, outKey, errors, path); - } - - private static JsonNode? ExpandRefValue( - JsonNode? value, string nodePkg, string refLabel, - List errors, string path) - { - if (value is JsonValue jv && jv.TryGetValue(out var s)) - { - try - { - return JsonValue.Create(ExpandRef(s, nodePkg)); - } - catch (InvalidOperationException ex) - { - errors.Add(new YamlCollectedError( - $"Cannot expand reference '{refLabel}' at {path}: {ex.Message}", - ErrorCode.ERR_BAD_ATTR_VALUE)); - return value; - } - } - if (value is JsonArray arr) - { - var outArr = new JsonArray(); - foreach (var el in arr) - { - if (el is JsonValue ev && ev.TryGetValue(out _)) - outArr.Add(ExpandRefValue(el.DeepClone(), nodePkg, refLabel, errors, path)); - else - outArr.Add(el?.DeepClone()); - } - return outArr; - } - return value; - } - // ----------------------------------------------------------------------- // Entry point // ----------------------------------------------------------------------- @@ -248,9 +75,9 @@ private static string ExpandOwner(string owner, string packageContext) public static YamlDesugarResult Desugar(YamlNode? input, TypeRegistry registry) { var errors = new List(); - // FR-032: root package context starts empty; each node's own `package` body key - // seeds the context threaded down to its children for ref expansion. - JsonObject? node = DesugarNode(input, registry, errors, "", ""); + // ADR-0059 R1: the desugar does syntax work only; reference values pass through + // unchanged and the loader's one resolver gives them meaning. + JsonObject? node = DesugarNode(input, registry, errors, ""); return new YamlDesugarResult(node ?? new JsonObject(), errors.AsReadOnly()); } @@ -266,8 +93,7 @@ public static YamlDesugarResult Desugar(YamlNode? input, TypeRegistry registry) YamlNode? input, TypeRegistry registry, List errors, - string path, - string parentPkg) + string path) { if (input is null) { @@ -334,14 +160,10 @@ public static YamlDesugarResult Desugar(YamlNode? input, TypeRegistry registry) // Rule 1: a bare `type` key → the type's registry default subType. string canonicalKey = ResolveKey(key, registry, errors, path); - // FR-032: this node's effective package context (its own `package` body key, else - // inherited). Used to expand its ref-bearing attrs AND threaded to its children. - string nodePkg = EffectivePackageFor(rawBody, parentPkg); - // Rule 2: a scalar body → { name: }. // FR5b — pass the wrapper-key position so the scalar-body synthesis // (Rule 2) can attribute the synthesized `name` slot to the wrapper. - JsonObject body = DesugarBody(rawBody, registry, canonicalKey, errors, path, wrapperKeyPos, nodePkg); + JsonObject body = DesugarBody(rawBody, registry, canonicalKey, errors, path, wrapperKeyPos); // Rule 3 (cont.): stamp isArray onto the canonical body. if (isArray) @@ -362,7 +184,7 @@ public static YamlDesugarResult Desugar(YamlNode? input, TypeRegistry registry) for (int i = 0; i < seq.Children.Count; i++) { string childPath = $"{path}.{RESERVED_KEY_CHILDREN}[{i}]"; - JsonObject? child = DesugarNode(seq.Children[i], registry, errors, childPath, nodePkg); + JsonObject? child = DesugarNode(seq.Children[i], registry, errors, childPath); children.Add(child ?? new JsonObject()); } body[RESERVED_KEY_CHILDREN] = children; @@ -434,10 +256,7 @@ private static JsonObject DesugarBody( // to back-fill the `name` slot on Rule-2 scalar-body synthesis; for // mapping bodies, we read each body-key's own position from the // YAML AST (the per-kvp scan below). - YamlPosition? wrapperKeyPos, - // FR-032: this node's effective package context, used to FQN-expand - // ref-bearing attr values (extends + @objectRef/@from/…). - string nodePkg) + YamlPosition? wrapperKeyPos) { var output = new JsonObject(); if (rawBody is null) @@ -533,9 +352,8 @@ private static JsonObject DesugarBody( // ERR_RESERVED_ATTR and emits it with the proper FR5a envelope // (format=json, jsonPath at the parent node). Mirrors TS // yaml-desugar.ts:197-203 and Java YamlDesugar.java:340-349. - // FR-032: expand a ref-bearing value (reserved `extends` or an already- - // `@`-prefixed ref attr) to FQN; the `package` key is never expanded. - output[key] = MaybeExpandRef(key, YamlToJsonNode(value), nodePkg, errors, path); + // ADR-0059 R1: a reference value is copied through exactly as written. + output[key] = YamlToJsonNode(value); outKey = key; if (key.StartsWith(ATTR_PREFIX, StringComparison.Ordinal)) { @@ -551,9 +369,8 @@ private static JsonObject DesugarBody( else { // Rule 4 — sigil-free attr: re-prefix with `@` when lowering to canonical. - // FR-032: a bare ref-bearing attr is expanded to FQN too. outKey = $"{ATTR_PREFIX}{key}"; - output[outKey] = MaybeExpandRef(outKey, YamlToJsonNode(value), nodePkg, errors, path); + output[outKey] = YamlToJsonNode(value); CheckCoercion(key, value, schemaIndex, errors, path); } diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java index 8d77cc454..aa46ab1f6 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java @@ -8,8 +8,14 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.regex.Pattern; /** @@ -21,20 +27,27 @@ * so the backstop was not implemented here at all. A file at a generated output path was * overwritten unconditionally, whatever it contained and whoever wrote it. * - *

What it does, and deliberately does not do. This is the marker floor, not - * edit detection. A file that carries the {@code GENERATED} marker is this toolchain's - * output and is overwritten; a file that does not is somebody's own and is refused. That - * makes taking ownership of a generated file an explicit gesture: delete the marker - * line, and regeneration will never touch the file again. + *

What it does. The write decision comes from a committed HASH MANIFEST of what + * {@code mvn metaobjects:generate} previously wrote — the same rule the TypeScript, Python + * and C# ports already share — not from the {@code GENERATED} marker alone. The marker + * rule it replaced was wrong in both directions: a hand-EDITED generated file keeps its + * marker, so the one case worth protecting was overwritten silently; and a file this + * generator wrote whose template later stopped emitting the marker was refused as though + * it were somebody's hand-written source. The marker still matters at the ONE moment the + * manifest cannot help: an existing file at a generated path that has never been recorded. + * There, carrying the marker is what lets a pre-manifest file (adopted from before this + * project committed {@code .metaobjects/.gen-state/.hashes.json}) be taken as this + * toolchain's own rather than refused as hand-written — see {@link #write} for the full + * decision tree. Absent any manifest configuration on the active {@link Run} (an embedder, + * or a test driving a generator directly), the marker alone still decides, exactly as + * before. * - *

It cannot detect an edit to a file that KEEPS its marker — for that you need a - * record of what was written, which is what the TypeScript port's committed hash manifest - * provides. That machinery is deliberately not replicated here: these ports' customization - * model is build-config and template-spec (see {@code docs/features/own-your-codegen.md}, - * "Per port"), not editing emitted files in place, so the accuracy it buys is not worth a - * committed state file, a migration and a new class of merge conflict on ports whose - * workflow does not ask for it. The same reasoning ADR-0015 applies to schema migrations: - * shared where the guarantee is shared, per-port where the workflow differs. + *

Each {@code } entry in the pom carries its own {@code handEdits} setting + * ({@code keep}, the default, or {@code overwrite}, set via {@link Run#attributeTo(String, + * String)}): {@code keep} refuses a hand-edited file by name; {@code overwrite} replaces it + * with fresh output and re-baselines its recorded hash, printing one line per file so the + * replacement is never silent. {@code overwrite} never applies to a file that was never + * recorded as ours (a stranger) — only to a file the manifest shows this toolchain wrote. * *

Refusing is a WARNING, never a build failure. These generators run inside * {@code mvn metaobjects:generate}, where failing the reactor over a file the user chose @@ -120,16 +133,120 @@ public static final class Run implements AutoCloseable { private final Map contentByPath = new HashMap<>(); private final Map writerByPath = new HashMap<>(); private String current = "(unattributed generator)"; + private String currentHandEdits = HAND_EDITS_KEEP; + private HashManifest.Owner currentOwner; + + // Manifest configuration — unset (null genStateDir/projectRoot) means "no manifest + // for this run", which keeps every write on the legacy marker-only rule. Set once, + // by the Mojo, right after beginRun(). + private Path genStateDir; + private Path projectRoot; + // The manifest, read once when it is configured and written back once by flush(). + private Map hashes; + private Map owners; + private boolean dirty; + + // Every path write() was asked to produce this run, whatever the outcome — written, + // unchanged, refused or adopted. This is the "emitted" set the caller's orphan sweep + // must not touch: a refused or adopted path is still spoken for. + private final Set emittedPaths = new LinkedHashSet<>(); + // Per path, the content hash each owner emitted, for every write that is ours to + // make: a file the adopter hand-owns is no generator's output, so it is left out. + private final Map> emissions = new LinkedHashMap<>(); + // Aggregate state for the Mojo's end-of-run notices. + private final List adoptedPaths = new ArrayList<>(); + private final List overwrittenHandEditedPaths = new ArrayList<>(); + private int untrackedCount = 0; private Run() {} - /** Name the generator whose writes follow. Called once per generator by the runner. */ + /** Name the generator whose writes follow, with {@code handEdits=keep}. Equivalent + * to {@code attributeTo(generatorName, "keep")}. */ public Run attributeTo(String generatorName) { + return attributeTo(generatorName, HAND_EDITS_KEEP, null); + } + + /** + * Name the generator whose writes follow, its {@code handEdits} policy + * ({@link #HAND_EDITS_KEEP} or {@link #HAND_EDITS_OVERWRITE}) and the + * {@link HashManifest.Owner} its writes are recorded against ({@code null}: none). + * Called once per generator by the runner, before that generator executes. + */ + public Run attributeTo(String generatorName, String handEdits, HashManifest.Owner owner) { this.current = generatorName; + this.currentHandEdits = handEdits; + this.currentOwner = owner; return this; } + /** + * Turn on manifest-aware writes for this run: {@code genStateDir} is the directory + * holding {@code .hashes.json} (typically {@code /.metaobjects/.gen-state}), + * and {@code projectRoot} is what manifest keys are relative to (typically the + * module basedir). Called once, by the Mojo, right after {@link #beginRun}. Leaving + * this unset keeps every write on the legacy marker-only rule. + */ + public Run configureManifest(Path genStateDir, Path projectRoot) { + this.genStateDir = genStateDir; + this.projectRoot = projectRoot; + this.hashes = HashManifest.load(genStateDir); + this.owners = HashManifest.loadOwners(genStateDir); + return this; + } + + /** Write this run's manifest changes to disk, if any. The Mojo calls it once every + * generator has run, before its orphan sweep reads the manifest; {@link #close} + * calls it for whatever is still unwritten. */ + public void flush() { + if (!dirty) return; + HashManifest.save(genStateDir, hashes, owners); + dirty = false; + } + + private void record(String key, String hash) { + if (!hash.equals(hashes.put(key, hash))) dirty = true; + HashManifest.Owner previous = currentOwner == null ? owners.remove(key) : owners.put(key, currentOwner); + if (!Objects.equals(previous, currentOwner)) dirty = true; + } + + /** The files written, left in place, or adopted this run — the orphan sweep's + * "do not touch" set. */ + public Set emittedPaths() { + return emittedPaths; + } + + /** Each path a generator emitted as its own output (not refused as hand-owned), with + * the hash of the content each owner emitted there. */ + public Map> emissions() { + return emissions; + } + + /** Project-relative paths adopted into a fresh manifest (or a manifest that had no + * record for them yet) this run — for the Mojo's one-time adoption notice. */ + public List adoptedPaths() { + return adoptedPaths; + } + + /** Paths overwritten under {@code handEdits=overwrite} despite having been + * hand-edited since this toolchain last wrote them. */ + public List overwrittenHandEditedPaths() { + return overwrittenHandEditedPaths; + } + + /** How many writes this run landed outside {@code projectRoot} and so could not be + * recorded in the manifest at all. */ + public int untrackedCount() { + return untrackedCount; + } + + private void note(Path outFile, String content) { + if (currentOwner == null) return; + emissions.computeIfAbsent(outFile.toAbsolutePath().normalize(), k -> new LinkedHashMap<>()) + .putIfAbsent(currentOwner, HashManifest.contentHash(content)); + } + private void claim(Path outFile, String content) { + note(outFile, content); Path key = outFile.toAbsolutePath().normalize(); String previous = contentByPath.get(key); if (previous == null) { @@ -150,7 +267,11 @@ private void claim(Path outFile, String content) { @Override public void close() { - ACTIVE_RUN.remove(); + try { + flush(); + } finally { + ACTIVE_RUN.remove(); + } } } @@ -173,27 +294,131 @@ public static Run currentRun() { return ACTIVE_RUN.get(); } + /** {@link Run#attributeTo(String, String, HashManifest.Owner)}'s default: refuse a hand-edited file by name. */ + public static final String HAND_EDITS_KEEP = "keep"; + /** {@link Run#attributeTo(String, String, HashManifest.Owner)}: replace a hand-edited file with fresh output. */ + public static final String HAND_EDITS_OVERWRITE = "overwrite"; + /** What happened to one file. */ public enum Outcome { - /** Written — the path was new, or held this toolchain's own output. */ + /** Written — the path was new, held this toolchain's own output, or was adopted + * or overwritten per the active {@code handEdits} policy. */ WRITTEN, - /** Left alone — the existing file carries no generated marker. */ - REFUSED + /** Left alone — hand-written (or hand-edited under {@code handEdits=keep}). */ + REFUSED, + /** Nothing to do — the file already holds exactly this content. */ + UNCHANGED } /** * Write {@code content} to {@code outFile} unless an existing file there is not ours, * creating parent directories as needed. Logs its own refusal. * + *

No manifest configured on the active {@link Run} (none open, or + * {@link Run#configureManifest} was never called — an embedder, or a test driving a + * generator directly): the legacy rule stands. A file that carries the + * {@code GENERATED} marker is overwritten; one that does not is refused. + * + *

With a manifest (the normal {@code mvn metaobjects:generate} path), per + * file: + *

    + *
  1. absent → write, record its hash;
  2. + *
  3. exists, no record for this key yet → carries the marker? adopt it + * (write fresh content, record the hash) — this is how a file generated before + * this project committed a manifest, or by a generator just added to one, is + * taken as ours without a human having to say so. No marker → refused, exactly + * as always (a stranger, never touched regardless of {@code handEdits});
  4. + *
  5. exists, content already matches the fresh output → re-affirm the record, + * {@link Outcome#UNCHANGED};
  6. + *
  7. exists, recorded hash matches the current content (pristine) → overwrite, + * record the new hash;
  8. + *
  9. exists, recorded hash does NOT match (hand-edited since we wrote it) → + * {@code handEdits=keep} (default) refuses by name; {@code handEdits=overwrite} + * replaces it and re-baselines the record, logging the replacement.
  10. + *
+ * + *

A path outside the run's configured project root is never recorded (nothing to + * key it by) and falls back to the marker-only rule, same as having no manifest at + * all — the Mojo counts these via {@link Run#untrackedCount()}. + * *

Throws {@link IOException} rather than wrapping it: every call site already sits * inside a {@code try/catch (IOException)} that raises a {@link GeneratorException} * naming the specific artifact. Swallowing it here would make those catches * unreachable — a compile error — and would replace each generator's precise message * with a generic one. - * - * @return {@link Outcome#WRITTEN} or {@link Outcome#REFUSED} */ public static Outcome write(Path outFile, String content) throws IOException { + Run run = ACTIVE_RUN.get(); + if (run != null) { + run.emittedPaths.add(outFile.toAbsolutePath().normalize()); + } + + if (run == null || run.genStateDir == null) { + return writeMarkerOnly(outFile, content, run); + } + + String key = OrphanSweep.keyFor(run.projectRoot, outFile); + if (key == null) { + run.untrackedCount++; + return writeMarkerOnly(outFile, content, run); + } + + if (!Files.exists(outFile)) { + performWrite(outFile, content, run); + run.record(key, HashManifest.contentHash(content)); + return Outcome.WRITTEN; + } + + String current = Files.readString(outFile, StandardCharsets.UTF_8); + String recorded = run.hashes.get(key); + + if (recorded == null) { + // Never recorded under this key. Marker present ⇒ this toolchain's own prior + // output, from before a manifest existed (or before this generator's entry did) + // — adopt it. No marker ⇒ hand-written, refused exactly as always; `handEdits` + // never applies here, because a file never recorded as ours is not ours to + // replace under any policy. + if (looksGenerated(current)) { + performWrite(outFile, content, run); + run.record(key, HashManifest.contentHash(content)); + run.adoptedPaths.add(outFile.toString()); + return Outcome.WRITTEN; + } + LOG.warn(refusedMessage(outFile)); + return Outcome.REFUSED; + } + + if (current.equals(content)) { + // Nothing to write, but re-affirm the record: a first run over already-correct + // output should leave the file recognised as ours next time. + run.claim(outFile, content); + run.record(key, HashManifest.contentHash(content)); + return Outcome.UNCHANGED; + } + + if (recorded.equals(HashManifest.contentHash(current))) { + // Pristine: matches what we last wrote, safe to overwrite with fresh content. + performWrite(outFile, content, run); + run.record(key, HashManifest.contentHash(content)); + return Outcome.WRITTEN; + } + + // Edited: differs from both the fresh output and the last recorded hash. + if (HAND_EDITS_OVERWRITE.equals(run.currentHandEdits)) { + performWrite(outFile, content, run); + run.record(key, HashManifest.contentHash(content)); + run.overwrittenHandEditedPaths.add(outFile.toString()); + LOG.warn("overwrote hand-edited " + outFile + " (handEdits: overwrite)"); + return Outcome.WRITTEN; + } + run.record(key, recorded); + run.note(outFile, content); + LOG.warn(refusedHandEditedMessage(outFile)); + return Outcome.REFUSED; + } + + /** The legacy, manifest-less decision: overwrite a marker-bearing file, refuse any other. */ + private static Outcome writeMarkerOnly(Path outFile, String content, Run run) throws IOException { // The marker question comes FIRST, and a refusal returns before anything is // claimed. A path the adopter hand-owns is a path NO generator can write, so two // generators emitting different content there is not an order-dependent output — @@ -205,12 +430,16 @@ public static Outcome write(Path outFile, String content) throws IOException { LOG.warn(refusedMessage(outFile)); return Outcome.REFUSED; } - // Now the ordering question, asked only of writes that will actually land: did a - // sibling generator in this run already claim this path with different content? - // That is a defect in the generator SELECTION, not in the file on disk, so it is - // raised rather than warned — an order-dependent output is not something the user - // can act on from a log line. - Run run = ACTIVE_RUN.get(); + performWrite(outFile, content, run); + return Outcome.WRITTEN; + } + + /** + * Claim the path in the active run (the ordering/collision check — raised, never + * warned, since an order-dependent output is not something the user can act on from a + * log line) and write it to disk, creating parent directories as needed. + */ + private static void performWrite(Path outFile, String content, Run run) throws IOException { if (run != null) { run.claim(outFile, content); } @@ -218,7 +447,6 @@ public static Outcome write(Path outFile, String content) throws IOException { Files.createDirectories(outFile.getParent()); } Files.writeString(outFile, content, StandardCharsets.UTF_8); - return Outcome.WRITTEN; } /** @@ -233,4 +461,16 @@ public static String refusedMessage(Path outFile) { + "' marker, so it is treated as hand-written and was left untouched. " + "If you want the generated version, move or delete this file and re-run."; } + + /** + * The message shown when a file this toolchain DID previously write is refused because + * it was edited since ({@code handEdits=keep}, the default). + */ + public static String refusedHandEditedMessage(Path outFile) { + return "refusing to overwrite " + outFile + " — it was edited since metaobjects " + + "generated it, so it is treated as hand-edited and was left untouched " + + "(handEdits: keep). Move your edits into a file of your own, delete this file and " + + "re-run to accept the generated version, or set this generator's to " + + "'overwrite' to have it replace hand edits automatically."; + } } diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/HashManifest.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/HashManifest.java new file mode 100644 index 000000000..8dcd96e76 --- /dev/null +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/HashManifest.java @@ -0,0 +1,202 @@ +package com.metaobjects.generator.util; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; + +/** + * The committed codegen hash manifest — how a run tells its own prior output from a hand + * edit, replacing the {@code GENERATED} marker as the sole overwrite signal. + * + *

Mirrors the shape and algorithm the TypeScript, Python and C# ports already share: + * a JSON object {@code { "": "" }}, keys sorted, + * pretty-printed, one trailing newline, at + * {@code /.metaobjects/.gen-state/.hashes.json} where {@code } is the + * Maven project base dir. The KEYS are not meant to be portable between ports — each + * keys relative to its own notion of "the project" — only the shape and the hashing + * algorithm (sha-256 hex of the UTF-8 content) are shared, so identical content hashes + * identically everywhere. + * + *

This file is meant to be COMMITTED: one hash per generated path, small and + * reviewable, where a full snapshot of every previously-generated file would be a second + * copy of everything. A hash already answers the only question the write decision needs: + * is this file byte-for-byte what we wrote last time? + * + *

An entry this port writes also names its {@link Owner}, what emitted the file: the + * generator's registry id and its output directory within the module, {@code "": { + * "hash": "", "generator": "", "outputDir": "

" }}. The owner does + * not depend on which Maven execution ran, so a command-line run, a renamed execution and + * the lifecycle execution with the same configuration agree on it. An entry in the plain + * {@code "": ""} form, the form the other ports write, reads as recorded with + * no owner, and so does an entry carrying the execution-id {@code "owner"} field an + * earlier 8.1.0 build wrote. + */ +public final class HashManifest { + + private HashManifest() {} + + /** What emitted a recorded file: a generator's registry id and its module-relative + * output directory. */ + public record Owner(String generator, String outputDir) { + @Override + public String toString() { + return generator + " -> " + outputDir; + } + } + + private static final String FILE_NAME = ".hashes.json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + + private static Path pathFor(Path genStateDir) { + return genStateDir.resolve(FILE_NAME); + } + + /** sha-256 hex (lowercase) of {@code content}'s UTF-8 bytes. */ + public static String contentHash(String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by every JDK provider; this cannot happen in practice. + throw new IllegalStateException("SHA-256 not available", e); + } + } + + /** + * Whether this project has a manifest AT ALL — distinct from "the manifest has no + * entry for this path". A project with no manifest predates this toolchain's hash + * manifest, so a file at a generated path with no record deserves adoption (if it + * carries the marker) rather than a cold refusal. + */ + public static boolean exists(Path genStateDir) { + return Files.isRegularFile(pathFor(genStateDir)); + } + + /** + * Load the manifest's hashes. An absent or corrupt one reads as EMPTY, which fails + * closed — every path then reads as unrecorded rather than being guessed at. + */ + public static Map load(Path genStateDir) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : entries(genStateDir).entrySet()) { + JsonElement value = e.getValue(); + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + out.put(e.getKey(), value.getAsString()); + } else if (value.isJsonObject()) { + String v = stringField(value.getAsJsonObject(), "hash"); + if (v != null) out.put(e.getKey(), v); + } + } + return out; + } + + /** Load the owner of every entry that names one; an entry with no owner is absent. */ + public static Map loadOwners(Path genStateDir) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : entries(genStateDir).entrySet()) { + if (!e.getValue().isJsonObject()) continue; + String generator = stringField(e.getValue().getAsJsonObject(), "generator"); + String outputDir = stringField(e.getValue().getAsJsonObject(), "outputDir"); + if (generator != null && outputDir != null) out.put(e.getKey(), new Owner(generator, outputDir)); + } + return out; + } + + private static Map entries(Path genStateDir) { + Map out = new LinkedHashMap<>(); + Path path = pathFor(genStateDir); + if (!Files.isRegularFile(path)) return out; + JsonElement parsed; + try { + parsed = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)); + } catch (IOException | JsonParseException e) { + return out; + } + if (!parsed.isJsonObject()) return out; + for (Map.Entry e : parsed.getAsJsonObject().entrySet()) { + out.put(e.getKey(), e.getValue()); + } + return out; + } + + private static String stringField(JsonObject object, String field) { + JsonElement v = object.get(field); + return v != null && v.isJsonPrimitive() && v.getAsJsonPrimitive().isString() ? v.getAsString() : null; + } + + /** + * Persist the manifest with SORTED keys, pretty-printed, one trailing newline. A key + * with an owner is written as {@code { "hash", "generator", "outputDir" }}, one without + * as the plain hash. Sorted because the file is committed: insertion order would make + * the diff — and any merge conflict between two people who both regenerated — depend + * on which generator happened to run first. + */ + public static void save(Path genStateDir, Map hashes, Map owners) { + try { + Files.createDirectories(genStateDir); + JsonObject root = new JsonObject(); + for (Map.Entry e : new TreeMap<>(hashes).entrySet()) { + Owner owner = owners.get(e.getKey()); + if (owner == null) { + root.addProperty(e.getKey(), e.getValue()); + } else { + JsonObject entry = new JsonObject(); + entry.addProperty("hash", e.getValue()); + entry.addProperty("generator", owner.generator()); + entry.addProperty("outputDir", owner.outputDir()); + root.add(e.getKey(), entry); + } + } + Files.writeString(pathFor(genStateDir), GSON.toJson(root) + "\n", StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Could not write codegen hash manifest at " + genStateDir, e); + } + } + + /** Record that {@code owner} wrote {@code key} with {@code content}: load, mutate, save. */ + public static void record(Path genStateDir, String key, String content, Owner owner) { + recordHash(genStateDir, key, contentHash(content), owner); + } + + /** Record {@code hash} for {@code key}, owned by {@code owner} ({@code null}: no owner). */ + public static void recordHash(Path genStateDir, String key, String hash, Owner owner) { + Map hashes = load(genStateDir); + Map owners = loadOwners(genStateDir); + hashes.put(key, hash); + if (owner == null) owners.remove(key); + else owners.put(key, owner); + save(genStateDir, hashes, owners); + } + + /** The hash recorded for {@code key}, or {@code null} if never recorded. */ + public static String recordedHash(Path genStateDir, String key) { + return load(genStateDir).get(key); + } + + /** Whether {@code current} is byte-for-byte what was recorded under {@code key}. + * FAILS CLOSED — {@code false} when it cannot be proven. */ + public static boolean isPristine(Path genStateDir, String key, String current) { + String recorded = recordedHash(genStateDir, key); + return recorded != null && recorded.equals(contentHash(current)); + } +} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/OrphanSweep.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/OrphanSweep.java new file mode 100644 index 000000000..021dbb0b7 --- /dev/null +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/OrphanSweep.java @@ -0,0 +1,262 @@ +package com.metaobjects.generator.util; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * The orphan sweep: delete a file {@code mvn metaobjects:generate} wrote earlier and no + * longer generates. + * + *

The rule every port shares (TypeScript's {@code runner.ts}/{@code orphan-sweep.ts}, + * Python's {@code codegen/orphan_sweep.py}, C#'s {@code OrphanSweep.cs}): + * + *

    + *
  • A candidate is a file under an output directory of a FULL run that the hash + * manifest records writing and that no generator of the run emitted this time. + * A file the manifest does not record is not ours, whatever its name, and is + * never touched (a {@link Verdict#STRANGER}).
  • + *
  • On the JVM every entry names its {@link HashManifest.Owner}, the generator and + * output directory that emitted it ({@link #isCandidate}). An entry is this run's + * to judge when this run's own generators own it, or when what owns it is configured + * in no {@code metaobjects:generate} execution of the module (a removed generator, + * or an entry with no owner in one of this run's output directories). An entry + * another configured generator owns is that generator's, even in a shared output + * directory.
  • + *
  • A file is judged once, by the innermost swept directory that holds it, so a + * directory nested in another is judged by its own generators' policy; a file in + * none of them is judged as {@code keep}.
  • + *
  • An untouched candidate ({@link Verdict#PRISTINE} — its content still hashes to + * what was recorded) is deleted and its manifest entry dropped.
  • + *
  • An edited candidate ({@link Verdict#EDITED}) is, by default, REFUSED by name and + * stays recorded, so the refusal repeats until someone deletes the file or moves + * the edits — unless the directory it lives under is entirely owned by generators + * whose {@code handEdits} setting is {@code overwrite}, in which case it is deleted + * too (there being no record of which generator wrote an orphan, {@code overwrite} + * only applies when EVERY generator writing into that directory this run agrees).
  • + *
  • A recorded path whose file is already gone is forgotten.
  • + *
+ * + *

{@code metaobjects:verify} convicts an extra committed file on the same evidence + * ({@link #classify}), so {@code generate} removes exactly the untouched files the gate + * reports, which is what makes the gate's "regenerate" remedy work. + */ +public final class OrphanSweep { + + private OrphanSweep() {} + + /** How a committed file relates to the manifest. */ + public enum Verdict { + /** Not recorded: never ours, never touched. */ + STRANGER, + /** Recorded and byte-for-byte what was recorded: safe to delete/overwrite. */ + PRISTINE, + /** Recorded but its content has changed since: hand-edited. */ + EDITED + } + + /** One directory a run's generators wrote into. */ + public static final class Entry { + public final Path dir; + /** Prefix shown before a dir-relative path when reporting a file under this dir. */ + public final String label; + /** True iff every generator that writes into this dir this run has {@code handEdits=overwrite}. */ + public final boolean allowOverwrite; + + public Entry(Path dir, String label, boolean allowOverwrite) { + this.dir = dir; + this.label = label; + this.allowOverwrite = allowOverwrite; + } + } + + /** The outcome of one sweep. */ + public static final class Result { + /** Pristine orphans deleted, as shown (label + dir-relative path). */ + public final List removed; + /** Edited orphans deleted because their directory's generators are all {@code overwrite}. */ + public final List overwrittenDeleted; + /** Project-relative keys of edited orphans left in place (handEdits=keep). */ + public final List refused; + + public Result(List removed, List overwrittenDeleted, List refused) { + this.removed = removed; + this.overwrittenDeleted = overwrittenDeleted; + this.refused = refused; + } + } + + /** + * The project-root-relative, forward-slash key for {@code file}, or {@code null} when + * {@code file} lies outside {@code projectRoot} — nothing outside the project is ever + * recorded, so nothing outside it can be classified. + */ + public static String keyFor(Path projectRoot, Path file) { + Path root = projectRoot.toAbsolutePath().normalize(); + Path full = file.toAbsolutePath().normalize(); + Path rel; + try { + rel = root.relativize(full); + } catch (IllegalArgumentException e) { + return null; // different filesystem roots (Windows drives) — definitely outside + } + String key = rel.toString().replace(java.io.File.separatorChar, '/'); + return key.startsWith("..") ? null : key; + } + + /** + * Classify one committed file against the manifest: not recorded ({@link Verdict#STRANGER}), + * recorded and unchanged ({@link Verdict#PRISTINE}), or recorded and changed + * ({@link Verdict#EDITED}). An unreadable file that IS recorded reads as edited — + * fail closed, never silently dropped. + */ + public static Verdict classify(Map hashes, Path projectRoot, Path file) { + String key = keyFor(projectRoot, file); + if (key == null) return Verdict.STRANGER; + String recorded = hashes.get(key); + if (recorded == null) return Verdict.STRANGER; + String current; + try { + current = Files.readString(file, StandardCharsets.UTF_8); + } catch (IOException e) { + return Verdict.EDITED; + } + return recorded.equals(HashManifest.contentHash(current)) ? Verdict.PRISTINE : Verdict.EDITED; + } + + /** + * Whether the sweep may judge an entry owned by {@code owner} ({@code null}: recorded + * with no owner): {@code runOwners} own it, or {@code configured} (every + * generator/output-directory pair configured in the module; {@code null} when that is + * unknown) does not hold it. An entry with no owner counts only inside one of this + * run's output directories ({@code inRunDir}), since outside them it may be another + * port's. {@code configured} is asked only when the answer depends on it. + */ + public static boolean isCandidate(HashManifest.Owner owner, boolean inRunDir, + Set runOwners, Supplier> configured) { + if (owner != null && runOwners.contains(owner)) return true; + if (owner == null && !inRunDir) return false; + Set known = configured.get(); + return known != null && (owner == null || !known.contains(owner)); + } + + /** + * Delete the untouched recorded files no generator of this run emitted ({@code emitted}: + * normalized absolute paths this run produced, refused hand-edits included) that the + * run may judge ({@link #isCandidate}), and forget every such entry whose file is + * already gone. No-op (empty result, manifest untouched) when the project has no + * manifest yet — there is nothing recorded, so nothing could be an orphan. + */ + public static Result sweep(Path genStateDir, Path projectRoot, List dirs, Set emitted, + Set runOwners, Supplier> configured) { + if (!HashManifest.exists(genStateDir)) { + return new Result(List.of(), List.of(), List.of()); + } + Map hashes = HashManifest.load(genStateDir); + Map owners = HashManifest.loadOwners(genStateDir); + Set forget = new LinkedHashSet<>(); + List removed = new ArrayList<>(); + List overwrittenDeleted = new ArrayList<>(); + List refused = new ArrayList<>(); + Path root = projectRoot.toAbsolutePath().normalize(); + + Map byDir = new LinkedHashMap<>(); + for (Entry e : dirs) { + byDir.putIfAbsent(e.dir.toAbsolutePath().normalize(), e); + } + + for (String key : hashes.keySet()) { + Path path = root.resolve(key).normalize(); + if (!path.startsWith(root) || emitted.contains(path)) continue; + Path dir = innermost(byDir.keySet(), path); + if (!isCandidate(owners.get(key), dir != null, runOwners, configured)) continue; + if (!Files.exists(path)) { + // Already gone: forget it, so a stranger's file created later under that + // name is not mistaken for ours. + forget.add(key); + continue; + } + if (!Files.isRegularFile(path)) continue; + + Entry e = dir != null ? byDir.get(dir) : null; + String shown = e != null + ? e.label + dir.relativize(path).toString().replace(java.io.File.separatorChar, '/') + : key; + switch (classify(hashes, root, path)) { + case PRISTINE: + delete(path); + forget.add(key); + removed.add(shown); + break; + case EDITED: + if (e != null && e.allowOverwrite) { + delete(path); + forget.add(key); + overwrittenDeleted.add(shown); + } else { + refused.add(key); + } + break; + case STRANGER: + default: + break; + } + } + + if (!forget.isEmpty()) { + Map updated = new LinkedHashMap<>(hashes); + for (String key : forget) updated.remove(key); + HashManifest.save(genStateDir, updated, owners); + } + + removed.sort(String::compareTo); + overwrittenDeleted.sort(String::compareTo); + List distinctRefused = refused.stream().distinct().sorted().collect(Collectors.toList()); + return new Result(removed, overwrittenDeleted, distinctRefused); + } + + /** The deepest of {@code dirs} holding {@code path}, or {@code null}. */ + public static Path innermost(Iterable dirs, Path path) { + Path best = null; + for (Path d : dirs) { + if (path.startsWith(d) && (best == null || d.getNameCount() > best.getNameCount())) best = d; + } + return best; + } + + private static void delete(Path path) { + try { + Files.delete(path); + } catch (IOException ex) { + throw new UncheckedIOException("Could not delete orphaned generated file [" + path + "]", ex); + } + } + + /** + * The aggregate warning for edited orphans left in place (handEdits=keep). The same + * wording, shared verbatim with codegen-ts's {@code refusedOrphanMessage}, Python's + * {@code refused_orphan_message} and C#'s {@code OrphanSweep.RefusedMessage}: what + * happened, why nothing was deleted, and the two ways out. + */ + public static String refusedMessage(List paths, String owner) { + return owner + ": " + paths.size() + " generated file(s) are no longer generated but have been " + + "edited by hand, so they were NOT deleted: " + String.join(", ", paths) + ". Delete each " + + "one, or, if what produced it was renamed, move your edits into the newly generated file " + + "first and then delete it (regeneration cannot follow a rename)."; + } + + /** The per-file notice for an edited orphan deleted under {@code handEdits=overwrite}. */ + public static String overwrittenOrphanMessage(String path) { + return "deleted hand-edited " + path + ", which no generator emits any more (handEdits: overwrite)"; + } +} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java index 690068363..97f95fb9c 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java @@ -12,7 +12,7 @@ import com.metaobjects.template.MetaTemplate; import com.metaobjects.template.PromptTemplate; import com.metaobjects.template.TemplateConstants; -import com.metaobjects.validation.SymbolTable; +import com.metaobjects.loader.References; import java.nio.file.Path; import java.util.ArrayList; @@ -96,14 +96,6 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) { List warnings = new ArrayList<>(); List unresolved = new ArrayList<>(); - // ADR-0042 — resolve @payloadRef through the loader's OWN package-local symbol table - // (#228), so a bare ref binds the template's own package (else root-level) and an FQN - // binds exactly — the same contract the loader validated the ref under. The prior - // package-blind bare-tail scan bound a same-short-named value-object in the WRONG - // package under a cross-package collision (load-order-dependent), deriving the wrong - // field tree and mis-reporting {{field}} drift. - SymbolTable symbols = SymbolTable.build(loader.getRoot()); - for (com.metaobjects.MetaData child : loader.getRoot().getChildren()) { if (!(child instanceof MetaTemplate tmpl)) continue; @@ -116,7 +108,7 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) { // Both subtypes: @payloadRef must resolve to a loaded object.value (a // non-empty derived field tree). Catches a renamed VO before codegen. - MetaObject payloadVo = resolveValueObject(symbols, payloadRef, tmpl.getPackage()); + MetaObject payloadVo = valueObjectOrNull(References.refTarget(tmpl, "payloadRef")); List fields = payloadVo == null ? List.of() : derivePayloadFieldTree(loader, payloadVo, new LinkedHashSet<>()); @@ -207,32 +199,10 @@ private static List derivePayloadFieldTree( } /** - * Resolve a {@code field.object}'s {@code @objectRef} to its target - * {@code object.value} by BARE short-name (the cross-port render-helper - * consensus; the raw attr value is read directly, NOT package-folded). + * The {@code object.value} a {@code field.object}'s {@code @objectRef} is bound to. */ private static MetaObject resolveNestedObjectRef(MetaDataLoader loader, ObjectField field) { - if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF, false)) return null; - String ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF, false).getValueAsString(); - if (ref == null || ref.isEmpty()) return null; - // ADR-0041: a FULLY-QUALIFIED @objectRef (contains "::") resolves EXACTLY on - // the value-object's package-qualified name — never a bare-tail fallback. A - // bare-tail match binds a same-named object.value in the WRONG package when - // the short name collides across packages (e.g. two prompt trees each declare - // their own `PlayerActionView`/`MemoryView`), yielding the wrong element - // field-tree and spurious ERR_VAR_NOT_ON_PAYLOAD on the inner {{fields}}. A - // bare ref (no "::") still matches the object.value's short name (the - // cross-port render-helper consensus). Mirrors the Kotlin render helper. - boolean fqn = ref.contains("::"); - String refShort = shortName(ref); - for (MetaObject obj : loader.getMetaObjects()) { - if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue; - boolean matches = fqn - ? obj.getName().equals(ref) - : shortName(obj.getName()).equals(refShort); - if (matches) return obj; - } - return null; + return valueObjectOrNull(References.refTarget(field, MetaObject.ATTR_OBJECT_REF)); } /** @@ -249,20 +219,7 @@ private static void addIfPresent(List refs, String ref) { if (ref != null && !ref.isEmpty()) refs.add(ref); } - /** - * Resolve {@code @payloadRef} to its {@code object.value} target under the loader's ADR-0042 - * package-local contract (rejects entities). {@code referrerPkg} is the template's own package - * ({@code ""} for a root-level template): a bare ref binds {@code ::} first, - * else a root-level object; an FQN binds exactly — no cross-package bare-name fallback. - */ - private static MetaObject resolveValueObject(SymbolTable symbols, String ref, String referrerPkg) { - MetaObject obj = symbols.resolveObject(ref, referrerPkg == null ? "" : referrerPkg); + private static MetaObject valueObjectOrNull(MetaObject obj) { return (obj != null && MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) ? obj : null; } - - /** Last {@code ::} segment of a (possibly packaged) metadata name. */ - private static String shortName(String fqn) { - int sep = fqn.lastIndexOf("::"); - return sep < 0 ? fqn : fqn.substring(sep + 2); - } } diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/template/TemplateDataTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/template/TemplateDataTest.java index 40dd5ca39..f59bddf41 100644 --- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/template/TemplateDataTest.java +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/template/TemplateDataTest.java @@ -61,7 +61,7 @@ private MetaObject obj(MetaDataLoader loader, String bareName) { assertEquals(1, rels.size()); assertEquals("product", rels.get(0).get("name")); assertEquals("one", rels.get(0).get("cardinality")); - assertEquals("Product", rels.get(0).get("targetRef")); + assertEquals("shop::Product", rels.get(0).get("targetRef")); } /** diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java index 46661051e..e09e14925 100644 --- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java @@ -7,8 +7,10 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -243,4 +245,230 @@ public void closingARunEndsItsScope() throws Exception { // The same path, different content, is now free again — a new build, not a collision. GeneratedFileWriter.write(out, "/** GENERATED */\nclass Scoped { int b; }\n"); } + + // === manifest-aware writes ================================================ + + @Test + public void absentFile_writesAndRecords() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, GENERATED_BODY)); + } + assertEquals(GENERATED_BODY, Files.readString(out)); + assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/X.java")); + } + + @Test + public void manifestIsWrittenOncePerRun_onFlushNotOnEachWrite() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + GeneratedFileWriter.write(project.resolve("gen/A.java"), GENERATED_BODY); + GeneratedFileWriter.write(project.resolve("gen/B.java"), GENERATED_BODY); + assertFalse(HashManifest.exists(genState)); + run.flush(); + assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/A.java")); + assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/B.java")); + } + } + + @Test + public void runThatChangesNothing_leavesTheManifestFileAlone() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + Files.createDirectories(out.getParent()); + Files.writeString(out, GENERATED_BODY); + Files.createDirectories(genState); + String manifest = "{\"gen/X.java\": \"" + HashManifest.contentHash(GENERATED_BODY) + "\"}"; + Files.writeString(genState.resolve(".hashes.json"), manifest); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + assertEquals(GeneratedFileWriter.Outcome.UNCHANGED, GeneratedFileWriter.write(out, GENERATED_BODY)); + } + assertEquals("not rewritten (a rewrite would pretty-print it)", manifest, + Files.readString(genState.resolve(".hashes.json"))); + } + + @Test + public void firstRunAdoption_markerFilePresentNoManifest_isAdoptedNotRefused() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + Files.createDirectories(out.getParent()); + Files.writeString(out, GENERATED_BODY); // pre-existing, never recorded + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, GENERATED_BODY)); + assertEquals(List.of(out.toString()), run.adoptedPaths()); + } + assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/X.java")); + } + + @Test + public void firstRunAdoption_noMarkerNoManifest_isRefusedNotAdopted() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + Files.createDirectories(out.getParent()); + String mine = "// hand-written, no marker\npublic class X {}\n"; + Files.writeString(out, mine); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, GENERATED_BODY)); + assertTrue(run.adoptedPaths().isEmpty()); + } + assertEquals(mine, Files.readString(out)); + assertNull(HashManifest.recordedHash(genState, "gen/X.java")); + } + + @Test + public void pristineRecordedFile_isOverwrittenAndReRecorded() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + GeneratedFileWriter.write(out, GENERATED_BODY); + } + String fresh = "/** GENERATED — REST controller for Council. */\npublic class X { int y; }\n"; + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, fresh)); + } + assertEquals(fresh, Files.readString(out)); + assertEquals(HashManifest.contentHash(fresh), HashManifest.recordedHash(genState, "gen/X.java")); + } + + @Test + public void unchangedContent_isReaffirmedNotRewritten() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + GeneratedFileWriter.write(out, GENERATED_BODY); + } + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + assertEquals(GeneratedFileWriter.Outcome.UNCHANGED, GeneratedFileWriter.write(out, GENERATED_BODY)); + } + } + + @Test + public void unchangedWriteStillClaimsThePath_soADifferingSecondEmitterCollides() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + GeneratedFileWriter.write(out, GENERATED_BODY); + } + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + run.attributeTo("GeneratorA"); + assertEquals(GeneratedFileWriter.Outcome.UNCHANGED, GeneratedFileWriter.write(out, GENERATED_BODY)); + run.attributeTo("GeneratorB"); + try { + GeneratedFileWriter.write(out, "/** GENERATED */\npublic class X { int other; }\n"); + fail("expected an output path collision"); + } catch (GeneratorException e) { + assertTrue(e.getMessage(), e.getMessage().contains("Output path collision") + && e.getMessage().contains("GeneratorA") && e.getMessage().contains("GeneratorB")); + } + } + assertEquals(GENERATED_BODY, Files.readString(out)); + } + + @Test + public void editedRecordedFile_handEditsKeep_isRefusedByNameAndStaysRecorded() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + GeneratedFileWriter.write(out, GENERATED_BODY); + } + String edited = GENERATED_BODY + "// mine\n"; + Files.writeString(out, edited); + + String fresh = "/** GENERATED — REST controller for Council. */\npublic class X { int z; }\n"; + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_KEEP, null); + assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, fresh)); + } + assertEquals(edited, Files.readString(out)); + // Still recorded under its ORIGINAL hash — the refusal must repeat next run. + assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/X.java")); + } + + @Test + public void editedRecordedFile_handEditsOverwrite_isReplacedAndReBaselined() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + GeneratedFileWriter.write(out, GENERATED_BODY); + } + Files.writeString(out, GENERATED_BODY + "// mine\n"); + + String fresh = "/** GENERATED — REST controller for Council. */\npublic class X { int z; }\n"; + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_OVERWRITE, null); + assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, fresh)); + assertEquals(List.of(out.toString()), run.overwrittenHandEditedPaths()); + } + assertEquals(fresh, Files.readString(out)); + assertEquals(HashManifest.contentHash(fresh), HashManifest.recordedHash(genState, "gen/X.java")); + } + + @Test + public void strangerFile_isNeverTouchedByOverwritePolicy() throws Exception { + // A file never recorded as ours stays refused even when the generator's policy is + // `overwrite` — `overwrite` only ever replaces what the manifest shows we wrote. + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path out = project.resolve("gen/X.java"); + Files.createDirectories(out.getParent()); + String mine = "// never ours\npublic class X {}\n"; + Files.writeString(out, mine); + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_OVERWRITE, null); + assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, GENERATED_BODY)); + } + assertEquals(mine, Files.readString(out)); + } + + @Test + public void untrackedPathOutsideProjectRoot_fallsBackToMarkerOnlyRule() throws Exception { + Path project = tmp.newFolder().toPath(); + Path genState = project.resolve(".metaobjects/.gen-state"); + Path outside = tmp.newFolder().toPath().resolve("X.java"); // sibling, not under project + + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project); + assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(outside, GENERATED_BODY)); + assertEquals(1, run.untrackedCount()); + } + assertEquals(GENERATED_BODY, Files.readString(outside)); + } } diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/HashManifestTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/HashManifestTest.java new file mode 100644 index 000000000..062bd337f --- /dev/null +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/HashManifestTest.java @@ -0,0 +1,119 @@ +package com.metaobjects.generator.util; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** The committed hash manifest: shape, merge semantics, fail-closed reads. */ +public class HashManifestTest { + + private static final HashManifest.Owner OWNER = new HashManifest.Owner("entity", "gen"); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void absentManifestDoesNotExistAndLoadsEmpty() throws Exception { + Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); + assertFalse(HashManifest.exists(genState)); + assertTrue(HashManifest.load(genState).isEmpty()); + assertNull(HashManifest.recordedHash(genState, "src/X.java")); + } + + @Test + public void recordThenExistsThenRecordedHash() throws Exception { + Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); + String content = "public class X {}\n"; + HashManifest.record(genState, "src/X.java", content, OWNER); + + assertTrue(HashManifest.exists(genState)); + assertEquals(HashManifest.contentHash(content), HashManifest.recordedHash(genState, "src/X.java")); + } + + @Test + public void recordMergesRatherThanOverwritingOtherKeys() throws Exception { + Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); + HashManifest.record(genState, "src/A.java", "a", OWNER); + HashManifest.record(genState, "src/B.java", "b", OWNER); + + Map loaded = HashManifest.load(genState); + assertEquals(2, loaded.size()); + assertEquals(HashManifest.contentHash("a"), loaded.get("src/A.java")); + assertEquals(HashManifest.contentHash("b"), loaded.get("src/B.java")); + } + + @Test + public void savedFileIsSortedPrettyAndTrailingNewline() throws Exception { + Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); + HashManifest.record(genState, "src/Z.java", "z", OWNER); + HashManifest.record(genState, "src/A.java", "a", OWNER); + + String raw = Files.readString(genState.resolve(".hashes.json")); + assertTrue("expected a trailing newline", raw.endsWith("\n")); + // Sorted: A before Z. + assertTrue(raw.indexOf("src/A.java") < raw.indexOf("src/Z.java")); + // Pretty-printed: not a single line. + assertTrue(raw.contains("\n ")); + } + + @Test + public void isPristineFailsClosedWhenNeverRecorded() throws Exception { + Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); + assertFalse(HashManifest.isPristine(genState, "src/Never.java", "anything")); + } + + @Test + public void isPristineTrueOnlyForExactRecordedContent() throws Exception { + Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); + HashManifest.record(genState, "src/X.java", "original", OWNER); + + assertTrue(HashManifest.isPristine(genState, "src/X.java", "original")); + assertFalse(HashManifest.isPristine(genState, "src/X.java", "edited")); + } + + @Test + public void contentHashIsStableAndLowercaseHex() { + String h1 = HashManifest.contentHash("hello"); + String h2 = HashManifest.contentHash("hello"); + assertEquals(h1, h2); + assertEquals(64, h1.length()); + assertEquals(h1, h1.toLowerCase()); + } + + @Test + public void ownedEntryIsWrittenWithItsOwner_plainAndExecutionIdEntriesReadAsUnowned() throws Exception { + Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); + Files.createDirectories(genState); + // The plain form, as the TypeScript, Python and C# ports write it, and the + // execution-id owner an earlier 8.1.0 build wrote. + Files.writeString(genState.resolve(".hashes.json"), "{\n \"src/Plain.java\": \"abc\",\n" + + " \"src/Legacy.java\": { \"hash\": \"def\", \"owner\": \"app:codegen\" }\n}\n"); + + HashManifest.record(genState, "src/Owned.java", "owned", OWNER); + + Map hashes = HashManifest.load(genState); + assertEquals("abc", hashes.get("src/Plain.java")); + assertEquals("def", hashes.get("src/Legacy.java")); + assertEquals(HashManifest.contentHash("owned"), hashes.get("src/Owned.java")); + Map owners = HashManifest.loadOwners(genState); + assertEquals(Map.of("src/Owned.java", OWNER), owners); + + com.google.gson.JsonObject raw = com.google.gson.JsonParser.parseString( + Files.readString(genState.resolve(".hashes.json"))).getAsJsonObject(); + assertEquals("abc", raw.get("src/Plain.java").getAsString()); + assertEquals("def", raw.get("src/Legacy.java").getAsString()); + com.google.gson.JsonObject owned = raw.getAsJsonObject("src/Owned.java"); + assertEquals(HashManifest.contentHash("owned"), owned.get("hash").getAsString()); + assertEquals("entity", owned.get("generator").getAsString()); + assertEquals("gen", owned.get("outputDir").getAsString()); + } +} diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/OrphanSweepTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/OrphanSweepTest.java new file mode 100644 index 000000000..ec55953c9 --- /dev/null +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/OrphanSweepTest.java @@ -0,0 +1,347 @@ +package com.metaobjects.generator.util; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** The orphan sweep: classify, delete, refuse, forget — against a real manifest and tree. */ +public class OrphanSweepTest { + + private static final HashManifest.Owner OWNER = new HashManifest.Owner("entity", "gen"); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Rule + public TemporaryFolder elsewhere = new TemporaryFolder(); + + private Path projectRoot; + private Path genStateDir; + private Path outDir; + + private void setUpProject() throws Exception { + projectRoot = tmp.getRoot().toPath(); + genStateDir = projectRoot.resolve(".metaobjects/.gen-state"); + outDir = projectRoot.resolve("gen"); + Files.createDirectories(outDir); + } + + // === classify ============================================================ + + @Test + public void classifyStrangerWhenNotRecorded() throws Exception { + setUpProject(); + Path file = outDir.resolve("Stray.java"); + Files.writeString(file, "// not ours\n"); + + assertEquals(OrphanSweep.Verdict.STRANGER, + OrphanSweep.classify(Collections.emptyMap(), projectRoot, file)); + } + + @Test + public void classifyPristineWhenContentMatchesRecordedHash() throws Exception { + setUpProject(); + Path file = outDir.resolve("Telemetry.java"); + String content = "/** GENERATED */\npublic class Telemetry {}\n"; + Files.writeString(file, content); + HashManifest.record(genStateDir, "gen/Telemetry.java", content, OWNER); + + assertEquals(OrphanSweep.Verdict.PRISTINE, + OrphanSweep.classify(HashManifest.load(genStateDir), projectRoot, file)); + } + + @Test + public void classifyEditedWhenContentDiffersFromRecordedHash() throws Exception { + setUpProject(); + Path file = outDir.resolve("Telemetry.java"); + HashManifest.record(genStateDir, "gen/Telemetry.java", "/** GENERATED */\npublic class Telemetry {}\n", OWNER); + Files.writeString(file, "/** GENERATED */\npublic class Telemetry { int extra; }\n"); + + assertEquals(OrphanSweep.Verdict.EDITED, + OrphanSweep.classify(HashManifest.load(genStateDir), projectRoot, file)); + } + + @Test + public void keyForIsNullOutsideProjectRoot() throws Exception { + setUpProject(); + Path outside = elsewhere.getRoot().toPath().resolve("X.java"); + assertEquals(null, OrphanSweep.keyFor(projectRoot, outside)); + } + + // === sweep: the rename case — pristine orphan deleted + forgotten ========= + + @Test + public void renameCase_pristineOrphanIsDeletedAndForgotten() throws Exception { + setUpProject(); + String oldContent = "/** GENERATED */\npublic class Telemetry {}\n"; + Path oldFile = outDir.resolve("Telemetry.java"); + Files.writeString(oldFile, oldContent); + HashManifest.record(genStateDir, "gen/Telemetry.java", oldContent, OWNER); + + Path newFile = outDir.resolve("TelemetryReading.java"); + Files.writeString(newFile, "/** GENERATED */\npublic class TelemetryReading {}\n"); + Set emitted = new LinkedHashSet<>(Set.of(newFile.toAbsolutePath().normalize())); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), emitted, Set.of(OWNER), () -> null); + + assertFalse("old file should be deleted", Files.exists(oldFile)); + assertTrue(result.removed.contains("gen/Telemetry.java")); + assertTrue(result.refused.isEmpty()); + assertTrue(result.overwrittenDeleted.isEmpty()); + assertFalse(HashManifest.load(genStateDir).containsKey("gen/Telemetry.java")); + } + + // === sweep: hand-edited orphan is refused, stays recorded ================= + + @Test + public void handEditedOrphan_isRefused_keptAndStillRecorded() throws Exception { + setUpProject(); + Path oldFile = outDir.resolve("Telemetry.java"); + Files.writeString(oldFile, "/** GENERATED */\npublic class Telemetry {}\n"); + HashManifest.record(genStateDir, "gen/Telemetry.java", "/** GENERATED */\npublic class Telemetry {}\n", OWNER); + // Hand-edit after generation. + Files.writeString(oldFile, "/** GENERATED */\npublic class Telemetry { /* mine */ }\n"); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); + + assertTrue("edited orphan must be left in place", Files.exists(oldFile)); + assertTrue(result.refused.contains("gen/Telemetry.java")); + assertTrue(result.removed.isEmpty()); + assertTrue(result.overwrittenDeleted.isEmpty()); + assertTrue("still recorded so the refusal repeats until acted on", + HashManifest.load(genStateDir).containsKey("gen/Telemetry.java")); + } + + @Test + public void handEditedOrphan_underAllowOverwrite_isDeleted() throws Exception { + setUpProject(); + Path oldFile = outDir.resolve("Telemetry.java"); + Files.writeString(oldFile, "/** GENERATED */\npublic class Telemetry {}\n"); + HashManifest.record(genStateDir, "gen/Telemetry.java", "/** GENERATED */\npublic class Telemetry {}\n", OWNER); + Files.writeString(oldFile, "/** GENERATED */\npublic class Telemetry { /* mine */ }\n"); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", true); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); + + assertFalse("overwrite policy deletes a hand-edited orphan", Files.exists(oldFile)); + assertTrue(result.overwrittenDeleted.contains("gen/Telemetry.java")); + assertTrue(result.refused.isEmpty()); + assertFalse(HashManifest.load(genStateDir).containsKey("gen/Telemetry.java")); + } + + // === sweep: stranger file is never touched ================================= + + @Test + public void strangerFile_isNeverTouched() throws Exception { + setUpProject(); + Path stray = outDir.resolve("HandWritten.java"); + Files.writeString(stray, "// never generated, never recorded\n"); + // Manifest exists (so sweep actually runs) but has nothing for this file. + HashManifest.record(genStateDir, "gen/Other.java", "x", OWNER); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); + + assertTrue(Files.exists(stray)); + assertFalse(result.removed.contains("gen/HandWritten.java")); + assertFalse(result.refused.contains("gen/HandWritten.java")); + } + + @Test + public void noManifestAtAll_sweepIsANoOp() throws Exception { + setUpProject(); + Path stray = outDir.resolve("Whatever.java"); + Files.writeString(stray, "whatever\n"); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); + + assertTrue(result.removed.isEmpty()); + assertTrue(result.refused.isEmpty()); + assertTrue(result.overwrittenDeleted.isEmpty()); + assertTrue(Files.exists(stray)); + } + + // === sweep: a recorded path whose file is already gone is forgotten ======== + + @Test + public void alreadyGoneRecordedFile_isForgotten() throws Exception { + setUpProject(); + HashManifest.record(genStateDir, "gen/Deleted.java", "irrelevant", OWNER); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); + + assertFalse(HashManifest.load(genStateDir).containsKey("gen/Deleted.java")); + } + + // === sweep: two directories (two executions sharing one manifest) ========= + + @Test + public void twoDirectories_neitherSweepsTheOthersFiles() throws Exception { + setUpProject(); + Path dirA = projectRoot.resolve("genA"); + Path dirB = projectRoot.resolve("genB"); + Files.createDirectories(dirA); + Files.createDirectories(dirB); + + Path fileA = dirA.resolve("A.java"); + Path fileB = dirB.resolve("B.java"); + Files.writeString(fileA, "a"); + Files.writeString(fileB, "b"); + HashManifest.Owner ownerA = new HashManifest.Owner("entity", "genA"); + HashManifest.record(genStateDir, "genA/A.java", "a", ownerA); + HashManifest.record(genStateDir, "genB/B.java", "b", new HashManifest.Owner("dto", "genB")); + + // Only dirA's execution runs this sweep (dirB's execution's generators did not run + // this time), and dirA emitted nothing — A.java is now an orphan of dirA only. + OrphanSweep.Entry entryA = new OrphanSweep.Entry(dirA, "genA/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entryA), Set.of(), Set.of(ownerA), () -> null); + + assertFalse("A.java is dirA's orphan", Files.exists(fileA)); + assertTrue("B.java is untouched — outside dirA's jurisdiction", Files.exists(fileB)); + assertTrue(HashManifest.load(genStateDir).containsKey("genB/B.java")); + } + + // === sweep: whose entries a run may judge ================================== + + private static final HashManifest.Owner DTO = new HashManifest.Owner("dto", "gen"); + private static final HashManifest.Owner REMOVED = new HashManifest.Owner("routes", "web"); + + private Path sharedLayout() throws Exception { + setUpProject(); + Files.writeString(outDir.resolve("Mine.java"), "m"); + Files.writeString(outDir.resolve("Theirs.java"), "t"); + Files.writeString(outDir.resolve("Unowned.java"), "u"); + Path web = projectRoot.resolve("web"); + Files.createDirectories(web); + Files.writeString(web.resolve("Route.java"), "r"); + Path other = projectRoot.resolve("other"); + Files.createDirectories(other); + Files.writeString(other.resolve("Plain.java"), "p"); + HashManifest.record(genStateDir, "gen/Mine.java", "m", OWNER); + HashManifest.record(genStateDir, "gen/Theirs.java", "t", DTO); + HashManifest.record(genStateDir, "gen/Unowned.java", "u", null); + HashManifest.record(genStateDir, "gen/TheirsGone.java", "g", DTO); + HashManifest.record(genStateDir, "web/Route.java", "r", REMOVED); + HashManifest.record(genStateDir, "other/Plain.java", "p", null); + return web; + } + + @Test + public void anotherConfiguredGeneratorsEntries_areNeverCandidates_evenInASharedDirectory() throws Exception { + Path web = sharedLayout(); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", true); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), + Set.of(OWNER), () -> Set.of(OWNER, DTO, REMOVED)); + + assertEquals(List.of("gen/Mine.java", "gen/Unowned.java"), result.removed); + assertTrue(Files.exists(outDir.resolve("Theirs.java"))); + assertTrue(Files.exists(web.resolve("Route.java"))); + assertTrue("an entry with no owner outside this run's directories may be another port's", + Files.exists(projectRoot.resolve("other/Plain.java"))); + assertTrue("another generator's gone file is that generator's to forget", + HashManifest.load(genStateDir).containsKey("gen/TheirsGone.java")); + assertEquals(DTO, HashManifest.loadOwners(genStateDir).get("gen/Theirs.java")); + } + + @Test + public void anEntryWhoseOwnerIsConfiguredNowhere_isAnOrphan_whereverItLives() throws Exception { + Path web = sharedLayout(); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), + Set.of(OWNER), () -> Set.of(OWNER)); + + assertEquals(List.of("gen/Mine.java", "gen/Theirs.java", "gen/Unowned.java", "web/Route.java"), + result.removed); + assertFalse(Files.exists(web.resolve("Route.java"))); + assertFalse(HashManifest.load(genStateDir).containsKey("gen/TheirsGone.java")); + assertTrue(Files.exists(projectRoot.resolve("other/Plain.java"))); + } + + @Test + public void withTheModulesConfigurationUnknown_onlyTheRunsOwnEntriesAreCandidates() throws Exception { + Path web = sharedLayout(); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), + Set.of(OWNER), () -> null); + + assertEquals(List.of("gen/Mine.java"), result.removed); + assertTrue(Files.exists(outDir.resolve("Unowned.java"))); + assertTrue(Files.exists(web.resolve("Route.java"))); + } + + // === sweep: a nested directory is judged by its own policy ================== + + @Test + public void nestedDirectory_editedOrphanIsJudgedOnceByTheInnermostDirectory() throws Exception { + setUpProject(); + Path inner = outDir.resolve("inner"); + Files.createDirectories(inner); + Path orphan = inner.resolve("Old.java"); + HashManifest.record(genStateDir, "gen/inner/Old.java", "original", OWNER); + Files.writeString(orphan, "edited"); + + OrphanSweep.Entry outerKeep = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Entry innerOverwrite = new OrphanSweep.Entry(inner, "inner/", true); + OrphanSweep.Result result = OrphanSweep.sweep( + genStateDir, projectRoot, List.of(outerKeep, innerOverwrite), Set.of(), Set.of(OWNER), () -> null); + + assertFalse(Files.exists(orphan)); + assertEquals(List.of("inner/Old.java"), result.overwrittenDeleted); + assertTrue(result.refused.isEmpty()); + } + + @Test + public void nestedDirectory_keepInsideOverwrite_isRefusedNotDeleted() throws Exception { + setUpProject(); + Path inner = outDir.resolve("inner"); + Files.createDirectories(inner); + Path orphan = inner.resolve("Old.java"); + HashManifest.record(genStateDir, "gen/inner/Old.java", "original", OWNER); + Files.writeString(orphan, "edited"); + + OrphanSweep.Entry outerOverwrite = new OrphanSweep.Entry(outDir, "gen/", true); + OrphanSweep.Entry innerKeep = new OrphanSweep.Entry(inner, "inner/", false); + OrphanSweep.Result result = OrphanSweep.sweep( + genStateDir, projectRoot, List.of(outerOverwrite, innerKeep), Set.of(), Set.of(OWNER), () -> null); + + assertTrue(Files.exists(orphan)); + assertEquals(List.of("gen/inner/Old.java"), result.refused); + assertTrue(result.overwrittenDeleted.isEmpty()); + } + + // === messages =============================================================== + + @Test + public void refusedMessageNamesEveryPathAndTheOwner() { + String msg = OrphanSweep.refusedMessage(List.of("gen/A.java", "gen/B.java"), "mvn metaobjects:generate"); + assertTrue(msg.contains("mvn metaobjects:generate")); + assertTrue(msg.contains("gen/A.java")); + assertTrue(msg.contains("gen/B.java")); + assertTrue(msg.contains("NOT deleted")); + } + + @Test + public void overwrittenOrphanMessageNamesThePathAndPolicy() { + String msg = OrphanSweep.overwrittenOrphanMessage("gen/Telemetry.java"); + assertTrue(msg.contains("gen/Telemetry.java")); + assertTrue(msg.contains("handEdits: overwrite")); + } +} diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/FindInbound.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/FindInbound.kt index 0a59501bc..9c8d3811a 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/FindInbound.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/FindInbound.kt @@ -71,10 +71,7 @@ object FindInbound { @JvmStatic fun responseShape(loader: MetaDataLoader, tmpl: MetaTemplate): InboundShape? { val ref = responseRefOf(tmpl) ?: return null - // ADR-0042/#228: resolve package-aware through the SAME value-object resolver - // @payloadRef obeys, so the parser can never bind a record the payload tier refused to - // emit (the C# defect this port is written to avoid). - val vo = KotlinGenUtil.resolveValueObjectRef(loader, ref, tmpl.getPackage()) ?: return null + val vo = KotlinGenUtil.payloadTarget(tmpl, "responseRef") ?: return null return InboundShape(vo, ref, responseFormatOf(tmpl)) } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt index dfebad248..67f73c239 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt @@ -427,7 +427,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { KotlinTypeMapper.enumTypeName(field, owner, fr019Config)?.let { return it } if (field is ObjectField) { val ref = readObjectRef(field) - val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + val target = ref?.let { KotlinGenUtil.lookupCanonicalObject(loader, it) } if (target != null) { val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name) return ClassName(targetPkg, targetShort) @@ -438,7 +438,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { // which needs the loader to resolve, so build the Map here. if (field is MapField) { val ref = readObjectRef(field) - val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + val target = ref?.let { KotlinGenUtil.lookupCanonicalObject(loader, it) } if (target != null) { val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name) return MAP.parameterizedBy(STRING, ClassName(targetPkg, targetShort)) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index ee31661e5..336b413fc 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -540,7 +540,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase f is ObjectField && readStorage(f) == STORAGE_FLATTENED && - (readObjectRef(f)?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) } + (readObjectRef(f)?.let { KotlinGenUtil.lookupCanonicalObject(loader, it) } ?.metaFields?.any { KotlinTypeMapper.isJsonbOpenBag(it) } ?: false) } // Object/map jsonb columns → the Jackson metaJsonbMapper codec (a same-package `internal` @@ -841,7 +841,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase rel.through == null && rel.objectRef != null && - KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === target && + KotlinGenUtil.lookupCanonicalObject(loader, rel.objectRef) === target && RelationshipReferences.resolveRelationshipReference( - entity, rel.shortName, rel.objectRef, rel.sourceRefField) === ref + entity, rel.shortName, target, rel.sourceRefField) === ref } var rel = childSide // When the tier-3 satisfiability guard fires, the reverse relationship's @@ -2172,7 +2172,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase() .filter { r -> r.isEnforced && r.targetEntity != null && - KotlinGenUtil.resolveObjectByShortOrFqn(loader, r.targetEntity) === target + KotlinGenUtil.lookupCanonicalObject(loader, r.targetEntity) === target } if (enforcedRefsToTarget.singleOrNull() !== ref) return null // #368: fail closed on ambiguity rather than taking the first match — when more @@ -2185,7 +2185,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase rel.through == null && rel.objectRef != null && - KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === entity + KotlinGenUtil.lookupCanonicalObject(loader, rel.objectRef) === entity }.singleOrNull() } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt index a0e411910..32a905c2a 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt @@ -24,7 +24,6 @@ import com.metaobjects.reporting.ReportShape import com.metaobjects.source.MetaSource import com.metaobjects.source.RdbSource import com.metaobjects.source.SourceResolution -import com.metaobjects.validation.SymbolTable /** * Helpers shared by the codegen-kotlin generators. Extracted to keep @@ -49,44 +48,46 @@ public object KotlinGenUtil { const val AUTO_SET_ON_UPDATE = "onUpdate" /** - * Resolve a MetaObject (entity OR value) by exact FQN match or by short-name match - * (the trailing segment after the last `::`). Returns null when neither matches. + * Resolve a MetaObject (entity OR value) from a CANONICAL reference value (ADR-0059 R7): + * an exact resolution-key match (`pkg::Name`, `::Name`, or a root-level `Name`), with any + * dotted child tail ignored. Never matches on a short name. Returns null when nothing matches. */ - fun resolveObjectByShortOrFqn(loader: MetaDataLoader, ref: String): MetaObject? { - for (child in loader.metaObjects) { - if (child.name == ref || child.name.substringAfterLast("::") == ref) return child - } - return null - } + fun lookupCanonicalObject(loader: MetaDataLoader, ref: String): MetaObject? = + com.metaobjects.loader.References.lookupCanonical(loader.root, ref) - // ========================================================================= - // ADR-0042 — canonical package-local object-ref resolution (@payloadRef). - // - // The loader validates a template's @payloadRef via the SAME package-local - // contract (ValidationPhase.resolveRootObject, backed by SymbolTable): an FQN ref - // binds EXACTLY; a bare ref binds the referrer's own package, else a root-level - // object; NO cross-package bare-name / bare-tail fallback. Reusing the loader's own - // public [SymbolTable] (rather than a divergent codegen copy) keeps codegen's - // @payloadRef resolution identical to the loader's, so under a cross-package - // short-name collision codegen binds the SAME value-object the loader validated — - // never a load-order-dependent decoy (the #244 class). The bare-tail/first-match - // [resolveObjectByShortOrFqn] above is deliberately left as-is: it backs the - // @from/@of/@via dotted-ref navigation (a different ref kind, #244's own domain). - // ========================================================================= + /** + * The earlier name of [lookupCanonicalObject], kept so a generator ejected before 1.1.0 + * still compiles unchanged. It now does the same exact canonical lookup; it no longer + * matches a short name, whatever its name says. + */ + @Deprecated("Use lookupCanonicalObject", ReplaceWith("lookupCanonicalObject(loader, ref)")) + fun resolveObjectByShortOrFqn(loader: MetaDataLoader, ref: String): MetaObject? = + lookupCanonicalObject(loader, ref) /** - * Resolve a metadata OBJECT reference (bare or FQN) under the loader's ADR-0042 - * package-local contract, or null. [referrerPkg] is the effective package of the node - * carrying the ref (a template's own `getPackage()` for a @payloadRef); "" for root-level. + * Resolve a CANONICAL object reference exactly (ADR-0059 R7), or null: `pkg::Name` binds + * that package's object, `::Name` and a bare `Name` bind the root-level object. The same + * lookup as [lookupCanonicalObject]; no short-name match and no package applied. Prefer + * [payloadTarget] on the node that carries the reference. + * + * [referrerPkg] is ignored: a canonical value is absolute. It is kept so existing callers + * and owned generator copies compile unchanged. */ - fun resolveObjectRef(loader: MetaDataLoader, ref: String?, referrerPkg: String?): MetaObject? { - if (ref == null) return null - return SymbolTable.build(loader.root).resolveObject(ref, referrerPkg ?: "") - } + @Suppress("UNUSED_PARAMETER") + fun resolveObjectRef(loader: MetaDataLoader, ref: String?, referrerPkg: String?): MetaObject? = + if (ref.isNullOrEmpty()) null else lookupCanonicalObject(loader, ref) + + /** + * The payload-shape target a reference attribute on [node] (`payloadRef` or `responseRef`) is + * bound to, as the loader recorded it, or null when it does not resolve or is not a legal + * payload target. + */ + fun payloadTarget(node: com.metaobjects.MetaData, attr: String): MetaObject? = + com.metaobjects.loader.References.refTarget(node, attr)?.takeIf { isLegalPayloadTarget(it) } /** - * Resolve [ref] to its payload-shape target under the same ADR-0042 package-local - * contract as [resolveObjectRef]. #210 — a template-level payload target is an + * Resolve the canonical [ref] to its payload-shape target exactly, as [resolveObjectRef] + * does ([referrerPkg] is ignored). Prefer [payloadTarget] on the template node. #210 — a template-level payload target is an * `object.value` OR a SOURCELESS `object.projection` (rejects entities and sourced * projections; the loader enforces the same set). Nested `field.object @objectRef` * targets stay value-only ([KotlinExtractSchemaEmitter.objectRefValueObject]). diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt index 9667f27b6..5f15e74ba 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt @@ -85,8 +85,30 @@ object KotlinM2mSupport { val targetScalarFields: List, val targetDiscriminator: TargetDiscriminator?, val symmetric: Boolean, + val junctionPackage: String = "", ) + /** + * The import lines a file in [sourcePkg] needs to name what each of [navs] reaches in ANOTHER + * package: the target's Exposed table (and the junction's, with [junction]) and, with + * [dataClass], the target data class when it is root-level (a packaged target is qualified + * inline). A root-level name is imported explicitly so it wins over a same-named declaration + * in [sourcePkg]. + */ + fun importLines(navs: Collection, sourcePkg: String, junction: Boolean, dataClass: Boolean): Set { + val out = sortedSetOf() + fun add(pkg: String, name: String) { + if (pkg == sourcePkg) return + out.add(if (pkg.isEmpty()) name else "$pkg.$name") + } + for (nav in navs) { + add(nav.targetPackage, nav.targetTableObj) + if (junction) add(nav.junctionPackage, nav.junctionTableObj) + if (dataClass && nav.targetPackage.isEmpty()) add(nav.targetPackage, nav.targetType) + } + return out + } + /** * Resolve every M:N relationship declared on [entity]. Returns an empty list * when the entity declares none. Each entry's junction FK fields are derived @@ -105,15 +127,15 @@ object KotlinM2mSupport { if (through.isNullOrEmpty()) continue val fields = M2MFields.derive(rel, entity, root) - val declaredTarget = KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef ?: continue) ?: continue - val junction = KotlinGenUtil.resolveObjectByShortOrFqn(loader, through) ?: continue + val declaredTarget = com.metaobjects.loader.References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF) ?: continue + val junction = com.metaobjects.loader.References.refTarget(rel, MetaRelationship.ATTR_THROUGH) ?: continue // FW-3: a TPH subtype (e.g. `BridgeAuth`) has no Exposed Table object and no data // class of its own — its rows live in, and are shaped like, the discriminator base's // (`AuthTable` / `Auth`). Redirect the target's PHYSICAL identity to that storage // object; `targetShortName` above keeps naming the DECLARED target for diagnostics. val target = KotlinTphPlan.storageObjectOf(declaredTarget) - val junctionShort = PackageMapping.splitFqn(junction.name).second + val (junctionPkg, junctionShort) = PackageMapping.splitFqn(junction.name) val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name) val declaredShort = PackageMapping.splitFqn(declaredTarget.name).second // The STORAGE object's full column set — base + folded subtype columns when it is a @@ -176,6 +198,7 @@ object KotlinM2mSupport { targetScalarFields = targetScalars, targetDiscriminator = targetDiscriminator, symmetric = rel.isSymmetric, + junctionPackage = junctionPkg, ) ) } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt index 4aac25532..8591caaa2 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt @@ -151,13 +151,13 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase() // Resolve each to-many composition target → (relShortName, targetShort). Skip // relationships whose @objectRef cannot be resolved (defensive — the loader's // validation phase already gates the attr being present). - data class Helper(val relShortName: String, val targetTable: String) + data class Helper(val relShortName: String, val targetTable: String, val targetPackage: String) val helpers = manyRels.mapNotNull { rel -> - val ref = rel.objectRef ?: return@mapNotNull null - val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: return@mapNotNull null - val targetShort = PackageMapping.splitFqn(target.name).second + val target = com.metaobjects.loader.References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF) + ?: return@mapNotNull null + val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name) val relShort = rel.shortName ?: rel.name - Helper(relShort, targetShort + "Table") + Helper(relShort, targetShort + "Table", targetPkg) } if (helpers.isEmpty() && m2mNavs.isEmpty() && reverseFks.isEmpty()) return @@ -207,6 +207,10 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase() if (pkParamSimpleName == "UUID" || reverseFks.any { it.valueType == "UUID" }) { append("import java.util.UUID\n") } + val crossPackageTables = KotlinM2mSupport.importLines(m2mNavs.map { it.nav }, pkg, junction = true, dataClass = false) + + helpers.filter { it.targetPackage != pkg } + .map { if (it.targetPackage.isEmpty()) it.targetTable else "${it.targetPackage}.${it.targetTable}" } + for (line in crossPackageTables.toSortedSet()) append("import $line\n") append("\n") append("/** GENERATED — extension fns for `$ownerShort` to-many relationships. Do not hand-edit. */\n") var first = true diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt index f0b9db157..13285e0e4 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt @@ -113,8 +113,7 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase::` — correct for a `field.object` inherited via extends across - * packages), else a root-level `object.value`. Package-local BEFORE root-level; - * no cross-package short-name fallback. - * Returns null when the field has no `@objectRef` or nothing resolves. + * The `object.value` a `field.object`'s `@objectRef` is bound to (ADR-0059 R7: the + * target the loader recorded, resolved in the package of the node that declared it). + * Returns null when the field has no `@objectRef` or it names no `object.value`. */ private fun resolveNestedObjectRef(loader: MetaDataLoader, field: ObjectField): MetaObject? { - // ADR-0039: @objectRef is an inheritable effective field property — RESOLVE - // (includeParentData=true) so a field.object inheriting @objectRef via extends - // still resolves its target VO. - if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF, true)) return null - val ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF, true).valueAsString - if (ref.isNullOrEmpty()) return null - val fqn = ref.contains("::") - // The DECLARING field's package (walks parents; for an inherited field.object it is - // the package where the field is DECLARED, not where it is used). - val referrerPkg = com.metaobjects.util.MetaDataUtil.findPackageForMetaData(field) ?: "" - val localKey = if (fqn || referrerPkg.isEmpty()) ref else "$referrerPkg::$ref" - var own: MetaObject? = null - var rootLevel: MetaObject? = null - for (obj in loader.metaObjects) { - if (MetaObject.SUBTYPE_VALUE != obj.subType) continue - if (obj.name == localKey) own = obj - if (!fqn && obj.name == ref) rootLevel = obj - } - return if (fqn) own else own ?: rootLevel + // ADR-0059 R7: read the target the loader bound for the declaring field's @objectRef + // (resolved where it was written, so an inherited field.object binds its own package). + val target = com.metaobjects.loader.References.refTarget(field, MetaObject.ATTR_OBJECT_REF) + return if (target != null && MetaObject.SUBTYPE_VALUE == target.subType) target else null } /** diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index 5842edb20..e745abb3d 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -378,6 +378,9 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase( val parameterRef = sourceRdb.takeIf { it.hasMetaAttr(RdbSource.ATTR_PARAMETER_REF) } ?.let { runCatching { it.getMetaAttr(RdbSource.ATTR_PARAMETER_REF).valueAsString }.getOrNull() } ?.takeIf { it.isNotEmpty() } - // ADR-0042: a bare ref resolves package-locally against the referrer's METADATA - // package — `entity.getPackage()` ("acme::demo"), never the mapped Kotlin package - // `pkg` ("acme.demo") that PackageMapping.splitFqn hands back. Passing the Kotlin - // one silently resolves NOTHING, which reads exactly like "this callable declares no - // arguments" and emits a zero-argument wrapper — the same silent-shortfall shape as - // the @param bug this replaces. - val argsObject = KotlinGenUtil.resolveObjectRef(loader, parameterRef, entity.getPackage()) + val argsObject = com.metaobjects.loader.References.refTarget(sourceRdb, RdbSource.ATTR_PARAMETER_REF) if (parameterRef != null && argsObject == null) { LOG.warn( "KotlinStoredProcGenerator: callable \"{}\" @parameterRef \"{}\" did not " + diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt index 842cf9565..1c62b600b 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt @@ -537,10 +537,7 @@ class KotlinApiModelBuilder( /** The `@payloadRef` value object a template resolves to (rejects entities), or null. */ private fun payloadValueObject(tmpl: MetaTemplate, loader: MetaDataLoader): MetaObject? { - val ref = tmpl.payloadRef - if (ref.isNullOrEmpty()) return null - // ADR-0042 — resolve @payloadRef under the loader's package-local contract (#228). - return KotlinGenUtil.resolveValueObjectRef(loader, ref, tmpl.getPackage()) + return KotlinGenUtil.payloadTarget(tmpl, "payloadRef") } private fun isEmailKind(tmpl: MetaTemplate): Boolean { diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossPackageBindingConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossPackageBindingConformanceTest.kt new file mode 100644 index 000000000..e16400e39 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossPackageBindingConformanceTest.kt @@ -0,0 +1,215 @@ +package com.metaobjects.generator.kotlin + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.metaobjects.identity.MetaIdentity +import com.metaobjects.identity.ReferenceIdentity +import com.metaobjects.loader.LoaderOptions +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.loader.References +import com.metaobjects.loader.uri.URIHelper +import com.metaobjects.metadata.ktx.loadUris +import com.metaobjects.`object`.MetaObject +import com.metaobjects.relationship.M2MFields +import com.metaobjects.relationship.MetaRelationship +import com.metaobjects.relationship.RelationshipReferences +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Cross-port conformance (Kotlin) for ADR-0059 Decision 7: codegen binds the target a reference + * names, never a same-short-named object in another package and never the package of the entity + * that inherits the attribute. Loads `fixtures/codegen-conformance/cross-package-binding/input/`, + * checks every entry of `expected-bindings.json`, then the generated Exposed tables, relations + * and controllers. + */ +class KotlinCrossPackageBindingConformanceTest { + + private fun fixtureDir(): Path { + val dir = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize() + .resolve("fixtures/codegen-conformance/cross-package-binding") + assertTrue(Files.isDirectory(dir), "fixture must exist at $dir") + return dir + } + + private fun load(): MetaDataLoader { + val uris = Files.list(fixtureDir().resolve("input")).use { s -> + s.filter { it.toString().endsWith(".json") }.sorted().toList() + }.map { URIHelper.toURI("model:file:" + it.toAbsolutePath().toString().replace('\\', '/')) } + assertEquals(4, uris.size) + return loadUris("cross-package-binding", uris, LoaderOptions.create(false, false, true)) + } + + private fun expected(): JsonNode = + ObjectMapper().readTree(Files.readString(fixtureDir().resolve("expected-bindings.json"))) + + private fun objectOf(loader: MetaDataLoader, key: String): MetaObject = + assertNotNull(loader.getMetaObjectByName(key), "object $key must load") + + private fun tableOf(o: MetaObject): String = o.findPrimaryWritableSource().orElseThrow().tableName + + private fun member(key: String): Pair = + key.substring(0, key.lastIndexOf('.')) to key.substring(key.lastIndexOf('.') + 1) + + @Test + fun `every identity reference is bound to the expected object`() { + val loader = load() + val refs = expected().get("references") + var checked = 0 + for (key in refs.fieldNames().asSequence().toList()) { + val (owner, name) = member(key) + val ref = objectOf(loader, owner).identities + .filterIsInstance().firstOrNull { it.shortName == name } + assertNotNull(ref, "identity.reference $key") + val bound = References.refTarget(ref, MetaIdentity.ATTR_REFERENCES) + assertNotNull(bound, "unbound $key") + assertEquals(refs.get(key).asText(), bound.name, "binding of $key") + checked++ + } + assertEquals(6, checked) + } + + @Test + fun `every relationship resolves to the expected target tables and join fields`() { + val loader = load() + val relations = expected().get("relations") + var checked = 0 + for (key in relations.fieldNames().asSequence().toList()) { + val want = relations.get(key) + val (ownerKey, name) = member(key) + val owner = objectOf(loader, ownerKey) + val rel = owner.relationships.firstOrNull { it.shortName == name } + assertNotNull(rel, "relationship $key") + + val target = References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF) + assertNotNull(target, "unbound target of $key") + assertEquals(want.get("target").asText(), target.name, "target of $key") + assertEquals(want.get("targetTable").asText(), tableOf(target), "target table of $key") + + if (want.get("cardinality").asText() == "many") { + val junction = References.refTarget(rel, MetaRelationship.ATTR_THROUGH) + assertNotNull(junction, "unbound junction of $key") + assertEquals(want.get("junction").asText(), junction.name, "junction of $key") + assertEquals(want.get("junctionTable").asText(), tableOf(junction), "junction table of $key") + val fields = M2MFields.derive(rel, owner, loader.root) + assertEquals(want.get("sourceJoinField").asText(), fields.sourceField, "source join field of $key") + assertEquals(want.get("targetJoinField").asText(), fields.targetField, "target join field of $key") + + val nav = KotlinM2mSupport.resolve(owner, loader).firstOrNull { it.relationName == name } + assertNotNull(nav, "codegen M:N navigation for $key") + assertEquals(want.get("sourceJoinField").asText(), nav.sourceField) + assertEquals(want.get("targetJoinField").asText(), nav.targetField) + assertEquals(PackageMapping.splitFqn(target.name).second + "Table", nav.targetTableObj) + assertEquals(PackageMapping.splitFqn(target.name).first, nav.targetPackage) + assertEquals(PackageMapping.splitFqn(junction.name).second + "Table", nav.junctionTableObj) + assertEquals(target.getMetaField("id").name, nav.targetPkField) + } else { + val fk = RelationshipReferences.resolveRelationshipReference( + owner, rel.shortName, target, rel.sourceRefField) + assertNotNull(fk, "fk reference of $key") + assertEquals(want.get("fkField").asText(), fk.fields[0], "fk field of $key") + } + checked++ + } + assertEquals(4, checked) + } + + @Test + fun `inherited root references bind the root object not the inheriting packages`() { + val loader = load() + val order = objectOf(loader, "acme::Order") + val customer = loader.getMetaObjectByName("Customer") + val label = loader.getMetaObjectByName("Label") + val customerRel = order.relationships.first { it.shortName == "customer" } + val labelsRel = order.relationships.first { it.shortName == "labels" } + assertSame(customer, References.refTarget(customerRel, MetaRelationship.ATTR_OBJECT_REF)) + assertSame(label, References.refTarget(labelsRel, MetaRelationship.ATTR_OBJECT_REF)) + assertSame(customer, References.lookupCanonical(loader.root, + customerRel.getMetaAttr(MetaRelationship.ATTR_OBJECT_REF).valueAsString)) + } + + private fun generateAll(loader: MetaDataLoader, out: Path, withControllers: Boolean): Map { + val args = mapOf("outputDir" to out.toString()) + KotlinEntityGenerator().apply { setArgs(args) }.execute(loader) + KotlinExposedTableGenerator().apply { setArgs(args) }.execute(loader) + KotlinRelationsGenerator().apply { setArgs(args) }.execute(loader) + if (withControllers) KotlinSpringControllerGenerator().apply { setArgs(args) }.execute(loader) + return Files.walk(out).use { s -> + s.filter { it.toString().endsWith(".kt") }.toList() + }.associate { out.relativize(it).toString().replace('\\', '/') to Files.readString(it) } + } + + @Test + fun `generated tables relations and controllers use the bound targets`() { + val out = Files.createTempDirectory("kcross-") + try { + val files = generateAll(load(), out, withControllers = true) + fun file(path: String) = assertNotNull(files[path], "expected $path; saw ${files.keys.sorted()}") + + val postTable = file("a/PostTable.kt") + assertTrue("import b.TagTable" in postTable, "Post.featuredTag must bind b::Tag; saw:\n$postTable") + assertTrue("references(TagTable.id" in postTable, "saw:\n$postTable") + val postRelations = file("a/PostRelations.kt") + assertTrue("import b.TagTable" in postRelations, "saw:\n$postRelations") + assertTrue("TagTable.join(PostTagTable, JoinType.INNER) { TagTable.id eq PostTagTable.tagId }" in postRelations, + "saw:\n$postRelations") + val postController = file("a/PostController.kt") + assertTrue("import b.TagTable" in postController, "saw:\n$postController") + assertTrue("ResponseEntity>" in postController, "saw:\n$postController") + + val orderTable = file("acme/OrderTable.kt") + assertTrue("\nimport CustomerTable\n" in orderTable, + "Order.customer must bind the root Customer, not acme::Customer; saw:\n$orderTable") + assertTrue("references(CustomerTable.id" in orderTable, "saw:\n$orderTable") + val orderRelations = file("acme/OrderRelations.kt") + assertTrue("\nimport LabelTable\n" in orderRelations, "saw:\n$orderRelations") + assertTrue("\nimport OrderLabelTable\n" in orderRelations, "saw:\n$orderRelations") + assertTrue("LabelTable.join(OrderLabelTable, JoinType.INNER) { LabelTable.id eq OrderLabelTable.labelId }" in orderRelations, + "saw:\n$orderRelations") + val orderController = file("acme/OrderController.kt") + assertTrue("\nimport LabelTable\n" in orderController, "saw:\n$orderController") + assertTrue("\nimport Label\n" in orderController, "saw:\n$orderController") + + for ((path, src) in files) { + if (!path.startsWith("a/Post") && !path.startsWith("acme/Order")) continue + for (wrong in listOf("a_tags", "acme_labels", "acme_customers", "a.TagTable", "acme.LabelTable", "acme.CustomerTable")) { + assertFalse(wrong in src, "$path must not mention $wrong") + } + } + } finally { + out.toFile().deleteRecursively() + } + } + + @OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) + @Test + fun `generated entities tables and relations compile against exposed`() { + val out = Files.createTempDirectory("kcross-compile-") + try { + generateAll(load(), out, withControllers = false) + val sources = Files.walk(out).use { s -> s.filter { Files.isRegularFile(it) }.sorted().toList() } + .map { SourceFile.kotlin(out.relativize(it).toString().replace('/', '_'), Files.readString(it)) } + val exposed = Path.of(System.getProperty("user.home"), ".m2", "repository", + "org/jetbrains/exposed/exposed-core/0.55.0/exposed-core-0.55.0.jar") + assertTrue(Files.exists(exposed), "expected jar on local m2: $exposed") + val result = KotlinCompilation().apply { + this.sources = sources + inheritClassPath = true + classpaths = listOf(exposed.toFile()) + messageOutputStream = System.out + }.compile() + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, + "generated cross-package tree failed to compile:\n${result.messages}") + } finally { + out.toFile().deleteRecursively() + } + } +} diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilCanonicalRefTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilCanonicalRefTest.kt new file mode 100644 index 000000000..2455aadc7 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilCanonicalRefTest.kt @@ -0,0 +1,53 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.loader.InMemoryStringSource +import com.metaobjects.loader.LoaderOptions +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.`object`.MetaObject +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * ADR-0059 R7 — [KotlinGenUtil.resolveObjectRef] and [KotlinGenUtil.resolveValueObjectRef] resolve a + * CANONICAL value exactly: `pkg::Name` binds that package's object, `::Name` and a bare `Name` bind + * the root-level one, and the referrer package is never applied. Three value objects share the short + * name `Report`, so a short-name scan or a package-local resolve would bind a packaged copy instead. + */ +class KotlinGenUtilCanonicalRefTest { + + private fun doc(pkg: String?, field: String): String { + val pkgPart = if (pkg == null) "" else "\"package\": \"$pkg\", " + return "{ \"metadata.root\": { $pkgPart\"children\": [" + + "{ \"object.value\": { \"name\": \"Report\", \"children\": [" + + "{ \"field.string\": { \"name\": \"$field\" } } ] } } ] } }" + } + + private fun load(vararg docs: String): MetaDataLoader { + val loader = MetaDataLoader(LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "kotlin-canonical") + loader.init() + loader.load(docs.mapIndexed { i, d -> InMemoryStringSource(d, "kotlin-canonical/meta.$i.json") }) + return loader + } + + private fun fieldOf(obj: MetaObject?): String? = obj?.metaFields?.first()?.name + + @Test + fun canonicalValuesResolveExactly() { + val loader = load(doc("acme::alpha", "alphaVal"), doc("acme::beta", "betaVal"), doc(null, "rootVal")) + + assertEquals("alphaVal", fieldOf(KotlinGenUtil.resolveObjectRef(loader, "acme::alpha::Report", ""))) + assertEquals("betaVal", fieldOf(KotlinGenUtil.resolveValueObjectRef(loader, "acme::beta::Report", "acme::alpha"))) + assertEquals("rootVal", fieldOf(KotlinGenUtil.resolveValueObjectRef(loader, "::Report", "acme::alpha"))) + assertEquals("rootVal", fieldOf(KotlinGenUtil.resolveObjectRef(loader, "Report", "acme::alpha"))) + assertNull(KotlinGenUtil.resolveObjectRef(loader, "acme::gamma::Report", "")) + } + + @Test + fun bareValueDoesNotBindAPackagedObject() { + val loader = load(doc("acme::alpha", "alphaVal")) + + assertNull(KotlinGenUtil.resolveObjectRef(loader, "Report", "acme::alpha")) + assertEquals("alphaVal", fieldOf(KotlinGenUtil.resolveObjectRef(loader, "acme::alpha::Report", ""))) + } +} diff --git a/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java b/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java index 087892a7d..386f3ddf2 100644 --- a/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java +++ b/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java @@ -369,18 +369,7 @@ protected List getAssociationRelationships(MetaObject m } protected MetaObject getTargetObject(AssociationRelationship relationship) { - // Get the target object name from the relationship - if (relationship.hasMetaAttr(MetaRelationship.ATTR_OBJECT_REF)) { - String targetObjectName = relationship.getMetaAttr(MetaRelationship.ATTR_OBJECT_REF).getValueAsString(); - if (targetObjectName != null) { - try { - return MetaDataUtil.findMetaObjectByName(targetObjectName, this); - } catch (Exception e) { - if (debug) log.warn("Could not find target object: " + targetObjectName, e); - } - } - } - return null; + return com.metaobjects.loader.References.refTarget(relationship, MetaRelationship.ATTR_OBJECT_REF); } protected void writeObjectKeyRelationships(MetaObject mo ) throws IOException { @@ -605,7 +594,8 @@ protected String getAttrValue( MetaAttribute attr ) { } // TODO: This should be handled differently else if (attr.getName().equals(ObjectField.ATTR_OBJECTREF)) { - return "\""+MetaDataUtil.expandPackageForPath(MetaDataUtil.findPackageForMetaData( attr ), attr.getValueAsString() )+"\""; + // ADR-0059: the value is already canonical (pkg::Name); never re-expand it. + return "\""+attr.getValueAsString()+"\""; } else if ( attr.getDataType() == DataTypes.STRING_ARRAY ) { return "\""+val.toString()+"\""; diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java index 9bc854ebd..6c08f9f44 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java @@ -360,10 +360,7 @@ parser, ApiSymbolKind.OUTPUT_PARSER, fqn(promptsPkg, parser), /** The template's resolvable {@code @payloadRef} value object, or {@code null}. */ private static MetaObject payloadValueObject(MetaTemplate tmpl, MetaDataLoader loader) { - String ref = tmpl.getPayloadRef(); - if (ref == null || ref.isEmpty()) return null; - return SpringNaming.resolveValueObjectRef( - loader, ref, com.metaobjects.util.MetaDataUtil.findPackageForMetaData(tmpl)); + return SpringNaming.payloadTarget(tmpl, TemplateConstants.ATTR_PAYLOAD_REF); } /** diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/FindInbound.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/FindInbound.java index e8f6aa966..e405d06c2 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/FindInbound.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/FindInbound.java @@ -87,12 +87,7 @@ public static boolean isInbound(com.metaobjects.MetaData node, MetaDataLoader lo public static InboundShape responseShape(MetaDataLoader loader, MetaTemplate tmpl) { String ref = responseRefOf(tmpl); if (ref == null) return null; - // ADR-0042/#228: resolve package-aware — a bare ref binds the template's OWN - // package first, never a bare-tail/global scan that could bind the WRONG package's - // same-short-named object. Enforces the SAME "must be an object.value" target rule - // @payloadRef obeys, so the two refs cannot diverge on what counts as a legal target. - MetaObject vo = SpringNaming.resolveValueObjectRef( - loader, ref, MetaDataUtil.findPackageForMetaData(tmpl)); + MetaObject vo = SpringNaming.payloadTarget(tmpl, "responseRef"); if (vo == null) return null; return new InboundShape(vo, ref, responseFormatOf(tmpl)); } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java index 72308aaf4..f83c47cae 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java @@ -174,11 +174,7 @@ protected void emit(MetaObject entity, MetaDataLoader loader, Path outRoot) { String responseRef = prompt.getResponseRef(); if (responseRef == null || responseRef.isEmpty()) return; // @responseRef gates the helper - // #228 — referrer is the PROMPT (the @responseRef is authored on it), not the - // entity: findPackageForMetaData walks parents, so a nested prompt still - // resolves the entity's effective package when the prompt itself carries none. - MetaObject responseVo = resolveValueObject(loader, responseRef, - com.metaobjects.util.MetaDataUtil.findPackageForMetaData(prompt)); + MetaObject responseVo = SpringNaming.payloadTarget(prompt, "responseRef"); if (responseVo == null) { throw new GeneratorException( "trace-helper: entity \"" + entity.getName() + "\" prompt @responseRef \"" @@ -329,14 +325,10 @@ protected static PromptTemplate firstPrompt(MetaObject entity) { } /** - * Resolve a {@code @responseRef} to its {@code object.value} target under the - * ADR-0042 package-local contract (#228). Was a package-BLIND bare-name scan that, - * worse, reduced the REF ITSELF to its trailing {@code ::} segment before matching - * — the #219/#244 bare-tail-fallback pattern: an FQN {@code @responseRef} that - * failed an exact match would still bind ANY same-bare-named {@code object.value} - * in a WRONG package (first match, load-order-dependent), not just a genuinely - * bare ref. Now delegates to the shared {@link SpringNaming#resolveValueObjectRef} - * (FQN matches exactly, never a bare-tail fallback). + * Resolve a canonical {@code @responseRef} value to its {@code object.value} target + * exactly (ADR-0059 R7), through {@link SpringNaming#resolveValueObjectRef}. Prefer + * {@link SpringNaming#payloadTarget} on the template node, which returns the target the + * loader bound. {@code referrerPkg} is ignored. */ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) { return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringM2mSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringM2mSupport.java index 4adc64a9b..a641fc32f 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringM2mSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringM2mSupport.java @@ -2,6 +2,7 @@ import com.metaobjects.MetaRoot; import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.References; import com.metaobjects.object.MetaObject; import com.metaobjects.relationship.M2MFields; import com.metaobjects.relationship.MetaRelationship; @@ -90,8 +91,8 @@ public static List resolve(MetaObject entity, MetaDataLoader loader) { M2MFields fields = M2MFields.derive(rel, entity, root); - MetaObject target = findEntity(root, rel.getObjectRef()); - MetaObject junction = findEntity(root, through); + MetaObject target = References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF); + MetaObject junction = References.refTarget(rel, MetaRelationship.ATTR_THROUGH); if (target == null || junction == null) continue; String[] targetSplit = SpringNaming.splitFqn(target.getName()); @@ -128,21 +129,4 @@ public static List resolve(MetaObject entity, MetaDataLoader loader) { } return out; } - - private static MetaObject findEntity(MetaRoot root, String name) { - if (name == null) return null; - // ADR-0041: a FULLY-QUALIFIED ref (contains "::") resolves EXACTLY on the - // object's package-qualified name — it never falls back to a bare-tail match - // (the closed bug: an FQN @objectRef / @through binding a same-named object - // in the WRONG package). A bare ref matches the object's short name (first - // match wins; cross-package same-package-preference is the deferred codegen - // follow-up, mirroring the TS residual note). - boolean fqn = name.indexOf("::") >= 0; - // ADR-0039: root-level entity scan — root is never extended, so own children - // is correct (entities are declared directly under root, not inherited into it). - for (MetaObject mo : root.getChildren(MetaObject.class, false)) { - if (fqn ? name.equals(mo.getName()) : name.equals(mo.getShortName())) return mo; - } - return null; - } } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java index abf5b5ae6..f3ceb638c 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java @@ -65,62 +65,45 @@ public static String[] splitFqn(String fqn) { } /** - * ADR-0042 — resolve a metadata OBJECT reference (bare or FQN) to a {@link MetaObject} - * under the package-local contract, or {@code null} when nothing matches: - *

    - *
  • FQN {@code ref} (contains {@code "::"}) → EXACT match on - * {@link MetaData#getName()}. No bare-tail fallback, so an FQN pointing at one - * package never binds a same-named object in another.
  • - *
  • bare {@code ref} (no {@code "::"}) → the referrer's OWN package - * ({@code ::}) first, else a root-level (unpackaged) object - * whose name IS {@code ref}. Package-local BEFORE root-level; no cross-package - * short-name scan, no bare-tail fallback.
  • - *
- * Mirrors the loader's own {@code ValidationPhase#resolveRootObject} (the same contract - * the TS/Python/C# ports' canonical resolvers implement), so a codegen-time - * {@code @payloadRef}/{@code @responseRef} resolution agrees with the loader's own - * validation of the same ref under a cross-package short-name collision. + * ADR-0059 R7 — resolve a CANONICAL object reference exactly, or {@code null} when + * nothing matches: {@code pkg::Name} binds that package's object, {@code ::Name} and a + * bare {@code Name} bind the root-level object. No short-name scan and no package is + * applied, so a value read from a loaded model means the same thing wherever it is read. + * Prefer {@link #payloadTarget} (or {@code References.refTarget}) on the node that + * carries the reference: it returns the target the loader bound. * - * @param referrerPkg the effective package of the node carrying the ref ("" for root-level) + * @param referrerPkg ignored; a canonical value is absolute. Kept so existing callers + * and owned generator copies compile unchanged. */ public static MetaObject resolveObjectRef(MetaDataLoader loader, String ref, String referrerPkg) { - if (ref == null) return null; - String pkg = referrerPkg == null ? "" : referrerPkg; - if (ref.contains("::")) { - for (MetaObject obj : loader.getMetaObjects()) { - if (ref.equals(obj.getName())) return obj; - } - return null; - } - String localKey = pkg.isEmpty() ? ref : pkg + "::" + ref; - MetaObject own = null; - MetaObject rootLevel = null; - for (MetaObject obj : loader.getMetaObjects()) { - String key = obj.getName(); - if (key == null) continue; - if (key.equals(localKey)) own = obj; - if (key.equals(ref)) rootLevel = obj; - } - if (own != null) return own; - return localKey.equals(ref) ? null : rootLevel; + if (ref == null || ref.isEmpty()) return null; + return com.metaobjects.loader.References.lookupCanonical(loader.getRoot(), ref); } /** - * Resolve {@code ref} to its payload-shape target under the same ADR-0042 - * package-local contract as {@link #resolveObjectRef}. #210 — a template-level - * payload target is an {@code object.value} OR a SOURCELESS - * {@code object.projection} (rejects entities and sourced projections; the - * loader enforces the same set). The shared home for every - * {@code @payloadRef}/{@code @responseRef} resolution in this package — callers - * derive {@code referrerPkg} via - * {@code MetaDataUtil.findPackageForMetaData(referrerNode)} (walks parents, so it - * works for both a root-level template and a nested {@code template.prompt}). + * Resolve the canonical {@code ref} to its payload-shape target exactly, as + * {@link #resolveObjectRef} does. #210 — a template-level payload target is an + * {@code object.value} OR a SOURCELESS {@code object.projection} (rejects entities and + * sourced projections; the loader enforces the same set). Prefer {@link #payloadTarget} + * on the template node. + * + * @param referrerPkg ignored; a canonical value is absolute. */ public static MetaObject resolveValueObjectRef(MetaDataLoader loader, String ref, String referrerPkg) { MetaObject obj = resolveObjectRef(loader, ref, referrerPkg); return (obj != null && isLegalPayloadTarget(obj)) ? obj : null; } + /** + * The payload-shape target a reference attribute on {@code node} is bound to (the target the + * loader recorded; {@code attr} is {@code payloadRef} or {@code responseRef}), or {@code null} + * when it does not resolve or is not a legal payload target. + */ + public static MetaObject payloadTarget(MetaData node, String attr) { + MetaObject obj = com.metaobjects.loader.References.refTarget(node, attr); + return (obj != null && isLegalPayloadTarget(obj)) ? obj : null; + } + /** * #210 — a template-level payload target ({@code @payloadRef} / {@code @responseRef}) * is an {@code object.value} OR a SOURCELESS {@code object.projection}. diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java index f0811d2ac..1eabd32c1 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java @@ -755,12 +755,10 @@ private static String escapeJava(String value) { } /** - * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities) - * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name - * scan over every loaded {@code object.value} (first match wins, load-order-dependent); - * now delegates to the shared {@link SpringNaming#resolveValueObjectRef} so a bare - * {@code @payloadRef} binds the referrer's OWN package first, agreeing with the - * loader's own {@code ValidationPhase} validation of the same ref. + * Resolve a canonical {@code @payloadRef} value to its {@code object.value} target + * exactly (ADR-0059 R7), through {@link SpringNaming#resolveValueObjectRef}. Prefer + * {@link SpringNaming#payloadTarget} on the template node, which returns the target the + * loader bound. {@code referrerPkg} is ignored. */ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) { return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java index e7f98e5d9..33f4419c3 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java @@ -168,10 +168,10 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot) } /** - * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities) - * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name - * scan (first match wins, load-order-dependent); now delegates to the shared - * {@link SpringNaming#resolveValueObjectRef}. + * Resolve a canonical {@code @payloadRef} value to its {@code object.value} target + * exactly (ADR-0059 R7), through {@link SpringNaming#resolveValueObjectRef}. Prefer + * {@link SpringNaming#payloadTarget} on the template node, which returns the target the + * loader bound. {@code referrerPkg} is ignored. */ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) { return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java index 056aa6fda..0d59e38eb 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java @@ -146,8 +146,7 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) { if (!isRenderable(template)) return false; String payloadRef = template.getPayloadRef(); if (payloadRef == null || payloadRef.isEmpty()) return false; - return resolveValueObject(loader, payloadRef, - com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)) != null; + return SpringNaming.payloadTarget(template, TemplateConstants.ATTR_PAYLOAD_REF) != null; } /** @@ -172,8 +171,7 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot, if (!appliesTo(template, loader)) { return; // missing @payloadRef, or not a value shape } - MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef(), - com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)); + MetaObject payloadVo = SpringNaming.payloadTarget(template, TemplateConstants.ATTR_PAYLOAD_REF); String[] split = SpringNaming.splitFqn(template.getName()); String templatePkg = split[0]; @@ -330,14 +328,6 @@ private static List derivePayloadFieldTree( // own-only isArray() flag would read false for a field that inherits @isArray. boolean isArray = field.isArrayType(); if (field instanceof ObjectField of) { - // Resolve the nested @objectRef by BARE short-name — the cross-port - // render-helper consensus (TS findObject: c.type === OBJECT && - // c.name === ref). Do NOT use of.getObjectRef() here: that runs the - // JVM package-FOLDING resolver, which expands a bare ref relative to - // the FIELD's package and diverges from TS/C#/Python/Kotlin for a - // PACKAGED payload VO. The render-helper field-tree must resolve - // identically across all five ports; only this walk changes — - // MetaDataUtil.getObjectRef and other generators are untouched. MetaObject target = resolveNestedObjectRef(loader, of); if (target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) { List children = @@ -352,42 +342,13 @@ private static List derivePayloadFieldTree( } /** - * Resolve a {@code field.object}'s {@code @objectRef} to its target - * {@code object.value} under the ADR-0042 package-local contract — mirroring the - * TS render-helper's {@code resolveObjectRef(root, ref, declaringFieldPkg)}: - *
    - *
  • an FQN {@code @objectRef} (contains {@code ::}) resolves EXACTLY on - * the object's package-qualified name — never a bare-tail fallback that would - * bind a same-named {@code object.value} in the WRONG package;
  • - *
  • a bare {@code @objectRef} resolves in the DECLARING field's package - * ({@code ::} — correct for a {@code field.object} inherited via - * extends across packages), else a root-level {@code object.value}. Package-local - * BEFORE root-level; no cross-package short-name fallback.
  • - *
- * Returns {@code null} if the field has no {@code @objectRef} or nothing resolves. + * The {@code object.value} a {@code field.object}'s {@code @objectRef} is bound to (the + * target the loader recorded, read resolving so an inherited ref works), or {@code null} + * when the field has no ref, nothing resolves, or the target is not an {@code object.value}. */ protected static MetaObject resolveNestedObjectRef(MetaDataLoader loader, ObjectField field) { - // ADR-0039: @objectRef is an inheritable effective field property — a concrete - // field.object may inherit it from an abstract parent via extends. RESOLVE - // (default includeParentData=true); own-only would miss the inherited ref. - if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF)) return null; - String ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF).getValueAsString(); - if (ref == null || ref.isEmpty()) return null; - boolean fqn = ref.contains("::"); - // The DECLARING field's package (walks parents; for an inherited field.object it is - // the package where the field is DECLARED, not where it is used). - String referrerPkg = com.metaobjects.util.MetaDataUtil.findPackageForMetaData(field); - if (referrerPkg == null) referrerPkg = ""; - String localKey = (fqn || referrerPkg.isEmpty()) ? ref : referrerPkg + "::" + ref; - MetaObject own = null; - MetaObject rootLevel = null; - for (MetaObject obj : loader.getMetaObjects()) { - if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue; - if (obj.getName().equals(localKey)) own = obj; - if (!fqn && obj.getName().equals(ref)) rootLevel = obj; - } - if (fqn) return own; - return own != null ? own : rootLevel; + MetaObject target = com.metaobjects.loader.References.refTarget(field, MetaObject.ATTR_OBJECT_REF); + return target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType()) ? target : null; } /** @@ -444,12 +405,11 @@ private static String attr(MetaTemplate template, String attr) { } /** - * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities) - * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name - * scan (first match wins, load-order-dependent); now delegates to the shared - * {@link SpringNaming#resolveValueObjectRef}. Distinct from this file's OWN - * {@link #resolveNestedObjectRef} (the {@code @objectRef} field-tree walk), which was - * ALREADY package-local-correct. + * Resolve a canonical {@code @payloadRef} value to its {@code object.value} target + * exactly (ADR-0059 R7), through {@link SpringNaming#resolveValueObjectRef}. Prefer + * {@link SpringNaming#payloadTarget} on the template node, which returns the target the + * loader bound. {@code referrerPkg} is ignored. Distinct from this file's OWN + * {@link #resolveNestedObjectRef} (the {@code @objectRef} field-tree walk). */ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) { return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java index 60657956d..e2bd22cd9 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java @@ -426,8 +426,9 @@ public static List reverseFksFor(MetaObject entity) { List fields = ref.getFields(); if (fields.size() != 1) continue; // single-column FKs only String fkField = fields.get(0); + MetaObject bound = com.metaobjects.loader.References.refTarget(ref, "references"); String target = ref.getTargetEntity(); - String targetShort = target == null ? "" : stripPackage(target); + String targetShort = bound != null ? bound.getShortName() : target == null ? "" : stripPackage(target); String valueType = "Long"; if (entity.hasMetaField(fkField)) { MetaField f = entity.getMetaField(fkField); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CrossPackageBindingConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CrossPackageBindingConformanceTest.java new file mode 100644 index 000000000..a08e2bafb --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CrossPackageBindingConformanceTest.java @@ -0,0 +1,226 @@ +package com.metaobjects.generator.spring; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; +import com.metaobjects.identity.MetaIdentity; +import com.metaobjects.identity.ReferenceIdentity; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.References; +import com.metaobjects.loader.uri.URIHelper; +import com.metaobjects.object.MetaObject; +import com.metaobjects.registry.SharedRegistryTestBase; +import com.metaobjects.relationship.M2MFields; +import com.metaobjects.relationship.MetaRelationship; +import com.metaobjects.relationship.RelationshipReferences; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Cross-port conformance (Java/Spring) for ADR-0059 Decision 7: codegen binds the target a + * reference names, never a same-short-named object in another package and never the package + * of the entity that inherits the attribute. Loads + * {@code fixtures/codegen-conformance/cross-package-binding/input/} and checks every entry of + * {@code expected-bindings.json}, then the generated controller and repository text. + */ +public class CrossPackageBindingConformanceTest extends SharedRegistryTestBase { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static Path fixtureDir() { + Path repoRoot = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize(); + Path dir = repoRoot.resolve("fixtures/codegen-conformance/cross-package-binding"); + assertTrue("fixture must exist at " + dir, Files.isDirectory(dir)); + return dir; + } + + private static MetaDataLoader load() throws IOException { + List uris = new ArrayList<>(); + try (Stream files = Files.list(fixtureDir().resolve("input"))) { + for (Path p : files.filter(f -> f.toString().endsWith(".json")).sorted().collect(Collectors.toList())) { + uris.add(URIHelper.toURI("model:file:" + p.toAbsolutePath().toString().replace('\\', '/'))); + } + } + assertEquals(4, uris.size()); + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "cross-package-binding"); + loader.setSourceURIs(uris); + loader.init(); + return loader; + } + + private static JsonNode expected() throws IOException { + return new ObjectMapper().readTree(Files.readString(fixtureDir().resolve("expected-bindings.json"))); + } + + private static MetaObject objectOf(MetaDataLoader loader, String key) { + MetaObject o = loader.getMetaObjectByName(key); + assertNotNull("object " + key + " must load", o); + return o; + } + + private static String tableOf(MetaObject o) { + return o.findPrimaryWritableSource().orElseThrow().getTableName(); + } + + private static String[] splitMember(String key) { + int dot = key.lastIndexOf('.'); + return new String[] { key.substring(0, dot), key.substring(dot + 1) }; + } + + @Test + public void everyIdentityReferenceIsBoundToTheExpectedObject() throws Exception { + MetaDataLoader loader = load(); + JsonNode refs = expected().get("references"); + Iterator keys = refs.fieldNames(); + int checked = 0; + while (keys.hasNext()) { + String key = keys.next(); + String[] m = splitMember(key); + MetaObject holder = objectOf(loader, m[0]); + ReferenceIdentity ref = null; + for (MetaIdentity id : holder.getIdentities()) { + if (id instanceof ReferenceIdentity && m[1].equals(id.getShortName())) ref = (ReferenceIdentity) id; + } + assertNotNull("identity.reference " + key, ref); + MetaObject bound = References.refTarget(ref, MetaIdentity.ATTR_REFERENCES); + assertNotNull("unbound " + key, bound); + assertEquals("binding of " + key, refs.get(key).asText(), bound.getName()); + checked++; + } + assertEquals(6, checked); + } + + @Test + public void everyRelationshipResolvesToTheExpectedTargetTablesAndJoinFields() throws Exception { + MetaDataLoader loader = load(); + JsonNode relations = expected().get("relations"); + Iterator keys = relations.fieldNames(); + int checked = 0; + while (keys.hasNext()) { + String key = keys.next(); + JsonNode want = relations.get(key); + String[] m = splitMember(key); + MetaObject owner = objectOf(loader, m[0]); + MetaRelationship rel = null; + for (MetaRelationship r : owner.getRelationships()) { + if (m[1].equals(r.getShortName())) rel = r; + } + assertNotNull("relationship " + key, rel); + + MetaObject target = References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF); + assertNotNull("unbound target of " + key, target); + assertEquals("target of " + key, want.get("target").asText(), target.getName()); + assertEquals("target table of " + key, want.get("targetTable").asText(), tableOf(target)); + + if ("many".equals(want.get("cardinality").asText())) { + MetaObject junction = References.refTarget(rel, MetaRelationship.ATTR_THROUGH); + assertNotNull("unbound junction of " + key, junction); + assertEquals("junction of " + key, want.get("junction").asText(), junction.getName()); + assertEquals("junction table of " + key, want.get("junctionTable").asText(), tableOf(junction)); + M2MFields fields = M2MFields.derive(rel, owner, loader.getRoot()); + assertEquals("source join field of " + key, want.get("sourceJoinField").asText(), fields.getSourceField()); + assertEquals("target join field of " + key, want.get("targetJoinField").asText(), fields.getTargetField()); + + SpringM2mSupport.M2mNav nav = SpringM2mSupport.resolve(owner, loader).stream() + .filter(n -> n.relationName().equals(m[1])).findFirst().orElse(null); + assertNotNull("codegen M:N navigation for " + key, nav); + assertEquals(want.get("sourceJoinField").asText(), nav.sourceField()); + assertEquals(want.get("targetJoinField").asText(), nav.targetField()); + } else { + ReferenceIdentity fk = RelationshipReferences.resolveRelationshipReference( + owner, rel.getShortName(), target, rel.getSourceRefField()); + assertNotNull("fk reference of " + key, fk); + assertEquals("fk field of " + key, want.get("fkField").asText(), fk.getFields().get(0)); + } + checked++; + } + assertEquals(4, checked); + } + + @Test + public void inheritedRootReferencesBindTheRootObjectNotTheInheritingPackages() throws Exception { + MetaDataLoader loader = load(); + MetaObject order = objectOf(loader, "acme::Order"); + MetaObject customer = loader.getMetaObjectByName("Customer"); + MetaObject label = loader.getMetaObjectByName("Label"); + MetaRelationship customerRel = null; + MetaRelationship labelsRel = null; + for (MetaRelationship r : order.getRelationships()) { + if ("customer".equals(r.getShortName())) customerRel = r; + if ("labels".equals(r.getShortName())) labelsRel = r; + } + assertTrue(References.refTarget(customerRel, MetaRelationship.ATTR_OBJECT_REF) == customer); + assertTrue(References.refTarget(labelsRel, MetaRelationship.ATTR_OBJECT_REF) == label); + assertTrue(References.lookupCanonical(loader.getRoot(), + customerRel.getMetaAttr(MetaRelationship.ATTR_OBJECT_REF).getValueAsString()) == customer); + } + + private Map generateAll(MetaDataLoader loader) throws IOException { + Path out = tmp.newFolder().toPath(); + for (MultiFileDirectGeneratorBase gen : List.of( + new SpringDtoGenerator(), new SpringRepositoryGenerator(), new SpringControllerGenerator())) { + Map args = new HashMap<>(); + args.put("outputDir", out.toString()); + gen.setArgs(args); + gen.execute(loader); + } + Map files = new HashMap<>(); + try (Stream walk = Files.walk(out)) { + for (Path p : walk.filter(f -> f.toString().endsWith(".java")).collect(Collectors.toList())) { + files.put(out.relativize(p).toString().replace('\\', '/'), Files.readString(p)); + } + } + return files; + } + + @Test + public void generatedControllerAndRepositoryUseTheBoundTargets() throws Exception { + Map files = generateAll(load()); + + String postController = files.get("a/PostController.java"); + String postRepo = files.get("a/PostRepository.java"); + assertNotNull(postController); + assertNotNull(postRepo); + assertTrue("Post.tags must traverse to b::Tag; saw:\n" + postController, + postController.contains("ResponseEntity> findTags(@PathVariable Long id)")); + assertTrue("Post.tags finder must return b::Tag rows; saw:\n" + postRepo, + postRepo.contains("List findTags(Long sourceId);")); + assertFalse("Post must not bind a::Tag; saw:\n" + postController, + postController.replace("b.TagDto", "").contains("TagDto")); + + String orderController = files.get("acme/OrderController.java"); + String orderRepo = files.get("acme/OrderRepository.java"); + assertNotNull(orderController); + assertNotNull(orderRepo); + assertTrue("Order.labels must traverse via the root OrderLabel junction; saw:\n" + orderController, + orderController.contains("findLabels(@PathVariable Long id)")); + assertTrue("saw:\n" + orderRepo, orderRepo.contains("findLabels(Long sourceId);")); + + for (Map.Entry f : files.entrySet()) { + for (String wrong : List.of("a_tags", "acme_labels", "acme_customers")) { + assertFalse(f.getKey() + " must not mention " + wrong, f.getValue().contains(wrong)); + } + } + } +} diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamingCanonicalRefTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamingCanonicalRefTest.java new file mode 100644 index 000000000..0639a50a3 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamingCanonicalRefTest.java @@ -0,0 +1,64 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.loader.InMemoryStringSource; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * ADR-0059 R7 — {@link SpringNaming#resolveObjectRef} and {@link SpringNaming#resolveValueObjectRef} + * resolve a CANONICAL value exactly: {@code pkg::Name} binds that package's object, {@code ::Name} + * and a bare {@code Name} bind the root-level one, and the referrer package is never applied. + * Three value objects share the short name {@code Report}, so a short-name scan or a package-local + * resolve would bind one of the packaged copies instead. + */ +public class SpringNamingCanonicalRefTest { + + private static String doc(String pkg, String field) { + String pkgPart = pkg == null ? "" : "\"package\": \"" + pkg + "\", "; + return "{ \"metadata.root\": { " + pkgPart + "\"children\": [" + + "{ \"object.value\": { \"name\": \"Report\", \"children\": [" + + "{ \"field.string\": { \"name\": \"" + field + "\" } } ] } } ] } }"; + } + + private static MetaDataLoader load(String... docs) { + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "spring-naming-canonical"); + loader.init(); + List sources = new java.util.ArrayList<>(); + for (int i = 0; i < docs.length; i++) { + sources.add(new InMemoryStringSource(docs[i], "spring-naming-canonical/meta." + i + ".json")); + } + loader.load(sources); + return loader; + } + + private static String fieldOf(MetaObject obj) { + return obj == null ? null : obj.getMetaFields().iterator().next().getName(); + } + + @Test + public void canonicalValuesResolveExactly() { + MetaDataLoader loader = load(doc("acme::alpha", "alphaVal"), doc("acme::beta", "betaVal"), doc(null, "rootVal")); + + assertEquals("alphaVal", fieldOf(SpringNaming.resolveObjectRef(loader, "acme::alpha::Report", ""))); + assertEquals("betaVal", fieldOf(SpringNaming.resolveValueObjectRef(loader, "acme::beta::Report", "acme::alpha"))); + assertEquals("rootVal", fieldOf(SpringNaming.resolveValueObjectRef(loader, "::Report", "acme::alpha"))); + assertEquals("rootVal", fieldOf(SpringNaming.resolveObjectRef(loader, "Report", "acme::alpha"))); + assertNull(SpringNaming.resolveObjectRef(loader, "acme::gamma::Report", "")); + } + + @Test + public void bareValueDoesNotBindAPackagedObject() { + MetaDataLoader loader = load(doc("acme::alpha", "alphaVal")); + + assertNull(SpringNaming.resolveObjectRef(loader, "Report", "acme::alpha")); + assertEquals("alphaVal", fieldOf(SpringNaming.resolveObjectRef(loader, "acme::alpha::Report", ""))); + } +} diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java index 258e99c4a..718383b02 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java @@ -16,12 +16,24 @@ import com.metaobjects.loader.LoaderOptions; import com.metaobjects.generator.EmitsPhysicalNameConstants; import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.generator.util.HashManifest; +import org.apache.maven.model.Plugin; +import org.apache.maven.model.PluginExecution; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Profile; +import org.apache.maven.model.building.ModelBuildingRequest; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.DefaultProjectBuildingRequest; import org.apache.maven.project.MavenProject; +import org.apache.maven.project.ProjectBuilder; +import org.apache.maven.project.ProjectBuildingException; +import org.apache.maven.project.ProjectBuildingRequest; +import org.codehaus.plexus.util.xml.Xpp3Dom; import java.io.File; import java.lang.reflect.Constructor; @@ -33,7 +45,9 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -54,9 +68,263 @@ public abstract class AbstractMetaDataMojo extends AbstractMojo @Parameter( defaultValue = "${project}", readonly = true, required = true ) protected MavenProject project; + @Parameter( defaultValue = "${session}", readonly = true ) + protected MavenSession session; + + @Component + protected ProjectBuilder projectBuilder; + @Parameter( defaultValue = "${mojoExecution}", readonly = true, required = true ) protected MojoExecution execution; + /** This plugin's key in a module's build model. */ + static final String PLUGIN_KEY = "com.metaobjects:metaobjects-maven-plugin"; + + /** + * What a codegen hash manifest entry {@code g} writes is recorded against: the + * generator's registry id and its output directory relative to the module basedir. + * It names the configuration, not the Maven execution, so a command-line run, a + * renamed execution and the lifecycle execution with the same configuration agree. + * {@code null} for a generator with no {@code outputDir}. + */ + protected HashManifest.Owner ownerOf(GeneratorParam g) { + return ownerOf(g.getClassname(), mergeAndOverwriteArgs(g).get(GeneratorBase.ARG_OUTPUTDIR)); + } + + private HashManifest.Owner ownerOf(String classname, String outputDir) { + if (classname == null || outputDir == null) return null; + return new HashManifest.Owner(RequiresCheck.registryId(classname), moduleDir(Path.of(outputDir))); + } + + /** {@code dir} as an owner names it: relative to the module basedir when inside it. */ + private String moduleDir(Path outputDir) { + Path dir = outputDir.toAbsolutePath().normalize(); + String shown = dir.toString(); + if (project != null && project.getBasedir() != null) { + Path base = project.getBasedir().toPath().toAbsolutePath().normalize(); + if (dir.startsWith(base)) shown = base.relativize(dir).toString(); + } + return shown.replace(File.separatorChar, '/'); + } + + /** + * Whether no generator the module configures for {@code dir}, in any execution or the + * plugin-level configuration, keeps hand edits: an edited orphan there may be deleted + * only when every writer of the directory says {@code handEdits=overwrite}. {@code false} + * when what is configured is unknown, so an edited orphan is never deleted on a guess. + */ + protected boolean noConfiguredWriterKeeps(Path dir) { + ConfiguredOwners c = configured(); + return c.owners() != null && !c.keepDirs().contains(moduleDir(dir)); + } + + /** + * Every generator/output-directory pair the module configures for + * {@code metaobjects:generate}: in each of its executions and in the plugin-level + * configuration a command-line run uses. It is the union over the module built with + * no profile, with each profile of the module and its parents active alone, and with + * this build's own active profiles, so a profile switched off for one build does not + * make its generators' files look stranded, and two profiles that set the same + * property or execution differently each count. Maven builds each model, so profile + * properties, inherited profiles and basedirs resolve as they do when the profile is + * on. {@code null} when a model cannot be built or none declares this plugin, so what + * is configured is unknown. Computed once per module per build and kept in the + * project's build context, since every execution of the module reads the same POMs. + */ + protected Set configuredOwners() { + return configured().owners(); + } + + /** + * Fail before anything is written when two {@code metaobjects:generate} executions of + * the module, in any of the models {@link #configuredOwners} reads, configure the same + * generator into the same output directory. Their files would share one owner, so + * neither execution could tell the other's output from its own orphans. The + * plugin-level configuration is no execution of its own: a command-line run of it and + * the one execution that inherits it are the same configuration. An execution disabled + * with {@code none} is not counted here, though its pairs still count as + * configured, so the output of an on-demand run is never stranded; run explicitly + * ({@code mvn metaobjects:generate@}), it is refused when an enabled execution owns + * one of its generator/output-directory pairs. + */ + protected void refuseSharedGeneratorDirs() { + List shared = new ArrayList<>(); + Map> executions = configured().executions(); + executions.forEach((owner, by) -> { + if (by.size() > 1) shared.add("'" + owner + "' in executions " + String.join(" and ", by)); + }); + String self = execution != null ? execution.getExecutionId() : null; + if (isDisabledGenerateExecution(self) && getGenerators() != null) { + for (GeneratorParam g : getGenerators()) { + HashManifest.Owner owner = ownerOf(g); + List by = owner != null ? executions.get(owner) : null; + if (by != null && !by.isEmpty()) { + shared.add("'" + owner + "' in executions " + String.join(" and ", by) + " and '" + self + + "' (none, invoked explicitly)"); + } + } + } + if (shared.isEmpty()) return; + shared.sort(String::compareTo); + throw new MetaDataException("Two metaobjects:generate executions of this module configure the same " + + "generator into the same output directory: " + String.join("; ", shared) + ". Give each " + + "execution its own , or merge them into one execution."); + } + + private ConfiguredOwners configured() { + if (project == null || project.getFile() == null || projectBuilder == null || session == null) { + return ConfiguredOwners.UNKNOWN; + } + if (project.getContextValue(CONFIGURED_OWNERS_CONTEXT_KEY) instanceof ConfiguredOwners known) { + return known; + } + ConfiguredOwners read = readConfiguredOwners(); + project.setContextValue(CONFIGURED_OWNERS_CONTEXT_KEY, read); + return read; + } + + private record ConfiguredOwners(Set owners, Map> executions, + Set keepDirs) { + static final ConfiguredOwners UNKNOWN = new ConfiguredOwners(null, Map.of(), Set.of()); + } + + private static final String CONFIGURED_OWNERS_CONTEXT_KEY = + AbstractMetaDataMojo.class.getName() + ".configuredOwners"; + + private ConfiguredOwners readConfiguredOwners() { + Set profileIds = new LinkedHashSet<>(); + for (MavenProject p = project; p != null; p = p.getParent()) { + if (p.getModel() == null) continue; + for (Profile profile : p.getModel().getProfiles()) profileIds.add(profile.getId()); + } + List models = new ArrayList<>(); + models.add(project); + MavenProject base = projectWith(null, profileIds); + if (base == null) return ConfiguredOwners.UNKNOWN; + models.add(base); + for (String id : profileIds) { + MavenProject alone = projectWith(id, profileIds); + if (alone == null) return ConfiguredOwners.UNKNOWN; + models.add(alone); + } + Set out = new HashSet<>(); + Set keepDirs = new HashSet<>(); + Map> executions = new HashMap<>(); + Map profileOf = new HashMap<>(); + Set baseIds = generateExecutionIds(base); + List profiles = new ArrayList<>(profileIds); + for (int i = 0; i < profiles.size(); i++) { + for (String id : generateExecutionIds(models.get(i + 2))) { + if (!baseIds.contains(id)) profileOf.putIfAbsent(id, profiles.get(i)); + } + } + boolean declared = false; + for (MavenProject model : models) { + Plugin plugin = model.getPlugin(PLUGIN_KEY); + if (plugin == null) continue; + declared = true; + Xpp3Dom pluginLevel = plugin.getConfiguration() instanceof Xpp3Dom dom ? dom : null; + out.addAll(configuredIn(pluginLevel, keepDirs)); + for (PluginExecution e : plugin.getExecutions()) { + if (!e.getGoals().contains("generate")) continue; + if (!(e.getConfiguration() instanceof Xpp3Dom execution)) continue; + for (HashManifest.Owner owner : configuredIn(pluginLevel == null ? execution + : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(execution), pluginLevel), keepDirs)) { + out.add(owner); + if (isEnabledGenerate(e)) { + executions.computeIfAbsent(owner, k -> new LinkedHashSet<>()).add(e.getId()); + } + } + } + } + if (!declared) return ConfiguredOwners.UNKNOWN; + Map> byOwner = new HashMap<>(); + executions.forEach((owner, ids) -> byOwner.put(owner, ids.stream() + .map(id -> "'" + id + "'" + (profileOf.containsKey(id) ? " (profile '" + profileOf.get(id) + "')" : "")) + .toList())); + return new ConfiguredOwners(out, byOwner, keepDirs); + } + + private static Set generateExecutionIds(MavenProject model) { + Set ids = new HashSet<>(); + Plugin plugin = model.getPlugin(PLUGIN_KEY); + if (plugin == null) return ids; + for (PluginExecution e : plugin.getExecutions()) { + if (isEnabledGenerate(e)) ids.add(e.getId()); + } + return ids; + } + + /** A {@code metaobjects:generate} execution not switched off with {@code none} + * (how a module disables an execution it inherits). */ + private static boolean isEnabledGenerate(PluginExecution e) { + return e.getGoals().contains("generate") && !"none".equals(e.getPhase()); + } + + /** Whether {@code id} names a generate execution of this module disabled with + * {@code none}, as {@code mvn metaobjects:generate@} can still run. */ + private boolean isDisabledGenerateExecution(String id) { + Plugin plugin = project != null && id != null ? project.getPlugin(PLUGIN_KEY) : null; + if (plugin == null) return false; + for (PluginExecution e : plugin.getExecutions()) { + if (id.equals(e.getId()) && e.getGoals().contains("generate") && !isEnabledGenerate(e)) return true; + } + return false; + } + + /** This module built by Maven with only profile {@code active} on ({@code null}: none of + * {@code all}); {@code null} when the build fails. */ + private MavenProject projectWith(String active, Set all) { + List inactive = new ArrayList<>(all); + inactive.remove(active); + ProjectBuildingRequest request = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest()); + request.setActiveProfileIds(active == null ? List.of() : List.of(active)); + request.setInactiveProfileIds(inactive); + request.setResolveDependencies(false); + request.setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); + request.setProject(null); + try { + return projectBuilder.build(project.getFile(), request).getProject(); + } catch (ProjectBuildingException e) { + getLog().warn("MetaData Mojo > Could not read every profile's configuration of this module, so files " + + "only a removed generator wrote are not cleaned up this run: " + e.getMessage()); + return null; + } + } + + /** The generator/output-directory pairs {@code dom} configures; adds to {@code keepDirs} + * the output directory of each one that keeps hand edits. */ + private List configuredIn(Xpp3Dom dom, Set keepDirs) { + List out = new ArrayList<>(); + if (dom == null) return out; + Map domGlobals = childValues(dom.getChild("globals")); + Xpp3Dom generators = dom.getChild("generators"); + if (generators == null) return out; + for (Xpp3Dom generator : generators.getChildren()) { + Xpp3Dom classname = generator.getChild("classname"); + if (classname == null) continue; + Map args = mergeArgs(domGlobals, childValues(generator.getChild("args"))); + HashManifest.Owner owner = ownerOf(classname.getValue(), args.get(GeneratorBase.ARG_OUTPUTDIR)); + if (owner == null) continue; + out.add(owner); + Xpp3Dom handEdits = generator.getChild("handEdits"); + if (handEdits == null || handEdits.getValue() == null + || !GeneratorParam.HAND_EDITS_OVERWRITE.equals(handEdits.getValue().trim())) { + keepDirs.add(owner.outputDir()); + } + } + return out; + } + + private static Map childValues(Xpp3Dom dom) { + Map out = new HashMap<>(); + if (dom == null) return out; + for (Xpp3Dom child : dom.getChildren()) { + if (child.getValue() != null) out.put(child.getName(), child.getValue().trim()); + } + return out; + } + @Parameter(name="loader") private LoaderParam loaderConfig = null; public LoaderParam getLoader() { @@ -257,9 +525,24 @@ protected List buildGenerators(ClassLoader projectClassLoader, public Map mergeAndOverwriteArgs(GeneratorParam g) { + Map allargs = mergeArgs(globals, g.getArgs()); + + // Dump the args to debug + getLog().debug( "-- Generator ["+g.getClass().getSimpleName()+"] merged Args"); + for ( String key : allargs.keySet()) { + getLog().debug( " "+key+" = '"+allargs.get(key) + + ((globals!=null && allargs.get(key).equals(globals.get(key)))?" #GLOBAL#":"")); + } + + return allargs; + } + + /** {@code args} over {@code globalArgs}, with every path arg resolved against the + * module basedir. */ + private Map mergeArgs(Map globalArgs, Map args) { Map allargs = new HashMap<>(); - if ( globals != null ) allargs.putAll(globals); - if ( g.getArgs() != null ) allargs.putAll(g.getArgs()); + if ( globalArgs != null ) allargs.putAll(globalArgs); + if ( args != null ) allargs.putAll(args); File basedir = project != null ? project.getBasedir() : null; if ( basedir != null ) { @@ -269,14 +552,6 @@ public Map mergeAndOverwriteArgs(GeneratorParam g) { allargs.put(key, basedir.toPath().resolve(value).normalize().toString()); } } - - // Dump the args to debug - getLog().debug( "-- Generator ["+g.getClass().getSimpleName()+"] merged Args"); - for ( String key : allargs.keySet()) { - getLog().debug( " "+key+" = '"+allargs.get(key) + - ((globals!=null && allargs.get(key).equals(globals.get(key)))?" #GLOBAL#":"")); - } - return allargs; } diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FmtSupport.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FmtSupport.java index 001d01062..01ea9c164 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FmtSupport.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FmtSupport.java @@ -158,7 +158,7 @@ public static FormatFileResult formatFile( return FormatFileResult.failure(overlay, message); } - return FormatFileResult.success(CanonicalJsonSerializer.canonicalSerialize(loader.getRoot())); + return FormatFileResult.success(CanonicalJsonSerializer.canonicalSerialize(loader.getRoot(), CanonicalJsonSerializer.RefSpelling.AUTHORED)); } /** diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/GeneratorParam.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/GeneratorParam.java index 8e3d1e9eb..74b1babea 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/GeneratorParam.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/GeneratorParam.java @@ -12,10 +12,16 @@ */ public class GeneratorParam { + /** Refuse a hand-edited generated file by name (the default). */ + public static final String HAND_EDITS_KEEP = "keep"; + /** Replace a hand-edited generated file with fresh output and re-baseline its hash. */ + public static final String HAND_EDITS_OVERWRITE = "overwrite"; + private String classname = null; private Map args = null; private List filters = null; private List scripts = null; + private String handEdits = HAND_EDITS_KEEP; public GeneratorParam() {} @@ -83,6 +89,43 @@ public void setScripts(List scripts) { this.scripts = scripts; } + /** + * How this generator's write decisions treat a generated file that was hand-edited + * since it was written: {@link #HAND_EDITS_KEEP} (default) refuses it by name and + * leaves it in place; {@link #HAND_EDITS_OVERWRITE} replaces it with fresh output + * (and lets the orphan sweep delete a hand-edited file this generator's output + * directory no longer emits, when every generator writing there agrees). Same name + * and values on every port. + * @return the configured policy, or {@link #HAND_EDITS_KEEP} if unset + */ + public String getHandEdits() { + return handEdits == null ? HAND_EDITS_KEEP : handEdits; + } + + /** + * Set the hand-edit policy ({@link #HAND_EDITS_KEEP} or {@link #HAND_EDITS_OVERWRITE}). + * @param handEdits the policy name + */ + public void setHandEdits(String handEdits) { + this.handEdits = handEdits; + } + + /** + * Validate and return this entry's {@code handEdits}, failing fast on a typo rather + * than silently falling back to {@code keep}. + * @throws com.metaobjects.MetaDataException if the value is neither "keep" nor "overwrite" + */ + public String validatedHandEdits() { + String value = getHandEdits(); + if (!HAND_EDITS_KEEP.equals(value) && !HAND_EDITS_OVERWRITE.equals(value)) { + throw new com.metaobjects.MetaDataException( + "Generator [" + classname + "] has " + value + "; " + + "valid values are '" + HAND_EDITS_KEEP + "' (default) and '" + + HAND_EDITS_OVERWRITE + "'."); + } + return value; + } + ////////////////////////////////////////////////////////////// // BUILDER PATTERN SUPPORT diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java index 99ce1411e..d916dbc48 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java @@ -4,6 +4,8 @@ import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.util.GeneratedFileWriter; +import com.metaobjects.generator.util.HashManifest; +import com.metaobjects.generator.util.OrphanSweep; import com.metaobjects.generator.util.RouteNaming; import com.metaobjects.loader.MetaDataLoader; import org.apache.maven.plugins.annotations.LifecyclePhase; @@ -34,6 +36,9 @@ public class MetaDataGeneratorMojo extends AbstractMetaDataMojo @Parameter(property = "metaobjects.allowUnmetRequires", defaultValue = "false") boolean allowUnmetRequires; + /** How this goal is named in a message telling the adopter what to run. */ + static final String OWNER = "mvn metaobjects:generate"; + @Override protected void executeGenerators(MetaDataLoader loader, List generatorImpls) { @@ -41,6 +46,7 @@ protected void executeGenerators(MetaDataLoader loader, List generato // generators its output references writes code that does not compile, and this goal // used to exit 0 on it. refuseUnmetRequires(generatorImpls); + refuseSharedGeneratorDirs(); // Refuse before any generator runs — the one choke point that sees the WHOLE // entity set every route-emitting generator below (Java's codegen-spring AND @@ -52,22 +58,178 @@ protected void executeGenerators(MetaDataLoader loader, List generato RouteNaming.assertNoCollectionNameCollisions(loader.getMetaObjects()); } + // Where this EXECUTION's codegen hash manifest lives: anchored on the module + // basedir, so two executions in one module share it (merged — every entry names + // the generator and output dir that emitted it, and a sweep leaves another + // configured generator's entries alone) and two modules never collide (each has + // its own basedir). `null` in a unit test that sets no project + // (e.g. SourceRootRegistrationTest), which keeps every write on the legacy + // marker-only rule. + Path projectRoot = project != null && project.getBasedir() != null + ? project.getBasedir().toPath() : null; + Path genStateDir = projectRoot != null + ? projectRoot.resolve(".metaobjects").resolve(".gen-state") : null; + // Captured BEFORE any write this run — read afterwards (every write that creates + // the manifest would make a fresh adoption look like it never happened). + boolean hadManifestAtStart = genStateDir != null && HashManifest.exists(genStateDir); + boolean migrating = genStateDir != null && migratingOwners(genStateDir); + // This loop IS the Java port's runner — the one place that spans generators — so it // is the only place that can see two of them claiming one output path. TypeScript's // runGen and Python's run_gen both refuse that; until this scope existed, Java could // not, and a selection whose output depended on generator order produced a WARN and // a green build. Opening the run here is what makes the check reach a real build. try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - for( Generator gen : generatorImpls ) { + if (genStateDir != null) { + run.configureManifest(genStateDir, projectRoot); + } + + // Every output dir THIS EXECUTION's generators write to, with the handEdits + // policy of every generator that writes there, and the owner of every + // generator that ran: the sweep judges this run's own entries and entries whose + // owner the module configures nowhere, so a sibling sharing the + // manifest, or even an output dir, is never touched. + Map> handEditsByDir = new LinkedHashMap<>(); + Set runOwners = new HashSet<>(); + List params = getGenerators(); + + for (int i = 0; i < generatorImpls.size(); i++) { + Generator gen = generatorImpls.get(i); getLog().info("MetaData Mojo > Executing Generator: " + gen.getClass().getName() ); - run.attributeTo( gen.getClass().getSimpleName() ); + GeneratorParam param = params != null && i < params.size() ? params.get(i) : null; + String handEdits = param != null ? param.validatedHandEdits() : GeneratorParam.HAND_EDITS_KEEP; + HashManifest.Owner owner = genStateDir != null && param != null ? ownerOf(param) : null; + if (owner != null) runOwners.add(owner); + run.attributeTo( gen.getClass().getSimpleName(), handEdits, owner ); gen.execute( loader ); + + if (genStateDir != null && param != null) { + String dir = mergeAndOverwriteArgs(param).get(GeneratorBase.ARG_OUTPUTDIR); + if (dir != null) { + Path abs = Path.of(dir).toAbsolutePath().normalize(); + handEditsByDir.computeIfAbsent(abs, k -> new ArrayList<>()).add(handEdits); + } + } + } + + run.flush(); + Set emittedThisBuild = refuseSharedPaths(run.emissions(), run.emittedPaths()); + if (migrating) { + getLog().info("MetaData Mojo > Recorded the generator and output dir of every generated " + + "file in `.metaobjects/.gen-state/.hashes.json`; this build deletes no orphaned file."); + } else if (genStateDir != null) { + sweepOrphans(genStateDir, projectRoot, handEditsByDir, emittedThisBuild, runOwners); } + reportManifestNotices(run, genStateDir, hadManifestAtStart); } registerGeneratedSourceRoots(); } + /** + * Delete the recorded files this run did not emit and may judge + * ({@link OrphanSweep#isCandidate}): its own generators' files, and files whose + * generator the module configures in no execution any more. + */ + private void sweepOrphans(Path genStateDir, Path projectRoot, Map> handEditsByDir, + Set emitted, Set runOwners) { + List dirs = new ArrayList<>(); + for (Map.Entry> e : handEditsByDir.entrySet()) { + boolean allOverwrite = !e.getValue().isEmpty() && e.getValue().stream() + .allMatch(GeneratorParam.HAND_EDITS_OVERWRITE::equals) && noConfiguredWriterKeeps(e.getKey()); + dirs.add(new OrphanSweep.Entry(e.getKey(), e.getKey() + File.separator, allOverwrite)); + } + OrphanSweep.Result swept = OrphanSweep.sweep( + genStateDir, projectRoot, dirs, emitted, runOwners, this::configuredOwners); + for (String path : swept.removed) { + getLog().info("MetaData Mojo > Removed orphaned generated file no longer produced: " + path); + } + for (String path : swept.overwrittenDeleted) { + getLog().warn(OrphanSweep.overwrittenOrphanMessage(path)); + } + if (!swept.refused.isEmpty()) { + getLog().warn(OrphanSweep.refusedMessage(swept.refused, OWNER)); + } + } + + /** + * Fail the goal when two generator/output-dir pairs emitted one path with different + * content in this build, in this execution or another of this module: the output would + * depend on which ran last. Byte-identical re-emission is one file, and a path the + * adopter hand-owns is no generator's output, so neither is a collision. The record + * lives in the project's build context, so it spans this build's executions and + * nothing else. Returns every path emitted in this build so far, which no sweep may + * treat as an orphan. + */ + private Set refuseSharedPaths(Map> emissions, Set emitted) { + @SuppressWarnings("unchecked") + Map emittedBy = project == null ? null + : (Map) project.getContextValue(EMITTED_BY_CONTEXT_KEY); + if (emittedBy == null) { + emittedBy = new HashMap<>(); + if (project != null) project.setContextValue(EMITTED_BY_CONTEXT_KEY, emittedBy); + } + List shared = new ArrayList<>(); + for (Path path : new TreeSet<>(emissions.keySet())) { + for (Map.Entry e : emissions.get(path).entrySet()) { + Emission previous = emittedBy.putIfAbsent(path, new Emission(e.getKey(), e.getValue())); + if (previous != null && !previous.owner().equals(e.getKey()) && !previous.hash().equals(e.getValue())) { + shared.add(path + " ('" + previous.owner() + "' and '" + e.getKey() + "')"); + } + } + } + if (!shared.isEmpty()) { + throw new MetaDataException("Two generators of " + OWNER + " in this module emitted the same " + + "file with different content, so each would overwrite the other's output: " + + String.join(", ", shared) + ". Configure only one generator to emit it, in one output " + + "directory."); + } + Set emittedThisBuild = new HashSet<>(emittedBy.keySet()); + emittedThisBuild.addAll(emitted); + return emittedThisBuild; + } + + private record Emission(HashManifest.Owner owner, String hash) {} + + /** + * Whether this build is the first to record owners: the manifest has entries but none + * names its generator (an 8.1.0 pre-release recorded the Maven execution instead, or + * nothing). That build records the owners and deletes nothing. Decided once per build, + * before any execution writes, and kept in the project's build context. + */ + private boolean migratingOwners(Path genStateDir) { + Object known = project.getContextValue(MIGRATING_CONTEXT_KEY); + if (known instanceof Boolean b) return b; + boolean migrating = !HashManifest.load(genStateDir).isEmpty() && HashManifest.loadOwners(genStateDir).isEmpty(); + project.setContextValue(MIGRATING_CONTEXT_KEY, migrating); + return migrating; + } + + private static final String MIGRATING_CONTEXT_KEY = MetaDataGeneratorMojo.class.getName() + ".migratingOwners"; + private static final String EMITTED_BY_CONTEXT_KEY = MetaDataGeneratorMojo.class.getName() + ".emittedBy"; + + /** The one-time notices about the manifest itself: first-run adoption, and files this + * run could not record because they landed outside the project root. */ + private void reportManifestNotices(GeneratedFileWriter.Run run, Path genStateDir, boolean hadManifestAtStart) { + if (genStateDir == null) return; + if (!run.adoptedPaths().isEmpty()) { + String lead = hadManifestAtStart + ? OWNER + ": adopted " + run.adoptedPaths().size() + + " existing generated file(s) into the codegen hash manifest " + + "`.metaobjects/.gen-state/.hashes.json`." + : OWNER + ": created the codegen hash manifest " + + "`.metaobjects/.gen-state/.hashes.json` and adopted " + run.adoptedPaths().size() + + " existing generated file(s) into it."; + getLog().warn(lead + " Commit it: it is how the next run tells a file it generated " + + "from one you edited, and how it removes files no generator emits any more."); + } + if (run.untrackedCount() > 0) { + getLog().warn(run.untrackedCount() + " generated file(s) were written outside the project " + + "root and are not recorded in `.metaobjects/.gen-state/.hashes.json`, so " + + "hand-edit protection does not apply to them beyond the GENERATED marker rule."); + } + } + /** Fail the goal, naming each missing companion, when the list does not declare what its * generators' output references; with {@link #allowUnmetRequires}, warn instead. */ void refuseUnmetRequires(List generatorImpls) { diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java index c76ce09d7..635802a6f 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java @@ -2,6 +2,8 @@ import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; +import com.metaobjects.generator.util.HashManifest; +import com.metaobjects.generator.util.OrphanSweep; import com.metaobjects.generator.verify.TemplateVerify; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.requirement.RequirementCheck; @@ -26,6 +28,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -165,6 +168,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { "meta:verify: unknown mode '" + mode + "'. Valid modes: 'codegen' (default), 'templates'."); } + refuseSharedGeneratorDirs(); verifyCodegen(); } @@ -333,6 +337,10 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException // Keyed by the normalized absolute output dir so two spellings of the same // directory ("gen", "./gen") collapse to one target. Map tempByOutputDir = new LinkedHashMap<>(); + // Every generator's handEdits policy, grouped by the same key — there being no + // record of which generator wrote a given extra file, "overwrite" is attributed to + // a directory only when EVERY generator writing into it agrees. + Map> handEditsByOutputDir = new LinkedHashMap<>(); if (getGenerators() != null) { for (GeneratorParam g : getGenerators()) { @@ -346,10 +354,13 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException + "' arg; meta:verify can only drift-check file-emitting generators"); } Path realPath = Path.of(realDir); - Path tempDir = tempByOutputDir.get(realPath.toAbsolutePath().normalize()); + Path realKey = realPath.toAbsolutePath().normalize(); + handEditsByOutputDir.computeIfAbsent(realKey, k -> new ArrayList<>()) + .add(g.validatedHandEdits()); + Path tempDir = tempByOutputDir.get(realKey); if (tempDir == null) { tempDir = tempRoot.resolve("out-" + tempByOutputDir.size()); - tempByOutputDir.put(realPath.toAbsolutePath().normalize(), tempDir); + tempByOutputDir.put(realKey, tempDir); // One compare per unique output dir, over every generator writing there. targets.add(new GenTarget(realPath, tempDir)); } @@ -357,6 +368,27 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException } } + for (GenTarget t : targets) { + Path realKey = t.realDir.toAbsolutePath().normalize(); + List handEdits = handEditsByOutputDir.get(realKey); + t.allOverwrite = handEdits != null && !handEdits.isEmpty() + && handEdits.stream().allMatch(GeneratorParam.HAND_EDITS_OVERWRITE::equals) + && noConfiguredWriterKeeps(realKey); + for (Path other : tempByOutputDir.keySet()) { + if (!other.equals(realKey) && other.startsWith(realKey)) t.nestedDirs.add(other); + } + } + + // The committed manifest, when this project has one — null otherwise, which keeps + // every extra committed file on the old blanket "stale-in-repo" wording (there is + // no evidence to tell a stray from an orphan without it). + Path projectRoot = project != null && project.getBasedir() != null + ? project.getBasedir().toPath() : null; + Path genStateDir = projectRoot != null + ? projectRoot.resolve(".metaobjects").resolve(".gen-state") : null; + Map hashes = genStateDir != null && HashManifest.exists(genStateDir) + ? HashManifest.load(genStateDir) : null; + // Build + run generators with the temp-dir overrides applied. List generatorImpls = buildGenerators(projectClassLoader, overrides); try { @@ -366,18 +398,38 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException } // Compare each output directory's temp tree against its committed tree. + Map ownHashes = hashes == null ? null + : ownedBy(hashes, HashManifest.loadOwners(genStateDir), targets, projectRoot); List drift = new ArrayList<>(); + List editedOrphans = new ArrayList<>(); for (GenTarget t : targets) { - drift.addAll(compareTrees(t)); + drift.addAll(compareTrees(t, ownHashes, projectRoot, editedOrphans)); + } + if (ownHashes != null) { + drift.addAll(strandedOutsideTargets(ownHashes, targets, projectRoot, editedOrphans)); } if (!drift.isEmpty()) { StringBuilder sb = new StringBuilder(); - sb.append("generated code is stale — run `mvn ").append(GEN_GOAL) - .append("` and commit. Drifted files:"); + if (drift.size() > editedOrphans.size()) { + sb.append("generated code is stale — run `mvn ").append(GEN_GOAL) + .append("` and commit. Drifted files:"); + } else { + // Every drifted file is an orphan that was edited by hand and that + // `generate` will (under handEdits=keep) refuse to delete — regenerating + // would change nothing, so the usual lead-in would send the reader in a + // circle. + sb.append("codegen drift detected. Drifted files:"); + } for (String d : drift) { sb.append("\n ").append(d); } + if (!editedOrphans.isEmpty()) { + sb.append("\n\nFor the file(s) above that no generator emits any more and that were " + + "edited by hand, `mvn ").append(GEN_GOAL) + .append("` will NOT delete them. Delete each one, or move your edits into a " + + "file of your own first."); + } throw new MojoFailureException(sb.toString()); } @@ -390,12 +442,86 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException failOnRequirementErrors(requirementErrors); } + /** + * The manifest entries {@code generate} would judge as orphans for the generators this + * verify runs ({@link OrphanSweep#isCandidate}): their own entries, and entries whose + * generator the module configures in no execution. Another configured generator's + * entry is left out and reads as a stranger here, as it is to {@code generate}. Empty + * while the manifest names no owner yet: the build that records them deletes nothing. + */ + private Map ownedBy(Map hashes, Map owners, + List targets, Path projectRoot) { + Map owned = new LinkedHashMap<>(); + if (owners.isEmpty()) return owned; + Set runOwners = new HashSet<>(); + for (GeneratorParam g : getGenerators() != null ? getGenerators() : List.of()) { + HashManifest.Owner owner = ownerOf(g); + if (owner != null) runOwners.add(owner); + } + Path root = projectRoot.toAbsolutePath().normalize(); + for (Map.Entry e : hashes.entrySet()) { + boolean inRunDir = targetHolding(targets, root.resolve(e.getKey()).normalize()) != null; + if (OrphanSweep.isCandidate(owners.get(e.getKey()), inRunDir, runOwners, this::configuredOwners)) { + owned.put(e.getKey(), e.getValue()); + } + } + return owned; + } + + private static GenTarget targetHolding(List targets, Path file) { + for (GenTarget t : targets) { + if (file.startsWith(t.realDir.toAbsolutePath().normalize())) return t; + } + return null; + } + + /** + * The orphans {@code generate} deletes outside every output dir this verify compares: + * files a generator the module no longer configures wrote into a directory nothing + * writes any more. + */ + private List strandedOutsideTargets(Map ownHashes, List targets, + Path projectRoot, List editedOrphans) { + List drift = new ArrayList<>(); + Path root = projectRoot.toAbsolutePath().normalize(); + for (String key : new TreeSet<>(ownHashes.keySet())) { + Path file = root.resolve(key).normalize(); + if (targetHolding(targets, file) != null || !Files.isRegularFile(file)) continue; + switch (OrphanSweep.classify(ownHashes, root, file)) { + case PRISTINE: + drift.add("[stale-in-repo] " + file + " (committed; no configured generator produces it; " + + "'mvn " + GEN_GOAL + "' deletes it)"); + break; + case EDITED: + drift.add("[stale-in-repo] " + file + " (no configured generator emits it any more, and " + + "it was edited by hand: delete it, or move your edits into a file of your own " + + "and then delete it)"); + editedOrphans.add(file.toString()); + break; + default: + break; + } + } + return drift; + } + /** * Compare one output directory's freshly-generated temp tree — the union of every * generator configured to write there — against its committed tree. * Returns a list of human-readable drift descriptions (empty if in sync). + * + *

When {@code hashes} is non-null (the project has a codegen manifest), an EXTRA + * committed file — one no generator just emitted — is classified against it rather + * than reported blindly: a file the manifest does not record is a stranger, not drift + * at all; a pristine orphan is reported with the remedy that {@code generate} will + * actually delete it; an edited orphan is reported as something {@code generate} will + * NOT delete under {@code handEdits=keep} (and its path is appended to {@code + * editedOrphans} so the caller can adjust the failure message), or, under {@code + * handEdits=overwrite} on every generator writing there, as something it WILL delete. + * With no manifest at all, every extra file keeps the old, un-evidenced wording. */ - private List compareTrees(GenTarget t) throws MojoExecutionException { + List compareTrees(GenTarget t, Map hashes, Path projectRoot, + List editedOrphans) throws MojoExecutionException { Set generated = relativeFiles(t.tempDir); Set committed = relativeFiles(t.realDir); @@ -409,6 +535,7 @@ private List compareTrees(GenTarget t) throws MojoExecutionException { boolean inGen = generated.contains(rel); boolean inCommitted = committed.contains(rel); Path committedFile = t.realDir.resolve(rel); + if (t.isNested(committedFile)) continue; if (inGen && !inCommitted) { drift.add("[missing-from-repo] " + committedFile + " (generator produces it; not committed)"); } else if (!inGen && inCommitted) { @@ -417,7 +544,33 @@ private List compareTrees(GenTarget t) throws MojoExecutionException { // also holds one (outputDir = src/main/java is legal) would otherwise report // it stale forever. if (isOwnedByEject(committedFile)) continue; - drift.add("[stale-in-repo] " + committedFile + " (committed; generator no longer produces it)"); + + if (hashes == null) { + drift.add("[stale-in-repo] " + committedFile + " (committed; generator no longer produces it)"); + continue; + } + OrphanSweep.Verdict verdict = OrphanSweep.classify(hashes, projectRoot, committedFile); + switch (verdict) { + case STRANGER: + // Not ours — never drift. + break; + case PRISTINE: + drift.add("[stale-in-repo] " + committedFile + " (committed; generator no longer " + + "produces it; 'mvn " + GEN_GOAL + "' deletes it)"); + break; + case EDITED: + if (t.allOverwrite) { + drift.add("[stale-in-repo] " + committedFile + " (no generator emits it any " + + "more, and it was edited by hand: 'mvn " + GEN_GOAL + + "' will overwrite and delete it, handEdits: overwrite)"); + } else { + drift.add("[stale-in-repo] " + committedFile + " (no generator emits it any " + + "more, and it was edited by hand: delete it, or move your edits into " + + "a file of your own and then delete it)"); + editedOrphans.add(committedFile.toString()); + } + break; + } } else { // Present in both — compare bytes. if (!contentEquals(t.tempDir.resolve(rel), committedFile)) { @@ -496,13 +649,23 @@ protected void executeGenerators(MetaDataLoader loader, List generato } /** One generator's committed-vs-temp output-dir mapping. */ - private static final class GenTarget { + static final class GenTarget { final Path realDir; final Path tempDir; + /** True iff every generator writing into {@code realDir}, this run's and every one the module + * configures, has {@code handEdits=overwrite}. */ + boolean allOverwrite; + /** Other targets' output dirs nested inside {@code realDir}: their files are theirs to judge. */ + final List nestedDirs = new ArrayList<>(); GenTarget(Path realDir, Path tempDir) { this.realDir = realDir; this.tempDir = tempDir; } + + boolean isNested(Path file) { + Path full = file.toAbsolutePath().normalize(); + return nestedDirs.stream().anyMatch(full::startsWith); + } } } diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java index 140b829ec..e602d699e 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java @@ -59,6 +59,13 @@ private static List known() { return out; } + /** The registry id of {@code classname} ({@code :}, so an ejected copy + * keeps its generator's id), or {@code classname} itself for a generator of your own. */ + static String registryId(String classname) { + Known k = resolve(known(), classname); + return k != null ? k.port() + ":" + k.stableName() : classname; + } + /** The registry entry {@code classname} is, or {@code null}: by class name, then by simple name. */ private static Known resolve(List known, String classname) { for (Known k : known) if (k.classname().equals(classname)) return k; diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ConfiguredGeneratorsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ConfiguredGeneratorsTest.java new file mode 100644 index 000000000..fa6a37289 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ConfiguredGeneratorsTest.java @@ -0,0 +1,531 @@ +package com.metaobjects.mojo; + +import com.metaobjects.MetaDataException; +import com.metaobjects.generator.GeneratorBase; +import com.metaobjects.generator.util.GeneratedFileWriter; +import com.metaobjects.generator.util.HashManifest; +import com.metaobjects.loader.MetaDataLoader; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.building.ModelBuildingRequest; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.descriptor.MojoDescriptor; +import org.apache.maven.plugin.testing.MojoRule; +import org.apache.maven.project.MavenProject; +import org.apache.maven.project.ProjectBuilder; +import org.apache.maven.project.ProjectBuildingRequest; +import org.eclipse.aether.DefaultRepositorySystemSession; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Which generators a module configures is read from the module as Maven builds it with no + * profile, with each profile alone and with the build's own profiles: a generator an + * inactive profile runs is still configured, so a plain + * build neither deletes its files nor reports them as drift, and a generator removed from + * the POM (main build or profile) is not, so its files are cleaned. Every module here is a + * real POM on disk, built by Maven's own {@link ProjectBuilder}. + */ +public class ConfiguredGeneratorsTest { + + @Rule + public MojoRule rule = new MojoRule(); + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + /** Writes one configurable file through the real write guard. */ + public static class FileGenerator extends GeneratorBase { + @Override + public void execute(MetaDataLoader loader) { + try { + GeneratedFileWriter.write(getOutputDir().toPath().resolve(getArg("filename", "Foo.java")), + "/** GENERATED */\npublic class X {}\n"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + public static class DtoGenerator extends FileGenerator {} + public static class ClientGenerator extends FileGenerator {} + + private Path root; + + @Before + public void setUp() { + root = tempFolder.getRoot().toPath(); + } + + private static String generators(String... classAndDir) { + StringBuilder sb = new StringBuilder(""); + for (int i = 0; i < classAndDir.length; i += 2) { + sb.append("").append(classAndDir[i]).append("") + .append(classAndDir[i + 1]).append(""); + } + return sb.append("").toString(); + } + + private static String plugin(String executionId, String generators) { + return "com.metaobjectsmetaobjects-maven-plugin" + + "8.1.0" + executionId + "" + + "generate" + generators + + ""; + } + + private static String profile(String id, String properties, String plugin) { + return "" + id + "" + properties + "" + + "" + plugin + ""; + } + + private static Path pom(Path dir, String artifactId, String parent, String plugins, String profiles) + throws IOException { + Files.createDirectories(dir); + Path pom = dir.resolve("pom.xml"); + Files.writeString(pom, "4.0.0" + parent + + "test" + artifactId + "1" + + "pom" + plugins + "" + + "" + profiles + ""); + return pom; + } + + private static final String DTO = DtoGenerator.class.getName(); + private static final String CLIENT = ClientGenerator.class.getName(); + + private ProjectBuildingRequest request(String... activeProfiles) { + ProjectBuildingRequest request = new DefaultMavenExecutionRequest().getProjectBuildingRequest(); + request.setRepositorySession(new DefaultRepositorySystemSession()); + request.setActiveProfileIds(List.of(activeProfiles)); + request.setResolveDependencies(false); + request.setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); + return request; + } + + /** {@code mvn [-P]} on the module at {@code pom}: the project Maven builds. */ + private MavenProject build(Path pom, String... activeProfiles) throws Exception { + return rule.lookup(ProjectBuilder.class).build(pom.toFile(), request(activeProfiles)).getProject(); + } + + /** The {@link ProjectBuilder} the mojos use, counted: every call is one model build. */ + private ProjectBuilder modelBuilder; + + private ProjectBuilder modelBuilder() throws Exception { + if (modelBuilder == null) modelBuilder = Mockito.spy(rule.lookup(ProjectBuilder.class)); + return modelBuilder; + } + + private long modelBuilds() throws Exception { + return Mockito.mockingDetails(modelBuilder()).getInvocations().stream() + .filter(i -> i.getMethod().getName().equals("build")).count(); + } + + private M inBuild(M mojo, MavenProject module, String executionId, + String... activeProfiles) throws Exception { + MavenSession session = Mockito.mock(MavenSession.class); + Mockito.when(session.getProjectBuildingRequest()).thenReturn(request(activeProfiles)); + mojo.project = module; + mojo.session = session; + mojo.projectBuilder = modelBuilder(); + mojo.execution = new MojoExecution(new MojoDescriptor(), executionId); + return mojo; + } + + private static GeneratorParam gen(String classname, Path outputDir, String filename) { + GeneratorParam p = new GeneratorParam(); + p.setClassname(classname); + p.setArgs(Map.of(GeneratorBase.ARG_OUTPUTDIR, outputDir.toString(), "filename", filename)); + return p; + } + + /** One execution of the module, as {@code mvn [-P] compile} runs it. */ + private void generate(MavenProject module, String executionId, String[] activeProfiles, GeneratorParam... params) + throws Exception { + MetaDataGeneratorMojo mojo = inBuild(new MetaDataGeneratorMojo(), module, executionId, activeProfiles); + mojo.setLoader(new LoaderParam()); + mojo.setGenerators(List.of(params)); + mojo.executeGenerators(null, mojo.buildGenerators(getClass().getClassLoader(), null)); + } + + private void verify(MavenProject module, GeneratorParam... params) throws Exception { + MetaDataVerifyMojo mojo = inBuild(new MetaDataVerifyMojo(), module, "verify"); + mojo.setLoader(LoaderParam.builder("configured-generators-test") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir(Path.of("src/test/resources").toAbsolutePath().toString()) + .withSource("mojo/verify-test-metadata.json") + .build()); + mojo.setGenerators(List.of(params)); + mojo.execute(); + } + + private static final String[] NO_PROFILES = {}; + + /** Generate with {@code -Pclient} (both executions), then a plain build. */ + private void clientBuildThenPlainBuild(Path pom, Path mainDir, Path clientDir) throws Exception { + MavenProject withClient = build(pom, "client"); + generate(withClient, "codegen", new String[] {"client"}, gen(DTO, mainDir, "Dto.java")); + generate(withClient, "client", new String[] {"client"}, gen(CLIENT, clientDir, "Client.java")); + assertTrue(Files.exists(clientDir.resolve("Client.java"))); + + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); + } + + @Test + public void inactiveProfilesGenerator_keepsItsFiles_andVerifyStaysGreen() throws Exception { + Path mainDir = root.resolve("gen"); + Path clientDir = root.resolve("client"); + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), + profile("client", "", plugin("client", generators(CLIENT, "${project.basedir}/client")))); + + clientBuildThenPlainBuild(pom, mainDir, clientDir); + + assertTrue("an inactive profile still configures its generator", Files.exists(clientDir.resolve("Client.java"))); + verify(build(pom), gen(DTO, mainDir, "Dto.java")); + } + + @Test + public void profileScopedPropertyAsOutputDir_resolvesAsWhenTheProfileIsOn() throws Exception { + Path mainDir = root.resolve("gen"); + Path clientDir = root.resolve("src/main/client"); + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), + profile("client", "${project.basedir}/src/main/client", + plugin("client", generators(CLIENT, "${client.dir}")))); + + clientBuildThenPlainBuild(pom, mainDir, clientDir); + + assertTrue(Files.exists(clientDir.resolve("Client.java"))); + verify(build(pom), gen(DTO, mainDir, "Dto.java")); + } + + @Test + public void parentPomsProfile_countsAsConfiguredInTheModule() throws Exception { + Path moduleDir = root.resolve("app"); + Path mainDir = moduleDir.resolve("gen"); + Path clientDir = moduleDir.resolve("client"); + pom(root, "parent", "", "", + profile("client", "", plugin("client", generators(CLIENT, "${project.basedir}/client")))); + Path pom = pom(moduleDir, "app", + "testparent1", + plugin("codegen", generators(DTO, "${project.basedir}/gen")), ""); + + clientBuildThenPlainBuild(pom, mainDir, clientDir); + + assertTrue(Files.exists(clientDir.resolve("Client.java"))); + verify(build(pom), gen(DTO, mainDir, "Dto.java")); + } + + @Test + public void exclusiveProfilesSettingOneOutputDir_eachKeepsItsFiles_inBothDirections() throws Exception { + Path pgDir = root.resolve("src/gen/pg"); + Path mysqlDir = root.resolve("src/gen/mysql"); + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${gen.dir}")), + profile("pg", "${project.basedir}/src/gen/pg", "") + + profile("mysql", "${project.basedir}/src/gen/mysql", "")); + + generate(build(pom, "pg"), "codegen", new String[] {"pg"}, gen(DTO, pgDir, "Dto.java")); + generate(build(pom, "mysql"), "codegen", new String[] {"mysql"}, gen(DTO, mysqlDir, "Dto.java")); + assertTrue("-Pmysql leaves what -Ppg configures", Files.exists(pgDir.resolve("Dto.java"))); + verify(build(pom, "mysql"), gen(DTO, mysqlDir, "Dto.java")); + + generate(build(pom, "pg"), "codegen", new String[] {"pg"}, gen(DTO, pgDir, "Dto.java")); + assertTrue("-Ppg leaves what -Pmysql configures", Files.exists(mysqlDir.resolve("Dto.java"))); + verify(build(pom, "pg"), gen(DTO, pgDir, "Dto.java")); + } + + @Test + public void twoProfilesOverridingOneExecutionsGenerators_eachKeepsItsFiles() throws Exception { + Path aDir = root.resolve("a"); + Path bDir = root.resolve("b"); + Path pom = pom(root, "app", "", "", + profile("a", "", plugin("codegen", generators(DTO, "${project.basedir}/a"))) + + profile("b", "", plugin("codegen", generators(CLIENT, "${project.basedir}/b")))); + + generate(build(pom, "a"), "codegen", new String[] {"a"}, gen(DTO, aDir, "A.java")); + generate(build(pom, "b"), "codegen", new String[] {"b"}, gen(CLIENT, bDir, "B.java")); + assertTrue("profile b's run leaves what profile a configures", Files.exists(aDir.resolve("A.java"))); + verify(build(pom, "b"), gen(CLIENT, bDir, "B.java")); + + generate(build(pom, "a"), "codegen", new String[] {"a"}, gen(DTO, aDir, "A.java")); + assertTrue("profile a's run leaves what profile b configures", Files.exists(bDir.resolve("B.java"))); + verify(build(pom, "a"), gen(DTO, aDir, "A.java")); + } + + @Test + public void generatorRemovedFromAProfile_itsFilesAreCleaned_andVerifyReportsThemFirst() throws Exception { + Path mainDir = root.resolve("gen"); + Path clientDir = root.resolve("client"); + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), + profile("client", "", plugin("client", generators(CLIENT, "${project.basedir}/client")))); + MavenProject withClient = build(pom, "client"); + generate(withClient, "codegen", new String[] {"client"}, gen(DTO, mainDir, "Dto.java")); + generate(withClient, "client", new String[] {"client"}, gen(CLIENT, clientDir, "Client.java")); + + pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), + profile("client", "", plugin("client", generators()))); + try { + verify(build(pom), gen(DTO, mainDir, "Dto.java")); + fail("expected the removed generator's file to be drift"); + } catch (MojoFailureException e) { + assertTrue(e.getMessage(), e.getMessage().contains("Client.java") + && e.getMessage().contains("'mvn metaobjects:generate' deletes it")); + } + + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); + + assertFalse(Files.exists(clientDir.resolve("Client.java"))); + assertTrue(Files.exists(mainDir.resolve("Dto.java"))); + } + + @Test + public void generatorRemovedFromTheMainBuild_itsFilesAreCleanedWhereverTheyLive() throws Exception { + Path mainDir = root.resolve("gen"); + Path webDir = root.resolve("web"); + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen", + CLIENT, "${project.basedir}/web")), ""); + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java"), gen(CLIENT, webDir, "Route.java")); + + pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), ""); + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); + + assertFalse("no execution configures its generator any more", Files.exists(webDir.resolve("Route.java"))); + assertTrue(Files.exists(mainDir.resolve("Dto.java"))); + } + + @Test + public void twoExecutionsInOneModule_readTheConfiguredGeneratorsOnce() throws Exception { + Path mainDir = root.resolve("gen"); + Path webDir = root.resolve("web"); + Path clientDir = root.resolve("client"); + String client = "clientgenerate" + + generators(CLIENT, "${project.basedir}/client") + ""; + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen", + CLIENT, "${project.basedir}/web")).replace("", client + ""), ""); + MavenProject first = build(pom); + generate(first, "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java"), gen(CLIENT, webDir, "Route.java")); + generate(first, "client", NO_PROFILES, gen(CLIENT, clientDir, "Client.java")); + Files.writeString(webDir.resolve("Route.java"), "/** GENERATED */\npublic class X { int mine; }\n"); + + pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")) + .replace("", client + ""), ""); + long before = modelBuilds(); + MavenProject second = build(pom); + generate(second, "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); + generate(second, "client", NO_PROFILES, gen(CLIENT, clientDir, "Client.java")); + + assertTrue("an edited orphan of a removed generator is refused", Files.exists(webDir.resolve("Route.java"))); + assertEquals("one model (no profiles) for the module, shared by both executions", 1, modelBuilds() - before); + } + + private static String execution(String id, String generators) { + return "" + id + "generate" + + generators + ""; + } + + @Test + public void oneGeneratorInOneDirFromTwoExecutions_failsBeforeWritingAnything() throws Exception { + Path gen = root.resolve("gen"); + Path pom = pom(root, "app", "", plugin("core", generators(DTO, "${project.basedir}/gen")) + .replace("", execution("ext", generators(DTO, "${project.basedir}/gen")) + ""), ""); + try { + generate(build(pom), "core", NO_PROFILES, gen(DTO, gen, "A.java")); + fail("expected two executions of one generator into one dir to be refused"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("'core' and 'ext'") + && e.getMessage().contains("its own ")); + } + assertFalse("nothing written", Files.exists(gen.resolve("A.java"))); + try { + verify(build(pom), gen(DTO, gen, "A.java")); + fail("expected verify to refuse it too"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("'core' and 'ext'")); + } + } + + @Test + public void oneGeneratorInOneDirFromAProfilesExecution_failsNamingTheProfile() throws Exception { + Path gen = root.resolve("gen"); + Path pom = pom(root, "app", "", plugin("core", generators(DTO, "${project.basedir}/gen")), + profile("extra", "", plugin("extra", generators(DTO, "${project.basedir}/gen")))); + try { + generate(build(pom), "core", NO_PROFILES, gen(DTO, gen, "A.java")); + fail("expected a profile's execution of the same generator and dir to be refused"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("'core' and 'extra' (profile 'extra')")); + } + assertFalse(Files.exists(gen.resolve("A.java"))); + } + + @Test + public void twoExecutionsInTwoDirs_eachCleansItsOwnOrphans() throws Exception { + Path gen = root.resolve("gen"); + Path client = root.resolve("client"); + Path pom = pom(root, "app", "", plugin("core", generators(DTO, "${project.basedir}/gen")) + .replace("", execution("ext", generators(DTO, "${project.basedir}/client")) + ""), ""); + MavenProject first = build(pom); + generate(first, "core", NO_PROFILES, gen(DTO, gen, "A.java")); + generate(first, "ext", NO_PROFILES, gen(DTO, client, "B.java"), gen(DTO, client, "C.java")); + + MavenProject second = build(pom); + generate(second, "core", NO_PROFILES, gen(DTO, gen, "A.java")); + assertTrue("core leaves ext's files", Files.exists(client.resolve("C.java"))); + generate(second, "ext", NO_PROFILES, gen(DTO, client, "B.java")); + + assertFalse("no execution emits it any more", Files.exists(client.resolve("C.java"))); + assertTrue(Files.exists(gen.resolve("A.java")) && Files.exists(client.resolve("B.java"))); + } + + @Test + public void onDemandExecutionsOutput_isKeptByTheLifecycleBuildAndVerify() throws Exception { + Path gen = root.resolve("gen"); + Path scaffold = root.resolve("src/main/scaffold"); + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")) + .replace("", "scaffoldnonegenerate" + + "" + generators(DTO, "${project.basedir}/src/main/scaffold") + + ""), ""); + + generate(build(pom), "scaffold", NO_PROFILES, gen(DTO, scaffold, "A.java")); + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, gen, "Dto.java")); + + assertTrue("mvn compile leaves what generate@scaffold wrote", Files.exists(scaffold.resolve("A.java"))); + verify(build(pom), gen(DTO, gen, "Dto.java")); + } + + /** A parent declares execution {@code codegen}; the child switches it off with + * {@code none} and declares {@code codegen-app} on the same generator and dir. */ + private Path childDisablingTheParentsExecution() throws IOException { + pom(root, "parent", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), ""); + return pom(root.resolve("app"), "app", + "testparent1", + "com.metaobjectsmetaobjects-maven-plugin" + + "codegennone" + + execution("codegen-app", generators(DTO, "${project.basedir}/gen")) + + "", ""); + } + + @Test + public void executionDisabledWithPhaseNone_isNotASecondOwner() throws Exception { + Path gen = root.resolve("app").resolve("gen"); + Path pom = childDisablingTheParentsExecution(); + + generate(build(pom), "codegen-app", NO_PROFILES, gen(DTO, gen, "A.java")); + + assertTrue(Files.exists(gen.resolve("A.java"))); + verify(build(pom), gen(DTO, gen, "A.java")); + } + + @Test + public void disabledExecutionInvokedExplicitly_isRefusedWhenAnEnabledOneOwnsItsPair() throws Exception { + Path gen = root.resolve("app").resolve("gen"); + try { + generate(build(childDisablingTheParentsExecution()), "codegen", NO_PROFILES, gen(DTO, gen, "A.java")); + fail("expected generate@codegen to be refused"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("'codegen-app' and 'codegen'")); + } + assertFalse("nothing written", Files.exists(gen.resolve("A.java"))); + } + + /** {@code mvn metaobjects:generate compile}: the command-line run and the execution that + * inherits the plugin-level configuration are one configuration, run twice. */ + @Test + public void commandLineRunAndTheExecutionInheritingItsConfiguration_inOneBuild_isGreen() throws Exception { + Path gen = root.resolve("gen"); + Path pom = pom(root, "app", "", "com.metaobjects" + + "metaobjects-maven-plugin8.1.0" + + "" + generators(DTO, "${project.basedir}/gen") + "" + + "codegengenerate" + + "", ""); + MavenProject module = build(pom); + generate(module, "default-cli", NO_PROFILES, gen(DTO, gen, "Dto.java")); + generate(module, "codegen", NO_PROFILES, gen(DTO, gen, "Dto.java")); + + assertTrue(Files.exists(gen.resolve("Dto.java"))); + verify(build(pom), gen(DTO, gen, "Dto.java")); + } + + private static String overwriting(String classname, String outputDir) { + return "" + classname + "overwrite" + + "" + outputDir + ""; + } + + private static GeneratorParam overwritingGen(String classname, Path outputDir, String filename) { + GeneratorParam p = gen(classname, outputDir, filename); + p.setHandEdits(GeneratorParam.HAND_EDITS_OVERWRITE); + return p; + } + + @Test + public void editedOrphan_everyWriterOfItsDirOverwrites_isDeleted() throws Exception { + Path gen = root.resolve("gen"); + Path pom = pom(root, "app", "", plugin("codegen-web", overwriting(DTO, "${project.basedir}/gen")), ""); + generate(build(pom), "codegen-web", NO_PROFILES, overwritingGen(DTO, gen, "Telemetry.java")); + Files.writeString(gen.resolve("Telemetry.java"), "/** GENERATED */\npublic class X { int mine; }\n"); + + generate(build(pom), "codegen-web", NO_PROFILES, overwritingGen(DTO, gen, "TelemetryReading.java")); + + assertFalse(Files.exists(gen.resolve("Telemetry.java"))); + } + + @Test + public void editedOrphan_anotherExecutionKeepsInItsDir_isRefusedNotDeleted() throws Exception { + Path gen = root.resolve("gen"); + Path pom = pom(root, "app", "", plugin("codegen-web", overwriting(DTO, "${project.basedir}/gen")) + .replace("", execution("codegen-data", generators(CLIENT, "${project.basedir}/gen")) + + ""), ""); + MavenProject first = build(pom); + generate(first, "codegen-web", NO_PROFILES, overwritingGen(DTO, gen, "Telemetry.java")); + generate(first, "codegen-data", NO_PROFILES, gen(CLIENT, gen, "Repository.java")); + Files.writeString(gen.resolve("Telemetry.java"), "/** GENERATED */\npublic class X { int mine; }\n"); + + MavenProject second = build(pom); + generate(second, "codegen-web", NO_PROFILES, overwritingGen(DTO, gen, "TelemetryReading.java")); + generate(second, "codegen-data", NO_PROFILES, gen(CLIENT, gen, "Repository.java")); + + assertTrue("one keep writer in the directory keeps the edit", Files.exists(gen.resolve("Telemetry.java"))); + } + + /** A manifest an 8.1.0 pre-release wrote names the Maven execution, or nothing. The first + * build records owners and deletes nothing; the next one sweeps what nothing emits. */ + @Test + public void manifestWithoutGeneratorOwners_firstBuildRecordsOwnersAndDeletesNothing() throws Exception { + Path outDir = root.resolve("gen"); + Files.createDirectories(outDir); + String body = "/** GENERATED */\npublic class X {}\n"; + Path kept = outDir.resolve("Kept.java"); + Path old = outDir.resolve("Old.java"); + Files.writeString(kept, body); + Files.writeString(old, body); + Path genState = root.resolve(".metaobjects").resolve(".gen-state"); + Files.createDirectories(genState); + String hash = HashManifest.contentHash(body); + Files.writeString(genState.resolve(".hashes.json"), "{\n" + + " \"gen/Kept.java\": { \"hash\": \"" + hash + "\", \"owner\": \"app:codegen\" },\n" + + " \"gen/Old.java\": { \"hash\": \"" + hash + "\", \"owner\": \"app:codegen\" }\n}\n"); + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), ""); + + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, outDir, "Kept.java")); + + assertTrue("the build that records owners deletes nothing", Files.exists(old)); + assertTrue(HashManifest.loadOwners(genState).containsKey("gen/Kept.java")); + + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, outDir, "Kept.java")); + + assertFalse("recorded in this run's directory, emitted by nothing", Files.exists(old)); + assertTrue(Files.exists(kept)); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DeclaresNothingWritesNothingTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DeclaresNothingWritesNothingTest.java index 28974e155..c4bd72dc1 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DeclaresNothingWritesNothingTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DeclaresNothingWritesNothingTest.java @@ -88,6 +88,9 @@ public void java_aPomThatListsOnlyEntityGetsItsClassesAndNoExtractorOrApiReferen .build()))).execute(); assertEquals(List.of( + // The codegen hash manifest — not a generator's output, but something every + // `generate` run now writes/updates alongside it (see HashManifest). + ".metaobjects/.gen-state/.hashes.json", "generated/META-INF/services/com.metaobjects.registry.ObjectClassBindingProvider", "generated/com/metaobjects/generated/GeneratedObjectClassBindingProvider.java", "generated/verify/demo/Gadget.java", @@ -103,6 +106,8 @@ public void kotlin_aPomThatListsOnlyEntityGetsItsFilesAndNoApiReference() throws List written = written(); assertFalse("the listed generator wrote nothing", written.isEmpty()); assertTrue("a file the entry did not declare: " + written, - written.stream().allMatch(f -> f.startsWith("generated/verify/demo/") && f.endsWith(".kt"))); + written.stream().allMatch(f -> + f.equals(".metaobjects/.gen-state/.hashes.json") + || (f.startsWith("generated/verify/demo/") && f.endsWith(".kt")))); } } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FmtSupportTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FmtSupportTest.java index c91fa4de6..4dcf8c122 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FmtSupportTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FmtSupportTest.java @@ -88,14 +88,11 @@ public void checkModeNeverWritesAByte() throws IOException { public void anUnsafeCandidateIsNeverWritten() throws IOException { Path base = dir.resolve("meta.base.json"); byte[] before = Files.readAllBytes(base); - // Baseline loads LAX (strict=false) — the messy file carries no unknown - // attrs, so this is clean either way. Force the SAFETY-CHECK reload loader - // to disagree about something the baseline never saw, by handing it a - // DIFFERENT loader name than the baseline used — the canonical output's - // (leaked) top-level "package" then differs from the baseline's, so the - // byte-identical check fails even though the candidate is a legitimate - // reformat. This manufactures the "unsafe" branch deterministically, - // without depending on finding a real formatter bug to trigger it. + // Force the SAFETY-CHECK reload loader to disagree about something the baseline + // never saw, by pre-seeding it with an extra object — the canonical output then + // differs from the baseline's, so the equivalence check fails even though the + // candidate is a legitimate reformat. This manufactures the "unsafe" branch + // deterministically, without depending on finding a real formatter bug to trigger it. MetaDataLoader baseline = loadBaseline(base, false); FmtSupport.RunResult result = FmtSupport.run( @@ -105,8 +102,9 @@ public void anUnsafeCandidateIsNeverWritten() throws IOException { List.of(), () -> { MetaDataLoader l = new MetaDataLoader( - LoaderOptions.create(false, false, false), MetaDataLoader.SUBTYPE_MANUAL, "not-acme"); + LoaderOptions.create(false, false, false), MetaDataLoader.SUBTYPE_MANUAL, "acme"); l.init(); + l.getRoot().addChild(com.metaobjects.object.ValueMetaObject.create("seeded::Extra")); return l; }, loaderName -> freshLoader(false), diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java new file mode 100644 index 000000000..a83c2d7d7 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java @@ -0,0 +1,508 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.Generator; +import com.metaobjects.generator.GeneratorBase; +import com.metaobjects.generator.util.GeneratedFileWriter; +import com.metaobjects.generator.util.HashManifest; +import com.metaobjects.MetaDataException; +import com.metaobjects.loader.MetaDataLoader; +import org.apache.maven.model.Build; +import org.apache.maven.model.Plugin; +import org.apache.maven.model.PluginExecution; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.plugin.descriptor.MojoDescriptor; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * {@code mvn metaobjects:generate}'s codegen hash manifest: the rename case (an orphan the + * manifest recorded and this run did not re-emit is deleted and forgotten), the hand-edit + * cases (refused by default, deleted under {@code handEdits=overwrite}), first-run + * adoption, multi-execution isolation within one module, and the stranger-file floor. + */ +public class MetaDataGeneratorMojoManifestTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + /** + * Writes one configurable file into its {@code outputDir} when executed, through + * {@link GeneratedFileWriter} — exactly as a real generator does — so the manifest, + * {@code handEdits} policy and run-scoped bookkeeping this test exercises actually + * apply. (A generator that wrote straight to disk, bypassing the guard, would make + * every one of these assertions vacuously true regardless of the Mojo's behavior.) + */ + public static class ConfigurableFileGenerator extends GeneratorBase { + @Override + public void execute(MetaDataLoader loader) { + try { + Path dir = getOutputDir().toPath(); + String filename = getArg("filename", "Foo.java"); + String content = getArg("content", "/** GENERATED */\npublic class Foo {}\n"); + GeneratedFileWriter.write(dir.resolve(filename), content); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private MetaDataGeneratorMojo mojo; + private MavenProject project; + private Path projectRoot; + + @Before + public void setUp() { + mojo = new MetaDataGeneratorMojo(); + mojo.setLoader(new LoaderParam()); + project = Mockito.mock(MavenProject.class); + projectRoot = tempFolder.getRoot().toPath(); + Mockito.when(project.getBasedir()).thenReturn(tempFolder.getRoot()); + mojo.project = project; + } + + private static GeneratorParam param(String outputDir, String filename, String content, String handEdits) { + GeneratorParam p = new GeneratorParam(); + p.setClassname(ConfigurableFileGenerator.class.getName()); + p.setArgs(Map.of( + GeneratorBase.ARG_OUTPUTDIR, outputDir, + "filename", filename, + "content", content)); + if (handEdits != null) { + p.setHandEdits(handEdits); + } + return p; + } + + private List build(GeneratorParam... params) { + mojo.setGenerators(List.of(params)); + return mojo.buildGenerators(getClass().getClassLoader(), null); + } + + private Map owners() { + return HashManifest.loadOwners(projectRoot.resolve(".metaobjects").resolve(".gen-state")); + } + + private Map manifest() { + return HashManifest.load(projectRoot.resolve(".metaobjects").resolve(".gen-state")); + } + + private static final String MARKER_BODY = "/** GENERATED */\npublic class X {}\n"; + + // === rename case =========================================================== + + @Test + public void renamedOutput_oldFileDeletedAndForgotten() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path oldFile = Path.of(outDir, "Telemetry.java"); + + mojo.executeGenerators(null, build(param(outDir, "Telemetry.java", MARKER_BODY, null))); + assertTrue(Files.exists(oldFile)); + assertTrue(manifest().containsKey("gen/Telemetry.java")); + + // Same module, same execution's output dir, but the generator now emits a + // differently-named file — simulating `Telemetry` renamed to `TelemetryReading`. + mojo.executeGenerators(null, build(param(outDir, "TelemetryReading.java", MARKER_BODY, null))); + + assertFalse("the renamed-away file must be deleted", Files.exists(oldFile)); + assertTrue(Files.exists(Path.of(outDir, "TelemetryReading.java"))); + assertFalse("its manifest entry must be forgotten", manifest().containsKey("gen/Telemetry.java")); + assertTrue(manifest().containsKey("gen/TelemetryReading.java")); + } + + // === hand-edited orphan: refused by default ================================ + + @Test + public void handEditedOrphan_keepPolicy_isRefusedKeptAndStillRecorded() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path oldFile = Path.of(outDir, "Telemetry.java"); + + mojo.executeGenerators(null, build(param(outDir, "Telemetry.java", MARKER_BODY, null))); + Files.writeString(oldFile, MARKER_BODY + "// hand edit\n"); + + mojo.executeGenerators(null, build(param(outDir, "TelemetryReading.java", MARKER_BODY, null))); + + assertTrue("an edited orphan is left in place under handEdits=keep", Files.exists(oldFile)); + assertTrue("still recorded, so the refusal repeats until acted on", + manifest().containsKey("gen/Telemetry.java")); + } + + // === hand-edited orphan under handEdits=overwrite, configuration unknown ====== + + /** Whether every configured writer of the directory overwrites is unknown here (no + * Maven model to read), so the edit is kept; ConfiguredGeneratorsTest covers deletion. */ + @Test + public void handEditedOrphan_overwritePolicy_configurationUnknown_isKept() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path oldFile = Path.of(outDir, "Telemetry.java"); + + mojo.executeGenerators(null, + build(param(outDir, "Telemetry.java", MARKER_BODY, GeneratorParam.HAND_EDITS_OVERWRITE))); + Files.writeString(oldFile, MARKER_BODY + "// hand edit\n"); + + mojo.executeGenerators(null, + build(param(outDir, "TelemetryReading.java", MARKER_BODY, GeneratorParam.HAND_EDITS_OVERWRITE))); + + assertTrue("never deleted on a guess", Files.exists(oldFile)); + assertTrue(manifest().containsKey("gen/Telemetry.java")); + } + + // === first-run adoption ====================================================== + + @Test + public void firstRunAdoption_markerFilePreexisting_isAdoptedNotDeleted() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path preexisting = Path.of(outDir, "Legacy.java"); + Files.writeString(preexisting, MARKER_BODY); + + // No manifest exists yet; this run's generator does not even emit Legacy.java — + // adoption must not be confused with the orphan sweep, and nothing is deleted + // on the very run that creates the manifest. + mojo.executeGenerators(null, build(param(outDir, "Telemetry.java", MARKER_BODY, null))); + + assertTrue("first run never sweeps — nothing was recorded to sweep against", + Files.exists(preexisting)); + } + + @Test + public void firstRunAdoption_ofTheEmittedFileItself_recordsAndWritesOneNotice() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path file = Path.of(outDir, "Telemetry.java"); + Files.writeString(file, MARKER_BODY); // pre-existing marker-bearing file, no manifest yet + + mojo.executeGenerators(null, build(param(outDir, "Telemetry.java", MARKER_BODY, null))); + + assertTrue(manifest().containsKey("gen/Telemetry.java")); + assertEquals(MARKER_BODY, Files.readString(file)); + } + + // === two executions in one module share the manifest without cross-sweeping === + + @Test + public void twoExecutionsInOneModule_shareManifest_neitherSweepsTheOther() throws IOException { + String dirA = tempFolder.newFolder("genA").getAbsolutePath(); + String dirB = tempFolder.newFolder("genB").getAbsolutePath(); + + // Execution 1 writes into genA. + mojo.executeGenerators(null, build(param(dirA, "A.java", MARKER_BODY, null))); + // Execution 2 (a second in the same module/pom) writes into genB. + mojo.executeGenerators(null, build(param(dirB, "B.java", MARKER_BODY, null))); + + assertTrue(manifest().containsKey("genA/A.java")); + assertTrue(manifest().containsKey("genB/B.java")); + + // Re-running execution 1's generator alone (its own Mojo invocation) must not + // touch genB's recorded file, even though both entries live in the same manifest. + mojo.executeGenerators(null, build(param(dirA, "A.java", MARKER_BODY, null))); + + assertTrue("genB/B.java is outside this execution's jurisdiction", + Files.exists(Path.of(dirB, "B.java"))); + assertTrue(manifest().containsKey("genB/B.java")); + } + + /** Distinct generators, so each is its own generator/output-dir owner. */ + public static class DtoGenerator extends ConfigurableFileGenerator {} + public static class ControllerGenerator extends ConfigurableFileGenerator {} + public static class RepositoryGenerator extends ConfigurableFileGenerator {} + public static class RouteGenerator extends ConfigurableFileGenerator {} + + private static GeneratorParam gen(Class generator, String outputDir, String filename) { + GeneratorParam p = param(outputDir, filename, MARKER_BODY, null); + p.setClassname(generator.getName()); + return p; + } + + private static Xpp3Dom configuration(GeneratorParam... params) { + Xpp3Dom configuration = new Xpp3Dom("configuration"); + Xpp3Dom generators = new Xpp3Dom("generators"); + for (GeneratorParam p : params) { + Xpp3Dom generator = new Xpp3Dom("generator"); + Xpp3Dom classname = new Xpp3Dom("classname"); + classname.setValue(p.getClassname()); + generator.addChild(classname); + Xpp3Dom args = new Xpp3Dom("args"); + for (Map.Entry e : p.getArgs().entrySet()) { + Xpp3Dom arg = new Xpp3Dom(e.getKey()); + arg.setValue(e.getValue()); + args.addChild(arg); + } + generator.addChild(args); + generators.addChild(generator); + } + configuration.addChild(generators); + return configuration; + } + + /** + * A fresh build of this module (a new project instance, so a new build context) whose + * POM configures {@code metaobjects:generate} with {@code pluginLevel} and one + * execution per {@code executions} entry, as Maven's effective model presents it. + */ + private MavenProject newBuild(Xpp3Dom pluginLevel, Map executions) { + MavenProject build = new MavenProject(); + build.setArtifactId("app"); + build.setFile(projectRoot.resolve("pom.xml").toFile()); + Plugin plugin = new Plugin(); + plugin.setGroupId("com.metaobjects"); + plugin.setArtifactId("metaobjects-maven-plugin"); + plugin.setConfiguration(pluginLevel); + for (Map.Entry e : executions.entrySet()) { + PluginExecution execution = new PluginExecution(); + execution.setId(e.getKey()); + execution.addGoal("generate"); + execution.setConfiguration(e.getValue()); + plugin.addExecution(execution); + } + Build model = new Build(); + model.addPlugin(plugin); + build.getModel().setBuild(model); + return build; + } + + /** One {@code } of the module {@code build} stands for, run as Maven runs it. */ + private void runExecution(MavenProject build, String executionId, GeneratorParam... params) { + MetaDataGeneratorMojo execution = new MetaDataGeneratorMojo(); + execution.setLoader(new LoaderParam()); + execution.project = build; + execution.execution = new MojoExecution(new MojoDescriptor(), executionId); + execution.setGenerators(List.of(params)); + execution.executeGenerators(null, execution.buildGenerators(getClass().getClassLoader(), null)); + } + + @Test + public void twoExecutionsSharingOneOutputDir_neitherSweepsTheOther_acrossBuilds() throws IOException { + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, shared, "Dto.java"); + GeneratorParam controller = gen(ControllerGenerator.class, shared, "Controller.java"); + GeneratorParam repository = gen(RepositoryGenerator.class, shared, "Repository.java"); + Map executions = Map.of( + "codegen-web", configuration(dto, controller), "codegen-data", configuration(repository)); + + for (int build = 1; build <= 2; build++) { + MavenProject module = newBuild(null, executions); + runExecution(module, "codegen-web", dto, controller); + runExecution(module, "codegen-data", repository); + + assertTrue("build " + build + ": the first execution's files survive the second's sweep", + Files.exists(Path.of(shared, "Dto.java")) && Files.exists(Path.of(shared, "Controller.java"))); + assertTrue("build " + build, Files.exists(Path.of(shared, "Repository.java"))); + } + Map owners = owners(); + assertEquals(new HashManifest.Owner(DtoGenerator.class.getName(), "generated-sources"), + owners.get("generated-sources/Dto.java")); + assertEquals(new HashManifest.Owner(RepositoryGenerator.class.getName(), "generated-sources"), + owners.get("generated-sources/Repository.java")); + } + + @Test + public void sharedOutputDir_aGeneratorStillSweepsItsOwnOrphans() throws IOException { + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + GeneratorParam repository = gen(RepositoryGenerator.class, shared, "Repository.java"); + Map executions = Map.of( + "codegen-web", configuration(gen(DtoGenerator.class, shared, "Telemetry.java")), + "codegen-data", configuration(repository)); + + MavenProject first = newBuild(null, executions); + runExecution(first, "codegen-web", gen(DtoGenerator.class, shared, "Telemetry.java")); + runExecution(first, "codegen-data", repository); + + MavenProject second = newBuild(null, executions); + runExecution(second, "codegen-web", gen(DtoGenerator.class, shared, "TelemetryReading.java")); + runExecution(second, "codegen-data", repository); + + assertFalse(Files.exists(Path.of(shared, "Telemetry.java"))); + assertTrue(Files.exists(Path.of(shared, "TelemetryReading.java"))); + assertTrue(Files.exists(Path.of(shared, "Repository.java"))); + } + + /** + * The lifecycle execution generates; the user runs {@code mvn metaobjects:generate}, + * which Maven runs as {@code default-cli} with the plugin-level configuration; then an + * entity is deleted and the lifecycle execution runs again. Its file must go. + */ + @Test + public void commandLineRunThenLifecycleRun_aDeletedEntitysFileIsDeleted() throws IOException { + String outDir = tempFolder.newFolder("generated-sources").getAbsolutePath(); + Xpp3Dom pluginLevel = configuration(gen(DtoGenerator.class, outDir, "Telemetry.java")); + Map executions = Map.of("codegen", pluginLevel); + + runExecution(newBuild(pluginLevel, executions), "codegen", gen(DtoGenerator.class, outDir, "Telemetry.java")); + runExecution(newBuild(pluginLevel, executions), "default-cli", gen(DtoGenerator.class, outDir, "Telemetry.java")); + runExecution(newBuild(pluginLevel, executions), "codegen", gen(DtoGenerator.class, outDir, "Other.java")); + + assertFalse("the deleted entity's file is an orphan, whichever execution wrote it last", + Files.exists(Path.of(outDir, "Telemetry.java"))); + assertFalse(manifest().containsKey("generated-sources/Telemetry.java")); + } + + @Test + public void renamedExecution_stillSweepsTheOrphansTheOldIdWrote() throws IOException { + String outDir = tempFolder.newFolder("generated-sources").getAbsolutePath(); + GeneratorParam before = gen(DtoGenerator.class, outDir, "Telemetry.java"); + runExecution(newBuild(null, Map.of("codegen", configuration(before))), "codegen", before); + + GeneratorParam after = gen(DtoGenerator.class, outDir, "TelemetryReading.java"); + runExecution(newBuild(null, Map.of("generate-java", configuration(after))), "generate-java", after); + + assertFalse(Files.exists(Path.of(outDir, "Telemetry.java"))); + assertTrue(Files.exists(Path.of(outDir, "TelemetryReading.java"))); + } + + @Test + public void onePathEmittedByTwoGenerators_isAnErrorNamingBoth() throws IOException { + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, shared, "Dto.java"); + GeneratorParam repository = withContent(gen(RepositoryGenerator.class, shared, "Dto.java"), + "/** GENERATED */\npublic class Dto { int other; }\n"); + MavenProject module = newBuild(null, Map.of( + "codegen-web", configuration(dto), "codegen-data", configuration(repository))); + runExecution(module, "codegen-web", dto); + try { + runExecution(module, "codegen-data", repository); + fail("expected the second generator to be refused"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("Dto.java") + && e.getMessage().contains(DtoGenerator.class.getName() + " -> generated-sources") + && e.getMessage().contains(RepositoryGenerator.class.getName() + " -> generated-sources")); + } + } + + private static GeneratorParam withContent(GeneratorParam p, String content) { + Map args = new java.util.HashMap<>(p.getArgs()); + args.put("content", content); + p.setArgs(args); + return p; + } + + /** Two generators emitting different content at a path the adopter hand-owns: neither + * write reaches disk, so each is a warning and the build stays green. */ + @Test + public void handOwnedPathTwoGeneratorsEmit_isRefusedTwice_notACollision() throws IOException { + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + Path mine = Path.of(shared, "Telemetry.java"); + String handWritten = "// mine, no marker\npublic class Telemetry {}\n"; + Files.writeString(mine, handWritten); + GeneratorParam dto = gen(DtoGenerator.class, shared, "Telemetry.java"); + GeneratorParam repository = withContent(gen(RepositoryGenerator.class, shared, "Telemetry.java"), + "/** GENERATED */\npublic record Telemetry() {}\n"); + + runExecution(newBuild(null, Map.of("codegen", configuration(dto, repository))), "codegen", dto, repository); + + assertEquals(handWritten, Files.readString(mine)); + assertFalse(manifest().containsKey("generated-sources/Telemetry.java")); + } + + /** One artifact emitted byte-for-byte by two generators is one file, not a collision. */ + @Test + public void byteIdenticalReEmissionByTwoGenerators_isOneFile() throws IOException { + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, shared, "Status.java"); + GeneratorParam repository = gen(RepositoryGenerator.class, shared, "Status.java"); + MavenProject module = newBuild(null, Map.of( + "codegen-web", configuration(dto), "codegen-data", configuration(repository))); + + runExecution(module, "codegen-web", dto, repository); + runExecution(module, "codegen-data", repository); + + assertEquals(MARKER_BODY, Files.readString(Path.of(shared, "Status.java"))); + } + + // === multi-module: two basedirs, two manifests =============================== + + @Test + public void twoModules_haveIndependentManifests() throws IOException { + TemporaryFolder otherModule = new TemporaryFolder(); + otherModule.create(); + try { + String outDirA = tempFolder.newFolder("gen").getAbsolutePath(); + mojo.executeGenerators(null, build(param(outDirA, "A.java", MARKER_BODY, null))); + + MetaDataGeneratorMojo otherMojo = new MetaDataGeneratorMojo(); + otherMojo.setLoader(new LoaderParam()); + MavenProject otherProject = Mockito.mock(MavenProject.class); + Mockito.when(otherProject.getBasedir()).thenReturn(otherModule.getRoot()); + otherMojo.project = otherProject; + + String outDirB = otherModule.newFolder("gen").getAbsolutePath(); + GeneratorParam pB = param(outDirB, "B.java", MARKER_BODY, null); + otherMojo.setGenerators(List.of(pB)); + otherMojo.executeGenerators(null, otherMojo.buildGenerators(getClass().getClassLoader(), null)); + + assertTrue(manifest().containsKey("gen/A.java")); + assertFalse("module A's manifest must not see module B's file", + manifest().containsKey("gen/B.java")); + + Map otherManifest = HashManifest.load( + otherModule.getRoot().toPath().resolve(".metaobjects").resolve(".gen-state")); + assertTrue(otherManifest.containsKey("gen/B.java")); + assertFalse(otherManifest.containsKey("gen/A.java")); + } finally { + otherModule.delete(); + } + } + + // === stranger file is never touched ========================================== + + @Test + public void strangerFile_neverRecorded_isUntouchedBySweep() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path stray = Path.of(outDir, "HandWritten.java"); + Files.writeString(stray, "// never ours, no marker\npublic class HandWritten {}\n"); + + mojo.executeGenerators(null, build(param(outDir, "Telemetry.java", MARKER_BODY, null))); + // A second full run, still not emitting HandWritten.java — it must never be swept, + // because it was never recorded in the first place. + mojo.executeGenerators(null, build(param(outDir, "Telemetry.java", MARKER_BODY, null))); + + assertTrue(Files.exists(stray)); + assertFalse(manifest().containsKey("gen/HandWritten.java")); + } + + // === handEdits for a file still emitted (not an orphan) ======================= + + @Test + public void stillEmittedFile_handEditsKeep_isRefusedByName() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path file = Path.of(outDir, "Telemetry.java"); + + mojo.executeGenerators(null, build(param(outDir, "Telemetry.java", MARKER_BODY, null))); + String edited = MARKER_BODY + "// mine\n"; + Files.writeString(file, edited); + + String fresh = "/** GENERATED */\npublic class X { int z; }\n"; + mojo.executeGenerators(null, + build(param(outDir, "Telemetry.java", fresh, GeneratorParam.HAND_EDITS_KEEP))); + + assertEquals("handEdits=keep leaves the hand edit in place", edited, Files.readString(file)); + } + + @Test + public void stillEmittedFile_handEditsOverwrite_isReplaced() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path file = Path.of(outDir, "Telemetry.java"); + + mojo.executeGenerators(null, build(param(outDir, "Telemetry.java", MARKER_BODY, null))); + Files.writeString(file, MARKER_BODY + "// mine\n"); + + String fresh = "/** GENERATED */\npublic class X { int z; }\n"; + mojo.executeGenerators(null, + build(param(outDir, "Telemetry.java", fresh, GeneratorParam.HAND_EDITS_OVERWRITE))); + + assertEquals("handEdits=overwrite replaces the hand edit", fresh, Files.readString(file)); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java new file mode 100644 index 000000000..9d37a4259 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java @@ -0,0 +1,321 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.Generator; +import com.metaobjects.generator.GeneratorBase; +import com.metaobjects.generator.util.GeneratedFileWriter; +import com.metaobjects.loader.MetaDataLoader; +import org.apache.maven.model.Build; +import org.apache.maven.model.Plugin; +import org.apache.maven.model.PluginExecution; +import org.apache.maven.plugin.MojoExecution; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.descriptor.MojoDescriptor; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * {@code mvn metaobjects:verify}'s codegen-drift goal, once a project has a committed + * codegen hash manifest: an extra committed file is classified against it (stranger / + * pristine / edited) rather than reported blindly, and the failure message's remedy + * matches what {@code mvn metaobjects:generate} will actually do about it. + */ +public class MetaDataVerifyMojoManifestTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + /** Writes one configurable file through the real write guard, same as + * {@link MetaDataGeneratorMojoManifestTest}'s generator. */ + public static class ConfigurableFileGenerator extends GeneratorBase { + @Override + public void execute(MetaDataLoader loader) { + try { + Path dir = getOutputDir().toPath(); + String filename = getArg("filename", "Foo.java"); + String content = getArg("content", "/** GENERATED */\npublic class Foo {}\n"); + GeneratedFileWriter.write(dir.resolve(filename), content); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private static final String MARKER_BODY = "/** GENERATED */\npublic class X {}\n"; + + private MavenProject mavenProject(Path basedir) { + MavenProject project = Mockito.mock(MavenProject.class); + Mockito.when(project.getBasedir()).thenReturn(basedir.toFile()); + return project; + } + + private GeneratorParam param(String outDir, String filename, String content, String handEdits) { + GeneratorParam p = new GeneratorParam(); + p.setClassname(ConfigurableFileGenerator.class.getName()); + p.setArgs(Map.of( + GeneratorBase.ARG_OUTPUTDIR, outDir, + "filename", filename, + "content", content)); + if (handEdits != null) p.setHandEdits(handEdits); + return p; + } + + /** A loader pointed at the same tiny test metadata source + * {@code MetaDataVerifyMojoTest} uses — these tests never read its content, only need + * {@code createLoader()} to succeed. */ + private static LoaderParam testLoader() { + // Absolute, not "./src/test/resources" like MetaDataVerifyMojoTest — that path is + // resolved against the MOJO's project basedir, which here is a throwaway temp dir, + // not this module's working directory. + String sourceDir = Path.of("src/test/resources").toAbsolutePath().toString(); + return LoaderParam.builder("verify-manifest-test") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir(sourceDir) + .withSource("mojo/verify-test-metadata.json") + .build(); + } + + private void generate(Path basedir, GeneratorParam... params) { + MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); + gen.setLoader(new LoaderParam()); + gen.project = mavenProject(basedir); + gen.setGenerators(List.of(params)); + List impls = gen.buildGenerators(getClass().getClassLoader(), null); + gen.executeGenerators(null, impls); + } + + /** Drives {@link MetaDataVerifyMojo#compareTrees} directly — the regenerate+compare + * internals this test wants, without a loader or a real generator execution. */ + private List compare(Path realDir, Path tempDir, Map hashes, + Path projectRoot, boolean allOverwrite, List editedOrphans) + throws Exception { + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + java.lang.reflect.Method m = MetaDataVerifyMojo.class.getDeclaredMethod( + "compareTrees", MetaDataVerifyMojo.GenTarget.class, Map.class, Path.class, List.class); + m.setAccessible(true); + var targetCtor = MetaDataVerifyMojo.GenTarget.class.getDeclaredConstructor(Path.class, Path.class); + targetCtor.setAccessible(true); + Object target = targetCtor.newInstance(realDir, tempDir); + java.lang.reflect.Field f = MetaDataVerifyMojo.GenTarget.class.getDeclaredField("allOverwrite"); + f.setAccessible(true); + f.setBoolean(target, allOverwrite); + @SuppressWarnings("unchecked") + List result = (List) m.invoke(verify, target, hashes, projectRoot, editedOrphans); + return result; + } + + @Test + public void pristineOrphan_reportsTheDeletesItRemedy() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + generate(basedir, param(outDir, "Telemetry.java", MARKER_BODY, null)); + + Map hashes = com.metaobjects.generator.util.HashManifest.load( + basedir.resolve(".metaobjects").resolve(".gen-state")); + + Path tempDir = Files.createTempDirectory("verify-temp"); // empty: generator did not re-emit this run + List editedOrphans = new java.util.ArrayList<>(); + List drift = compare(Path.of(outDir), tempDir, hashes, basedir, false, editedOrphans); + + assertTrue(drift.toString(), drift.stream().anyMatch(d -> d.contains("stale-in-repo") + && d.contains("'mvn metaobjects:generate' deletes it"))); + assertTrue(editedOrphans.isEmpty()); + } + + @Test + public void strangerFile_isNotDrift() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + generate(basedir, param(outDir, "Telemetry.java", MARKER_BODY, null)); + Files.writeString(Path.of(outDir, "Stray.java"), "// never recorded\n"); + + Map hashes = com.metaobjects.generator.util.HashManifest.load( + basedir.resolve(".metaobjects").resolve(".gen-state")); + Path tempDir = Files.createTempDirectory("verify-temp"); + List editedOrphans = new java.util.ArrayList<>(); + List drift = compare(Path.of(outDir), tempDir, hashes, basedir, false, editedOrphans); + + assertFalse(drift.toString(), drift.stream().anyMatch(d -> d.contains("Stray.java"))); + } + + @Test + public void editedOrphan_handEditsKeep_isReportedAsNotDeletable() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + generate(basedir, param(outDir, "Telemetry.java", MARKER_BODY, null)); + Files.writeString(Path.of(outDir, "Telemetry.java"), MARKER_BODY + "// hand edit\n"); + + Map hashes = com.metaobjects.generator.util.HashManifest.load( + basedir.resolve(".metaobjects").resolve(".gen-state")); + Path tempDir = Files.createTempDirectory("verify-temp"); + List editedOrphans = new java.util.ArrayList<>(); + List drift = compare(Path.of(outDir), tempDir, hashes, basedir, false, editedOrphans); + + assertTrue(drift.toString(), drift.stream().anyMatch(d -> + d.contains("edited by hand: delete it"))); + assertTrue(editedOrphans.stream().anyMatch(p -> p.contains("Telemetry.java"))); + } + + @Test + public void editedOrphan_allOverwrite_reportsTheOverwriteRemedy_andIsNotAnEditedOrphan() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + generate(basedir, param(outDir, "Telemetry.java", MARKER_BODY, GeneratorParam.HAND_EDITS_OVERWRITE)); + Files.writeString(Path.of(outDir, "Telemetry.java"), MARKER_BODY + "// hand edit\n"); + + Map hashes = com.metaobjects.generator.util.HashManifest.load( + basedir.resolve(".metaobjects").resolve(".gen-state")); + Path tempDir = Files.createTempDirectory("verify-temp"); + List editedOrphans = new java.util.ArrayList<>(); + List drift = compare(Path.of(outDir), tempDir, hashes, basedir, true, editedOrphans); + + assertTrue(drift.toString(), drift.stream().anyMatch(d -> + d.contains("will overwrite and delete it, handEdits: overwrite"))); + assertTrue("an overwrite-policy orphan is drift `generate` fixes, not a kept edited orphan", + editedOrphans.isEmpty()); + } + + @Test + public void noManifestAtAll_keepsTheOldBlanketWording() throws Exception { + Path tempDirReal = tempFolder.newFolder("gen").toPath(); + Files.writeString(tempDirReal.resolve("Orphan.java"), "// not produced\n"); + Path tempDir = Files.createTempDirectory("verify-temp"); + + List editedOrphans = new java.util.ArrayList<>(); + List drift = compare(tempDirReal, tempDir, null, tempFolder.getRoot().toPath(), false, editedOrphans); + + assertTrue(drift.toString(), drift.stream().anyMatch(d -> + d.contains("stale-in-repo") && d.contains("(committed; generator no longer produces it)"))); + assertTrue(editedOrphans.isEmpty()); + } + + /** + * End-to-end through {@code verify()}: when EVERY drifted file is a keep-policy edited + * orphan, the failure message must not tell the reader to regenerate (regenerating + * would refuse the same file again) and must append the "will NOT delete them" footer. + */ + @Test + public void verifyFailureMessage_allEditedOrphans_suppressesRegenerateLeadIn() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + // Two generators commit two files; "Extra.java" is then dropped from the verify + // run's generator selection (simulating it no longer being produced) and hand-edited, + // so it is the ONLY drift — "Telemetry.java" still matches exactly. + generate(basedir, + param(outDir, "Telemetry.java", MARKER_BODY, null), + param(outDir, "Extra.java", MARKER_BODY, null)); + Files.writeString(Path.of(outDir, "Extra.java"), MARKER_BODY + "// hand edit\n"); + + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + verify.project = mavenProject(basedir); + verify.setLoader(testLoader()); + verify.setGenerators(List.of(param(outDir, "Telemetry.java", MARKER_BODY, null))); + + try { + verify.execute(); + fail("expected MojoFailureException"); + } catch (MojoFailureException e) { + String msg = e.getMessage(); + assertFalse("must not send the reader in a circle with 'regenerate'", + msg.contains("generated code is stale")); + assertTrue(msg.contains("will NOT delete them")); + } + } + + public static class DtoGenerator extends ConfigurableFileGenerator {} + public static class RepositoryGenerator extends ConfigurableFileGenerator {} + + private GeneratorParam gen(Class generator, String outDir, String filename) { + GeneratorParam p = param(outDir, filename, MARKER_BODY, null); + p.setClassname(generator.getName()); + return p; + } + + /** This module with {@code metaobjects:generate} configured to run {@code generators} + * in one execution, as Maven's effective model presents it. */ + private MavenProject module(Path basedir, GeneratorParam... generators) { + Build build = new Build(); + build.addPlugin(plugin("codegen", configuration(generators))); + MavenProject module = new MavenProject(); + module.setArtifactId("app"); + module.setFile(basedir.resolve("pom.xml").toFile()); + module.getModel().setBuild(build); + return module; + } + + private static Plugin plugin(String executionId, Xpp3Dom configuration) { + Plugin plugin = new Plugin(); + plugin.setGroupId("com.metaobjects"); + plugin.setArtifactId("metaobjects-maven-plugin"); + PluginExecution execution = new PluginExecution(); + execution.setId(executionId); + execution.addGoal("generate"); + execution.setConfiguration(configuration); + plugin.addExecution(execution); + return plugin; + } + + private static Xpp3Dom configuration(GeneratorParam... generators) { + Xpp3Dom configuration = new Xpp3Dom("configuration"); + Xpp3Dom list = new Xpp3Dom("generators"); + for (GeneratorParam p : generators) { + Xpp3Dom generator = new Xpp3Dom("generator"); + Xpp3Dom classname = new Xpp3Dom("classname"); + classname.setValue(p.getClassname()); + generator.addChild(classname); + Xpp3Dom args = new Xpp3Dom("args"); + Xpp3Dom outputDir = new Xpp3Dom(GeneratorBase.ARG_OUTPUTDIR); + outputDir.setValue(p.getArgs().get(GeneratorBase.ARG_OUTPUTDIR)); + args.addChild(outputDir); + generator.addChild(args); + list.addChild(generator); + } + configuration.addChild(list); + return configuration; + } + + private void generateIn(MavenProject module, GeneratorParam... params) { + MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); + gen.setLoader(new LoaderParam()); + gen.project = module; + gen.execution = new MojoExecution(new MojoDescriptor(), "codegen"); + gen.setGenerators(List.of(params)); + gen.executeGenerators(null, gen.buildGenerators(getClass().getClassLoader(), null)); + } + + /** + * Two generators share one output dir; a verify that regenerates only the first must + * not report the second's files as stale, because {@code generate} never deletes a + * configured generator's file for another. + */ + @Test + public void sharedOutputDir_anotherConfiguredGeneratorsFiles_areNotDrift() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, shared, "Dto.java"); + GeneratorParam repository = gen(RepositoryGenerator.class, shared, "Repository.java"); + MavenProject module = module(basedir, dto, repository); + generateIn(module, dto, repository); + + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + verify.project = module; + verify.setLoader(testLoader()); + verify.setGenerators(List.of(dto)); + + verify.execute(); + } +} diff --git a/server/java/metadata-ktx/README.md b/server/java/metadata-ktx/README.md index 9eb3f4792..103cb2303 100644 --- a/server/java/metadata-ktx/README.md +++ b/server/java/metadata-ktx/README.md @@ -56,7 +56,7 @@ val maxLen: String? = field.attrStringOrNull("maxLength") import com.metaobjects.render.InMemoryProvider val prompt = loader.promptTemplateOrNull("acme::demo::WelcomePrompt") -println(prompt?.payloadRef) // "Author" +println(prompt?.payloadRef) // "acme::demo::Author" // Render via the Kotlin builder val out = render { diff --git a/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Relationships.kt b/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Relationships.kt index 79dd7ec7e..14ac3f5d8 100644 --- a/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Relationships.kt +++ b/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Relationships.kt @@ -1,6 +1,5 @@ package com.metaobjects.metadata.ktx -import com.metaobjects.MetaDataNotFoundException import com.metaobjects.`object`.MetaObject import com.metaobjects.relationship.MetaRelationship @@ -24,21 +23,8 @@ val MetaRelationship.cardinalityType: Cardinality? } /** - * Resolve `@objectRef` against this relationship's owning loader; null if absent or unresolved. - * - * Tries the value as an FQN first ([com.metaobjects.loader.MetaDataLoader.getMetaObjectByName]); - * falls back to a short-name scan of the loader's root, mirroring what the validation phase - * does for origin `@via` path resolution (origin/relationship attrs accept bare entity names). + * The object `@objectRef` names; null if absent or unresolved. Reads the target the loader + * bound once (ADR-0059 R7); a short name never matches a packaged object. */ val MetaRelationship.targetObjectOrNull: MetaObject? - get() { - val ref = objectRef ?: return null - val loader = loader ?: return null - try { - return loader.getMetaObjectByName(ref) - } catch (_: MetaDataNotFoundException) { - // fall through to short-name scan - } - return loader.root.getChildren(MetaObject::class.java, false) - .firstOrNull { it.shortName == ref || it.name == ref } - } + get() = com.metaobjects.loader.References.refTarget(this, "objectRef") diff --git a/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/MetaObjectsTest.kt b/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/MetaObjectsTest.kt index 34f03850f..bb9eb091e 100644 --- a/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/MetaObjectsTest.kt +++ b/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/MetaObjectsTest.kt @@ -53,6 +53,6 @@ class MetaObjectsTest { @Test fun `promptTemplateOrNull returns typed when present`() { val p = loader().promptTemplateOrNull("acme::ai::MyPrompt") assertNotNull(p) - assertEquals("Payload", p.payloadRef) + assertEquals("acme::ai::Payload", p.payloadRef) } } diff --git a/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/README.kt b/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/README.kt index 2e9d0e501..178d391af 100644 --- a/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/README.kt +++ b/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/README.kt @@ -22,7 +22,7 @@ class ReadmeSamplesTest { val loader = loadResources("demo", listOf("meta.demo.json")) val prompt = loader.promptTemplateOrNull("acme::demo::WelcomePrompt") assertNotNull(prompt) - assertEquals("Author", prompt.payloadRef) + assertEquals("acme::demo::Author", prompt.payloadRef) } @Test fun `render a prompt via the builder`() { diff --git a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java index 09a1a7d33..bb9b7c1e4 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java +++ b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java @@ -454,15 +454,6 @@ public enum ErrorCode { */ ERR_REGISTRY_SEALED, - /** - * FR-032 (ADR-0032): a ref-bearing attribute in canonical JSON carries a - * relative reference form (a value starting with {@code ::} or {@code ..::}). - * Canonical JSON is the self-contained interchange form and MUST be fully - * qualified — relative forms are a YAML-authoring affordance the desugar - * expands, so they must never survive into canonical JSON. - */ - ERR_RELATIVE_REF_IN_CANONICAL, - /** * Index-key resolution for {@code index.lookup} AND {@code identity.secondary} * (#342) — the key is {@code @fields} XOR {@code @expr}: neither declared, BOTH diff --git a/server/java/metadata/src/main/java/com/metaobjects/MetaData.java b/server/java/metadata/src/main/java/com/metaobjects/MetaData.java index 29c277b18..91866188c 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/MetaData.java +++ b/server/java/metadata/src/main/java/com/metaobjects/MetaData.java @@ -929,6 +929,40 @@ public void setAuthoredSuperRef(String authoredSuperRef) { this.authoredSuperRef = authoredSuperRef; } + // ADR-0059 R5 — the canonical spelling of `extends`, set once by the loader's + // resolveReferences pass (null until then, and when the super never resolved). + /** + * ADR-0059 R10 — the default package of the FILE that declared this node, recorded at + * parse time ({@code ""} when that file declares none; {@code null} for a node built by + * hand). Resolution-only: it is the referrer package of a nested node's {@code extends}. + */ + private String fileDefaultPackage = null; + + public String getFileDefaultPackage() { + return fileDefaultPackage; + } + + public void setFileDefaultPackage(String fileDefaultPackage) { + this.fileDefaultPackage = fileDefaultPackage; + } + + private String canonicalSuperRef = null; + + /** The canonical spelling of this node's {@code extends}, or {@code null} when none was recorded. */ + public String getCanonicalSuperRef() { + return canonicalSuperRef; + } + + /** Records the canonical {@code extends} spelling (ADR-0059 R5). */ + public void setCanonicalSuperRef(String canonicalSuperRef) { + this.canonicalSuperRef = canonicalSuperRef; + } + + /** Drops every cached derivation on this node (ADR-0059: reference values change once, after validation). */ + public void invalidateCaches() { + cache.clear(); + } + /** * Retrieve the MetaObject short name * @return the short name of this metadata without package prefix diff --git a/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java b/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java index eb68b092f..52b042a7f 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java +++ b/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java @@ -237,7 +237,9 @@ public MetaObject findObject(String name) { // ADR-0039: root is never extended — own child lookup is complete. return getChild(n, MetaObject.class, false); } catch (MetaDataNotFoundException e) { - return null; + // ADR-0059 R7: a canonical value (pkg::Name, ::Name) matches by exact + // resolution key; a bare name keeps its old meaning above. + return com.metaobjects.loader.References.lookupCanonical(this, n); } }); } diff --git a/server/java/metadata/src/main/java/com/metaobjects/attr/MetaAttribute.java b/server/java/metadata/src/main/java/com/metaobjects/attr/MetaAttribute.java index 3f652f20b..6748b2f7b 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/attr/MetaAttribute.java +++ b/server/java/metadata/src/main/java/com/metaobjects/attr/MetaAttribute.java @@ -150,6 +150,22 @@ public boolean isArrayType() { + // ADR-0059 R5 — set once by the loader's resolveReferences pass for a reference attribute. + private Object resolvedTarget = null; + private Object authoredValue = null; + + /** The top-level object this reference attribute names (a {@code MetaData}; a list for a list value), or null. */ + public Object getResolvedTarget() { return resolvedTarget; } + + /** Records the bound target (ADR-0059 R5). */ + public void setResolvedTarget(Object resolvedTarget) { this.resolvedTarget = resolvedTarget; } + + /** The value exactly as authored, kept when the loader rewrote it to its canonical spelling; else null. */ + public Object getAuthoredValue() { return authoredValue; } + + /** Records the authored value (ADR-0059 R5). */ + public void setAuthoredValue(Object authoredValue) { this.authoredValue = authoredValue; } + /** * Safe value getter with Optional wrapper */ diff --git a/server/java/metadata/src/main/java/com/metaobjects/attr/StringAttribute.java b/server/java/metadata/src/main/java/com/metaobjects/attr/StringAttribute.java index bc9786e66..d38760f55 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/attr/StringAttribute.java +++ b/server/java/metadata/src/main/java/com/metaobjects/attr/StringAttribute.java @@ -96,6 +96,15 @@ public void setSingleElementArray(String value) { setValueAsObjectDirect(one); } + /** + * Replaces the value with a list of items, kept as given (no comma splitting). Used by the + * loader's reference-resolution pass to write a list of canonical references (ADR-0059 R5). + */ + public void setArrayValue(List items) { + setArray(true); + setValueAsObjectDirect(new ArrayList<>(items)); + } + /** * Direct value setting that bypasses DataConverter when in array mode */ diff --git a/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java b/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java index 734320f1a..0a4dc9d01 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java +++ b/server/java/metadata/src/main/java/com/metaobjects/io/json/CanonicalJsonSerializer.java @@ -124,7 +124,26 @@ private CanonicalJsonSerializer() {} * @return canonical JSON string */ public static String canonicalSerialize(MetaData node) { - JsonElement tree = serializeNode(node, /*effective=*/false, /*parentPackage=*/null); + return canonicalSerialize(node, RefSpelling.CANONICAL); + } + + /** + * How references are written (ADR-0059 R8). + *

    + *
  • {@link #CANONICAL} (default): each reference as the loader resolved it + * ({@code pkg::Name}, {@code ::Name} for a root-level target from a packaged referrer) + * and the document package rule. The output loads back to the same model from any + * source.
  • + *
  • {@link #AUTHORED}: each reference, {@code extends} included, exactly as written, and + * every {@code package} where it was written. Only {@code fmt} uses it, so formatting + * never rewrites what an author wrote.
  • + *
+ */ + public enum RefSpelling { CANONICAL, AUTHORED } + + /** As {@link #canonicalSerialize(MetaData)}, choosing how references are spelled. */ + public static String canonicalSerialize(MetaData node, RefSpelling refs) { + JsonElement tree = serializeNode(node, /*effective=*/false, refs, /*pkgSlot=*/null, /*parentPackage=*/null); // FR-016 / ADR-0018: rewrite legacy @table → kind-matching alias on // source.rdb wrappers so canonical output pins per-kind naming // regardless of which spelling was on input. @@ -143,7 +162,7 @@ public static String canonicalSerialize(MetaData node) { * @return canonical JSON string */ public static String canonicalSerializeEffective(MetaData node) { - JsonElement tree = serializeNode(node, /*effective=*/true, /*parentPackage=*/null); + JsonElement tree = serializeNode(node, /*effective=*/true, RefSpelling.CANONICAL, /*pkgSlot=*/null, /*parentPackage=*/null); rewriteSourceRdbPhysicalNames(tree); return toCanonicalString(tree); } @@ -250,9 +269,10 @@ private static void rewriteSourceRdbPhysicalNames(JsonElement value) { * if there is no parent (i.e. this is the tree root). * Used to suppress redundant {@code package} keys. */ - private static JsonElement serializeNode(MetaData node, boolean effective, String parentPackage) { + private static JsonElement serializeNode(MetaData node, boolean effective, RefSpelling refs, + String pkgSlot, String parentPackage) { String fusedKey = node.getType() + TYPE_SUBTYPE_SEPARATOR + node.getSubType(); - JsonObject body = serializeBody(node, effective, parentPackage); + JsonObject body = serializeBody(node, effective, refs, pkgSlot, parentPackage); JsonObject wrapper = new JsonObject(); wrapper.add(fusedKey, body); @@ -273,7 +293,8 @@ private static JsonElement serializeNode(MetaData node, boolean effective, Strin *
  • {@code children} — structural child nodes (recursive); non-empty only
  • * */ - private static JsonObject serializeBody(MetaData node, boolean effective, String parentPackage) { + private static JsonObject serializeBody(MetaData node, boolean effective, RefSpelling refs, + String pkgSlot, String parentPackage) { JsonObject body = new JsonObject(); // 1. name — omit empty names, auto-generated names, and root-node names. @@ -306,8 +327,16 @@ private static JsonObject serializeBody(MetaData node, boolean effective, String // package). Effective package is recovered structurally on reload from // the parent context, so suppressing it here is byte-correct and // round-trips. - String nodePackage = resolveNodePackage(node); - if (nodePackage != null && !nodePackage.isEmpty()) { + // ADR-0059 R8 — in canonical mode the document root assigns every named top-level node's + // package by the document rule (see documentPackages); nodes below keep their own. + DocPackages doc = (node instanceof MetaRoot && refs == RefSpelling.CANONICAL) + ? documentPackages((MetaRoot) node) : null; + String nodePackage = doc != null ? doc.rootPkg() : resolveNodePackage(node); + if (pkgSlot != null) { + // Per-node mode: the package the document rule assigns this top-level node. + if (!pkgSlot.isEmpty()) body.addProperty(KEY_PACKAGE, pkgSlot); + nodePackage = pkgSlot; + } else if (nodePackage != null && !nodePackage.isEmpty()) { boolean isRoot = node instanceof MetaRoot; // Cross-port byte-parity: when the author explicitly wrote a // `package` key on this node's body, round-trip it on the way out. @@ -342,10 +371,16 @@ private static JsonObject serializeBody(MetaData node, boolean effective, String // `meta fmt` formats a file standalone, deferring super resolution on // purpose, so an authored cross-file `extends` reaches here with // hasSuperData() false and must still round-trip). + // ADR-0059 R8: after the loader's resolveReferences pass the canonical spelling is recorded + // beside the authored one; canonical mode writes it, authored mode (fmt) writes what was + // authored. String authoredSuperRef = node.getAuthoredSuperRef(); - String superRef = (authoredSuperRef != null && !authoredSuperRef.isEmpty()) - ? authoredSuperRef - : (node.hasSuperData() ? node.getSuperData().getName() : null); + String canonicalSuperRef = node.getCanonicalSuperRef(); + String superRef = (refs == RefSpelling.CANONICAL && canonicalSuperRef != null && !canonicalSuperRef.isEmpty()) + ? canonicalSuperRef + : (authoredSuperRef != null && !authoredSuperRef.isEmpty()) + ? authoredSuperRef + : (node.hasSuperData() ? node.getSuperData().getName() : null); if (superRef != null && !superRef.isEmpty()) { body.addProperty(KEY_EXTENDS, superRef); } @@ -395,7 +430,10 @@ private static JsonObject serializeBody(MetaData node, boolean effective, String if (ATTR_NAME_IS_ABSTRACT.equals(attrName) || ATTR_NAME_IS_ARRAY.equals(attrName)) { continue; } - JsonElement attrValue = attrValueToJson(attr); + // ADR-0039: own — the authored value lives on the declaring attr node. + JsonElement attrValue = (refs == RefSpelling.AUTHORED && !effective && attr.getAuthoredValue() != null) + ? authoredAttrToJson(attr) + : attrValueToJson(attr); sortedAttrs.put(ATTR_PREFIX + attrName, attrValue); } for (Map.Entry e : sortedAttrs.entrySet()) { @@ -409,7 +447,12 @@ private static JsonObject serializeBody(MetaData node, boolean effective, String if (!structuralChildren.isEmpty()) { JsonArray childArray = new JsonArray(); for (MetaData child : structuralChildren) { - childArray.add(serializeNode(child, effective, childParentPackage)); + String childSlot = null; + if (doc != null && doc.perNode()) { + childSlot = (child.getShortName() != null && !child.getShortName().isEmpty()) + ? topLevelPackage(child) : ""; + } + childArray.add(serializeNode(child, effective, refs, childSlot, childParentPackage)); } body.add(KEY_CHILDREN, childArray); } @@ -510,6 +553,49 @@ private static String canonicalMatchName(MetaData node) { // Helpers — package resolution // --------------------------------------------------------------------------- + /** The package a top-level node carries: its own (name-derived) package, "" for root-level. */ + private static String topLevelPackage(MetaData node) { + String p = node.getPackage(); + return p == null ? "" : p; + } + + /** ADR-0059 R8 — {@code rootPkg} null = write none; {@code perNode} = each named top-level node carries its own. */ + private record DocPackages(String rootPkg, boolean perNode) {} + + /** + * ADR-0059 R8 document package rule, for a {@code metadata.root} in canonical mode. Every + * named top-level node in one non-empty package P: the root carries P (a node keeps a + * package it carries itself, as before) unless the root names none and every node names P + * itself. Otherwise the root carries none and each packaged top-level node carries its own. + * A root with no named top-level node keeps its own package. + */ + private static DocPackages documentPackages(MetaRoot root) { + // ADR-0039: own — the serializer writes the declared layer; the root has no super. + List named = new ArrayList<>(); + for (MetaData c : root.getChildren()) { + if (c instanceof MetaAttribute) continue; + String sn = c.getShortName(); + if (sn != null && !sn.isEmpty()) named.add(c); + } + if (named.isEmpty()) return new DocPackages(resolveNodePackage(root), false); + java.util.Set pkgs = new java.util.LinkedHashSet<>(); + for (MetaData c : named) pkgs.add(topLevelPackage(c)); + if (pkgs.size() == 1) { + String only = pkgs.iterator().next(); + if (!only.isEmpty()) { + String rootOwn = resolveNodePackage(root); + boolean selfNamed = (rootOwn == null || rootOwn.isEmpty()); + if (selfNamed) { + for (MetaData c : named) { + if (!c.isPackageAuthored() || !only.equals(topLevelPackage(c))) { selfNamed = false; break; } + } + } + return new DocPackages(selfNamed ? null : only, false); + } + } + return new DocPackages(null, true); + } + /** * Returns the "canonical package" for a node. * @@ -726,6 +812,18 @@ private static JsonElement attrValueToJson(MetaAttribute attr) { return primitiveToJson(value); } + /** An attribute's authored (pre-canonicalization) value: a string, or a list of strings. */ + @SuppressWarnings("rawtypes") + private static JsonElement authoredAttrToJson(MetaAttribute attr) { + Object authored = attr.getAuthoredValue(); + if (authored instanceof List) { + JsonArray arr = new JsonArray(); + for (Object o : (List) authored) arr.add(String.valueOf(o)); + return arr; + } + return new JsonPrimitive(String.valueOf(authored)); + } + /** Converts a non-null primitive-ish value to a JsonPrimitive. */ private static JsonElement primitiveToJson(Object value) { if (value instanceof Boolean) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/DirectorySource.java b/server/java/metadata/src/main/java/com/metaobjects/loader/DirectorySource.java index 42b90d3cb..598b5a24c 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/DirectorySource.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/DirectorySource.java @@ -114,7 +114,7 @@ public Options getOptions() { /** * Expands this directory into a sorted stream of {@link FileSource}. - * Sorted by full path (ordinal) for deterministic ordering across runs. + * Sorted by file name (basename) for deterministic ordering across runs, as the TypeScript and Python ports do. * *

    Follows symlinked directories — including when {@code directory} itself * is a symlink — matching TypeScript ({@code stat}, not {@code lstat}) and C# diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java index 0b28f7e7b..bf67aeebb 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java @@ -381,6 +381,15 @@ private void resolvePendingNode(PendingExtends p, // SuperResolve.cs. The parser's getSuperMetaData never resolves dotted refs // (no top-level node has a dotted name), so every dotted ref arrives here // via the pending queue — single resolution site. + if (overDrops(p)) { + throw new com.metaobjects.MetaDataException( + "the extends ref [" + p.superName + "] on [" + p.child.getShortName() + + "] climbs above the root package — each '..::' drops one segment of the declaring package" + + " in file [" + p.filename + "]", + com.metaobjects.ErrorCode.ERR_BAD_ATTR_VALUE, + com.metaobjects.source.ResolvedSource.from( + p.child.getSource(), p.child.getShortName(), p.superName)); + } if (isChildTargetingRef(p.superName)) { // #188: wire the owner's OWN chain first so its effective children // include inherited members before resolveChildTargetingRef reads them. @@ -418,25 +427,22 @@ private void resolvePendingNode(PendingExtends p, * (the caller throws {@code ERR_UNRESOLVED_SUPER}). Extracted verbatim from the * former inline {@link #resolvePendingExtends} loop body. */ - private MetaData resolveTopLevelSuper(PendingExtends p) { - MetaData superData = null; + /** ADR-0059 R2: a {@code ..::} dropping more package segments than the declaring node has. */ + private boolean overDrops(PendingExtends p) { try { - String sn = p.superName; - String pkg = p.packageName == null ? "" : p.packageName; - if (sn.indexOf(PKG_SEPARATOR) < 0 && !pkg.isEmpty()) { - superData = getChildOfType(p.typeName, pkg + PKG_SEPARATOR + sn); - } - } catch (com.metaobjects.MetaDataNotFoundException ignore) { - // fall through to FQN lookup - } - if (superData == null) { - try { - superData = getChildOfType(p.typeName, p.superName); - } catch (com.metaobjects.MetaDataNotFoundException ignore) { - // still unresolved - } + References.ownerCandidateKeys(References.splitChildTail(p.superName).owner(), + References.extendsPackageOf(p.child)); + return false; + } catch (References.OverDropException e) { + return true; } - return superData; + } + + private MetaData resolveTopLevelSuper(PendingExtends p) { + // ADR-0059 R2/R3 — the same candidate keys every reference uses, matched EXACTLY against + // top-level nodes of the referrer's type; a bare ref never binds another package. + return References.resolveOwner(root, p.superName, References.extendsPackageOf(p.child), + p.typeName).node(); } /** @@ -496,44 +502,10 @@ private MetaData resolveOwnerObject(PendingExtends p) { } } String ownerRef = p.superName.substring(0, segStart) + parts[0]; - - MetaData owner = null; - String pkg = p.packageName == null ? "" : p.packageName; - if (ownerRef.indexOf(PKG_SEPARATOR) < 0 && !pkg.isEmpty()) { - try { - owner = getChildOfType(com.metaobjects.object.MetaObject.TYPE_OBJECT, - pkg + PKG_SEPARATOR + ownerRef); - } catch (com.metaobjects.MetaDataNotFoundException ignore) { - // fall through - } - } - if (owner == null && ownerRef.indexOf(PKG_SEPARATOR) < 0) { - // NESTED referrers (a field/identity inside an object) are queued with an - // EMPTY packageName (the parser does not fold the object's package onto - // nested children) — derive the context package from the referrer's - // ancestry instead: the enclosing object's registered name carries the - // parse-time-folded package (e.g. "fitness::ProgramView" → "fitness"). - // Mirrors TS resolveDeferredSupers' node.fileDefaultPackage fallback. - for (MetaData ancestor = p.child.getParent(); ancestor != null; ancestor = ancestor.getParent()) { - String ancestorPkg = ancestor.getPackage(); - if (ancestorPkg == null || ancestorPkg.isEmpty()) continue; - try { - owner = getChildOfType(com.metaobjects.object.MetaObject.TYPE_OBJECT, - ancestorPkg + PKG_SEPARATOR + ownerRef); - break; - } catch (com.metaobjects.MetaDataNotFoundException ignore) { - // keep walking up - } - } - } - if (owner == null) { - try { - owner = getChildOfType(com.metaobjects.object.MetaObject.TYPE_OBJECT, ownerRef); - } catch (com.metaobjects.MetaDataNotFoundException ignore) { - return null; - } - } - return owner; + // ADR-0059 R2/R3 — the owner resolves by the one resolver, in the declaring node's + // package (a nested referrer takes its enclosing top-level node's). + return References.resolveOwner(root, ownerRef, References.extendsPackageOf(p.child), + com.metaobjects.object.MetaObject.TYPE_OBJECT).node(); } /** diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/References.java b/server/java/metadata/src/main/java/com/metaobjects/loader/References.java new file mode 100644 index 000000000..0cc81bbcc --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/References.java @@ -0,0 +1,363 @@ +package com.metaobjects.loader; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaRoot; +import com.metaobjects.attr.MetaAttribute; +import com.metaobjects.object.MetaObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * ADR-0059 — the one place a reference gets its meaning, and the one pass that resolves + * every reference once, against the built tree. + * + *

    Sources do not interpret references: YAML and canonical JSON hand the loader every + * reference string exactly as authored. This class gives each form its meaning:

    + *
      + *
    • {@code ::Rest} — root-absolute: the node whose resolution key is exactly + * {@code Rest}. No package-local retry.
    • + *
    • {@code ..::Rest} — parent-relative: drop one trailing segment of the referrer's + * package per {@code ..::}, then the exact key. Over-drop is {@link OverDropException}.
    • + *
    • {@code pkg::Name} — exact.
    • + *
    • {@code Name} — exact {@code ::Name}, else exact root-level {@code Name}.
    • + *
    + *

    A FR-024 dotted child tail starts at the first {@code .} after the owner's last + * {@code ::}; only the owner is resolved by form. Mirrors the TypeScript + * {@code naming-refs.ts} and {@code loader/resolve-references.ts}.

    + */ +public final class References { + + private References() {} + + private static final String SEP = MetaData.PKG_SEPARATOR; + private static final String PARENT_PREFIX = ".." + SEP; + + /** ADR-0059 R4 — the inline attribute names whose value is an object reference. */ + public static final Set REF_BEARING_ATTR_NAMES = Set.of( + "objectRef", "through", "references", "from", "via", "of", + "parameterRef", "payloadRef", "responseRef", "spine"); + + /** A parent-relative reference with more {@code ..::} than its referrer's package has segments. */ + public static final class OverDropException extends RuntimeException { + private static final long serialVersionUID = 1L; + public OverDropException(String message) { super(message); } + } + + /** The outcome of an owner resolution: a node, an over-drop message, or neither. */ + public record Resolution(MetaData node, String error) { + static final Resolution NONE = new Resolution(null, null); + } + + /** An owner and its dotted child tail ({@code ""} or a string starting with {@code .}). */ + public record Split(String owner, String tail) {} + + /** Split a reference into its owner and any dotted child tail. */ + public static Split splitChildTail(String raw) { + int lastSep = raw.lastIndexOf(SEP); + int segStart = lastSep == -1 ? 0 : lastSep + SEP.length(); + int dot = raw.indexOf('.', segStart); + if (dot == -1) return new Split(raw, ""); + return new Split(raw.substring(0, dot), raw.substring(dot)); + } + + /** Resolution keys an OWNER may bind, in order to try (R2). Throws {@link OverDropException}. */ + public static List ownerCandidateKeys(String owner, String referrerPkg) { + String pkg = referrerPkg == null ? "" : referrerPkg; + if (owner.startsWith(SEP)) return List.of(owner.substring(SEP.length())); + if (owner.startsWith(PARENT_PREFIX)) { + String rest = owner; + int levels = 0; + while (rest.startsWith(PARENT_PREFIX)) { + levels++; + rest = rest.substring(PARENT_PREFIX.length()); + } + String[] parts = pkg.isEmpty() ? new String[0] : pkg.split(SEP); + if (levels > parts.length) { + throw new OverDropException("Relative reference '" + owner + "' over-drops: " + levels + + " parent level(s) but the package context '" + pkg + "' has only " + + parts.length + " segment(s)"); + } + StringBuilder reduced = new StringBuilder(); + for (int i = 0; i < parts.length - levels; i++) { + if (reduced.length() > 0) reduced.append(SEP); + reduced.append(parts[i]); + } + return List.of(reduced.length() > 0 ? reduced + SEP + rest : rest); + } + if (owner.contains(SEP)) return List.of(owner); + return pkg.isEmpty() ? List.of(owner) : List.of(pkg + SEP + owner, owner); + } + + /** Bind an owner among {@code candidates} by exact resolution key. */ + private static Resolution bind(Iterable candidates, String owner, String referrerPkg) { + List keys; + try { + keys = ownerCandidateKeys(owner, referrerPkg); + } catch (OverDropException e) { + return new Resolution(null, e.getMessage()); + } + for (String key : keys) { + for (MetaData c : candidates) { + if (key.equals(c.getName())) return new Resolution(c, null); + } + } + return Resolution.NONE; + } + + /** The root's direct children of {@code type} ({@code null} = any). ADR-0039: the root has no super. */ + private static List topLevelOfType(MetaData root, String type) { + List out = new ArrayList<>(); + for (MetaData c : root.getChildren(MetaData.class, false)) { + if (c instanceof MetaAttribute) continue; + if (type == null || type.equals(c.getType())) out.add(c); + } + return out; + } + + /** Resolve an OBJECT reference (no child tail) under R2. */ + public static Resolution resolveObjectRef(MetaData root, String ref, String referrerPkg) { + return bind(topLevelOfType(root, MetaObject.TYPE_OBJECT), ref, referrerPkg); + } + + /** Resolve an owner among the root's top-level nodes of {@code type} (any type when null). */ + public static Resolution resolveOwner(MetaData root, String owner, String referrerPkg, String type) { + return bind(topLevelOfType(root, type), owner, referrerPkg); + } + + /** + * R7 — resolve a CANONICAL reference (owner plus optional dotted tail) with no referrer: + * strip one leading {@code ::}, match a top-level object's resolution key exactly. The tail + * is ignored (callers wanting the child walk it). Never matches on a short name. Returns + * {@code null} when nothing matches. + */ + public static MetaObject lookupCanonical(MetaData root, String value) { + if (value == null || value.isEmpty()) return null; + String owner = splitChildTail(value).owner(); + String exact = owner.startsWith(SEP) ? owner : SEP + owner; + MetaData node = resolveObjectRef(root, exact, "").node(); + return node instanceof MetaObject ? (MetaObject) node : null; + } + + /** R6 — the canonical spelling of a resolved owner, as written from a referrer in {@code referrerPkg}. */ + public static String spellRef(MetaData target, String referrerPkg, String tail) { + String key = target.getName(); + boolean rootLevel = !key.contains(SEP); + String ref = (rootLevel && referrerPkg != null && !referrerPkg.isEmpty()) ? SEP + key : key; + return ref + (tail == null ? "" : tail); + } + + /** The top-level node (a direct child of the root) that physically contains {@code node}. */ + public static MetaData topLevelNodeOf(MetaData node) { + MetaData current = node; + while (current.getParent() != null && current.getParent().getParent() != null) { + current = current.getParent(); + } + return current; + } + + /** Effective package of a node: that of its top-level ancestor ({@code ""} for root-level). */ + public static String effectivePackageOf(MetaData node) { + MetaData top = topLevelNodeOf(node); + if (top instanceof MetaRoot) return ""; + String pkg = top.getPackage(); + return pkg == null ? "" : pkg; + } + + /** + * R3/R10 — the package an {@code extends} on {@code node} resolves in: a top-level node's + * own package; a nested node's own FILE's default package (so a nested node merged in from + * an overlay file resolves in that file's package, not the overlaid node's). + */ + public static String extendsPackageOf(MetaData node) { + if (node.getParent() == null || node.getParent().getParent() == null) { + return effectivePackageOf(node); + } + String file = node.getFileDefaultPackage(); + return file != null ? file : effectivePackageOf(node); + } + + /** + * R3 default — the package a reference attribute resolves in: the effective package of the + * top-level node that PHYSICALLY declares the attribute. An inherited attribute resolves + * where it was written. + */ + public static String referrerPackageFor(MetaData node, String attrName) { + MetaData declarer = node; + java.util.Set seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + // ADR-0039: own — locating the layer that physically declares the attr. + while (declarer != null && !declarer.hasMetaAttr(attrName, false) && seen.add(declarer)) { + declarer = declarer.hasSuperData() ? declarer.getSuperData() : null; + } + return effectivePackageOf(declarer != null ? declarer : node); + } + + /** + * R7 — the object a reference attribute on {@code node} names: the owner the loader bound + * once, read RESOLVING so an inherited attribute works. For a dotted ref it is the owner + * object. A tree built without the loader has no recorded target, so the value is resolved + * from the package of the node that declared it. {@code null} when absent or unresolvable. + */ + public static MetaObject refTarget(MetaData node, String attrName) { + java.util.Set seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + for (MetaData n = node; n != null && seen.add(n); n = n.hasSuperData() ? n.getSuperData() : null) { + // ADR-0039: own — locating the layer that declares the attr, walking the super chain. + if (!n.hasMetaAttr(attrName, false)) continue; + MetaAttribute attr = n.getMetaAttr(attrName, false); + Object recorded = attr.getResolvedTarget(); + if (recorded instanceof MetaObject) return (MetaObject) recorded; + if (recorded != null) return null; + Object value = attr.getValue(); + if (!(value instanceof String) || ((String) value).isEmpty()) return null; + MetaData root = topLevelRoot(node); + if (root == null) return null; + MetaData found = resolveObjectRef(root, splitChildTail((String) value).owner(), + effectivePackageOf(n)).node(); + return found instanceof MetaObject ? (MetaObject) found : null; + } + return null; + } + + public static MetaData topLevelRoot(MetaData node) { + MetaData c = node; + while (c.getParent() != null) c = c.getParent(); + return c instanceof MetaRoot ? c : null; + } + + // --------------------------------------------------------------------------------- + // R5 — the pass + // --------------------------------------------------------------------------------- + + /** + * Resolve every own reference attribute (R4) and every {@code extends} in the tree, record + * the targets, and rewrite resolved values to their canonical spelling. Runs once, after + * the last validation pass. A reference that does not resolve is left as written. + */ + public static void resolveReferences(MetaRoot root) { + visit(root, root); + flushCaches(root); + } + + private static void visit(MetaRoot root, MetaData node) { + if (node != root) { + resolveExtends(root, node); + String declPkg = effectivePackageOf(node); + // ADR-0039: own — this pass rewrites the declared-here layer; an inherited attribute + // is resolved once, on the node that declares it. + for (MetaAttribute attr : new ArrayList<>(node.getMetaAttrs(false))) { + if (REF_BEARING_ATTR_NAMES.contains(attr.getName())) resolveAttr(root, node, attr, declPkg); + } + } + // ADR-0039: own — structural walk over the physical declaration tree. + for (MetaData child : new ArrayList<>(node.getChildren(MetaData.class, false))) { + if (child instanceof MetaAttribute) continue; + visit(root, child); + } + } + + private static void flushCaches(MetaData node) { + node.invalidateCaches(); + for (MetaData child : node.getChildren(MetaData.class, false)) flushCaches(child); + } + + /** R3 — the package an own reference attribute of {@code node} resolves in. */ + private static String referrerPackage(MetaRoot root, MetaData node, String attr, String declPkg) { + // @of on a dimension that walks @via: the package of the entity the walk reaches. + if (com.metaobjects.reporting.ReportingConstants.TYPE_DIMENSION.equals(node.getType()) + && com.metaobjects.reporting.ReportingConstants.ATTR_OF.equals(attr)) { + String via = stringAttr(node, com.metaobjects.reporting.ReportingConstants.ATTR_VIA); + MetaData declaring = node.getParent(); + if (via != null && !via.isEmpty() && declaring != null) { + MetaData terminal = ReportingValidation.dimensionViaTerminal(root, declaring, via); + return terminal != null ? effectivePackageOf(terminal) : declPkg; + } + } + // @spine on a report: the package of the @from entity. + if (MetaObject.TYPE_OBJECT.equals(node.getType()) && MetaObject.SUBTYPE_REPORT.equals(node.getSubType()) + && MetaObject.ATTR_REPORT_SPINE.equals(attr)) { + String from = stringAttr(node, "from"); + if (from != null && !from.isEmpty()) { + MetaData fromNode = resolveObjectRef(root, splitChildTail(from).owner(), declPkg).node(); + if (fromNode != null) return effectivePackageOf(fromNode); + } + } + return declPkg; + } + + private static String stringAttr(MetaData node, String name) { + if (!node.hasMetaAttr(name)) return null; + Object v = node.getMetaAttr(name).getValue(); + return v instanceof String ? (String) v : null; + } + + /** One resolved reference: its owner object and the canonical spelling. */ + private record Resolved(MetaData target, String canonical) {} + + private static Resolved resolveOne(MetaRoot root, String value, String refPkg, String declPkg) { + Split s = splitChildTail(value); + MetaData target = resolveObjectRef(root, s.owner(), refPkg).node(); + if (target == null) return null; + return new Resolved(target, spellRef(target, !refPkg.isEmpty() ? refPkg : declPkg, s.tail())); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void resolveAttr(MetaRoot root, MetaData node, MetaAttribute attr, String declPkg) { + Object value = attr.getValue(); + String refPkg = referrerPackage(root, node, attr.getName(), declPkg); + if (value instanceof String) { + String v = (String) value; + if (v.isEmpty()) return; + Resolved r = resolveOne(root, v, refPkg, declPkg); + if (r == null) return; + attr.setResolvedTarget(r.target()); + if (!r.canonical().equals(v)) { + if (attr.getAuthoredValue() == null) attr.setAuthoredValue(v); + attr.setValueAsObject(r.canonical()); + } + return; + } + if (value instanceof List && !((List) value).isEmpty() + && ((List) value).stream().allMatch(x -> x instanceof String)) { + List items = (List) value; + List resolved = new ArrayList<>(); + boolean any = false; + for (String v : items) { + Resolved r = v.isEmpty() ? null : resolveOne(root, v, refPkg, declPkg); + if (r != null) any = true; + resolved.add(r); + } + if (!any) return; + List targets = new ArrayList<>(); + List canonical = new ArrayList<>(); + boolean changed = false; + for (int i = 0; i < items.size(); i++) { + Resolved r = resolved.get(i); + targets.add(r == null ? null : r.target()); + String c = r == null ? items.get(i) : r.canonical(); + if (!c.equals(items.get(i))) changed = true; + canonical.add(c); + } + attr.setResolvedTarget(targets); + if (changed) { + if (attr.getAuthoredValue() == null) attr.setAuthoredValue(new ArrayList<>(items)); + if (attr instanceof com.metaobjects.attr.StringAttribute) { + ((com.metaobjects.attr.StringAttribute) attr).setArrayValue(canonical); + } else { + attr.setValueAsObject(canonical); + } + } + } + } + + /** Canonical {@code extends}: the owner spelled from the declaring node's package, tail kept. */ + private static void resolveExtends(MetaRoot root, MetaData node) { + String ref = node.getAuthoredSuperRef(); + if (ref == null || ref.isEmpty() || !node.hasSuperData()) return; + String pkg = extendsPackageOf(node); + Split s = splitChildTail(ref); + MetaData ownerNode = resolveOwner(root, s.owner(), pkg, null).node(); + if (ownerNode == null) return; + node.setCanonicalSuperRef(spellRef(ownerNode, pkg, s.tail())); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java index 0aefcf92e..2866b0e85 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ReportingValidation.java @@ -407,6 +407,16 @@ private static void checkEntityMembers(MetaRoot root, MetaObject entity, boolean } } + /** + * ADR-0059 R3 — the entity a dimension's {@code @via} reaches, walked from {@code declaring} + * with D2's rules, or {@code null} when the walk fails (D2 reports why). A dimension's + * {@code @of} resolves in this entity's package, so the loader's {@code resolveReferences} + * pass reuses the walk rather than restating it. + */ + static MetaData dimensionViaTerminal(MetaRoot root, MetaData declaring, String via) { + return walkToOneVia(root, declaring, via, m -> { }, d2Walk(declaring)); + } + private static void checkDimension(MemberCtx ctx, MetaDimension dim) { Consumer err = message -> ctx.sink().push(dim, ErrorCode.ERR_INVALID_DIMENSION, ctx.label(), ": " + message, ctx.suffix()); @@ -559,10 +569,11 @@ private static MetaData walkToOneVia(MetaRoot root, MetaData host, String via, C + "', which is not to-one. " + walk.toOneReason()); return null; } - String targetRef = stringAttr(hop, isReference ? MetaIdentity.ATTR_REFERENCES : MetaRelationship.ATTR_OBJECT_REF); - // ADR-0042 — a hop target resolves in the package of the entity declaring the hop. + String hopAttr = isReference ? MetaIdentity.ATTR_REFERENCES : MetaRelationship.ATTR_OBJECT_REF; + String targetRef = stringAttr(hop, hopAttr); + // ADR-0059 R3 — a hop target resolves in the package of the node that declares the hop's attribute. MetaData target = targetRef == null ? null - : ValidationPhase.resolveRootObject(root, targetRef, pkgOf(current)); + : ValidationPhase.resolveRootObject(root, targetRef, References.referrerPackageFor(hop, hopAttr)); if (target == null) { err.accept(named + " hop '" + hopName + "' on '" + nameOf.apply(current) + "' targets no object."); return null; diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index ede515449..018f7a11a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -312,6 +312,11 @@ public static void run(MetaRoot root, MetaDataLoader loader) { } throw findings.get(findings.size() - 1); } + + // ADR-0059 R5 — after the last validation pass, resolve every reference once: record the + // bound target and rewrite the value to its canonical spelling. The passes above resolved + // the AUTHORED strings, so their codes and order are unchanged. + References.resolveReferences(root); } /** Run one validation pass, catching its finding (if any) into {@code collected} so the @@ -1898,7 +1903,7 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj, // package is self, but an FQN "other::Widget" (a different same-short-name entity) is // NOT (comparing stripped short names would misclassify it). boolean isSelfJoin = objectRef != null - && resolveRootObject(root, objectRef, referrerPkg) == declaringEntity; + && resolveRootObject(root, objectRef, References.referrerPackageFor(rel, MetaRelationship.ATTR_OBJECT_REF)) == declaringEntity; if (symmetric && !isSelfJoin) { errors.add(new MetaDataException( ErrorMessageConstants.ERR_BAD_ATTR_VALUE @@ -1913,7 +1918,8 @@ private static void validateRelationshipM2MNode(MetaRoot root, MetaObject obj, // Rule (c): @through must name an entity declaring exactly two identity.reference children. // ADR-0042: @through resolves package-locally when bare, exactly when FQN — a bare // cross-package @through no longer binds a junction in another package. - MetaObject junction = resolveRootObject(root, through, referrerPkg); + MetaObject junction = resolveRootObject(root, through, + References.referrerPackageFor(rel, MetaRelationship.ATTR_THROUGH)); if (junction == null) { errors.add(new MetaDataException( ErrorMessageConstants.ERR_INVALID_RELATIONSHIP @@ -2012,8 +2018,10 @@ static List validateOneSideReferenceResolution(MetaRoot root) String objectRef = rel.getObjectRef(); if (objectRef == null || objectRef.isEmpty()) continue; + MetaObject objectRefNode = References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF); + if (objectRefNode == null) continue; List candidates = - RelationshipReferences.referenceCandidatesFor(obj, objectRef); + RelationshipReferences.referenceCandidatesFor(obj, objectRefNode); String sourceRefField = rel.getSourceRefField(); String declared = (sourceRefField != null && !sourceRefField.isEmpty()) @@ -2047,7 +2055,7 @@ static List validateOneSideReferenceResolution(MetaRoot root) // (ladder step 3) decides. if (candidates.size() <= 1) continue; ReferenceIdentity resolved = RelationshipReferences.resolveRelationshipReference( - obj, rel.getShortName(), objectRef); + obj, rel.getShortName(), objectRefNode, null); if (resolved != null) continue; errors.add(new MetaDataException( @@ -2184,7 +2192,8 @@ private static boolean isJunctionShapeErrorOwnedByRuleD(MetaRelationship rel, Me MetaObject declaring = (relParent instanceof MetaObject) ? (MetaObject) relParent : null; String referrerPkg = (declaring != null && declaring.getPackage() != null) ? declaring.getPackage() : ""; - MetaObject junction = resolveRootObject(root, through, referrerPkg); + MetaObject junction = resolveRootObject(root, through, + References.referrerPackageFor(rel, MetaRelationship.ATTR_THROUGH)); if (junction == null) return true; if (!MetaObject.SUBTYPE_ENTITY.equals(junction.getSubType())) return true; if (countJunctionReferences(junction) != 2) return true; @@ -2683,7 +2692,7 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, // field, so this branch enforces it itself. Without it an @objectRef to an entity // silently rolls up the FULL entity — the #270 shape, this time baked into DDL. // ADR-0042 — a bare @objectRef resolves in the DECLARING owner's package. - String refPkg = obj.getPackage() == null ? "" : obj.getPackage(); + String refPkg = References.referrerPackageFor(field, "objectRef"); MetaObject refTarget = resolveRootObject(root, objectRef, refPkg); if (refTarget != null && !MetaObject.SUBTYPE_VALUE.equals(refTarget.getSubType())) { throw new MetaDataException( @@ -3834,8 +3843,8 @@ private static void validateTemplateNode(MetaRoot root, MetaTemplate template) { // an FQN resolves exactly. No bare-tail cross-package fallback. // #210 — a template-level payload target widened to object.value OR a // sourceless object.projection (a SOURCED projection stays illegal). - String referrerPkg = template.getPackage() == null ? "" : template.getPackage(); - MetaObject payloadVo = resolveRootObject(root, payloadRef, referrerPkg); + MetaObject payloadVo = resolveRootObject(root, payloadRef, + References.referrerPackageFor(template, "payloadRef")); if (payloadVo == null || !isLegalPayloadTarget(payloadVo)) { // FR5d — @payloadRef is a reference; emit format=resolved with // referrer=template bare (short) name to match TS/C#/Python (the @@ -3862,7 +3871,8 @@ private static void validateTemplateNode(MetaRoot root, MetaTemplate template) { if (template instanceof PromptTemplate promptTmpl) { String responseRef = promptTmpl.getResponseRef(); if (responseRef != null && !responseRef.isEmpty()) { - MetaObject responseVo = resolveRootObject(root, responseRef, referrerPkg); + MetaObject responseVo = resolveRootObject(root, responseRef, + References.referrerPackageFor(template, "responseRef")); if (responseVo == null || !isLegalPayloadTarget(responseVo)) { throw new MetaDataException( ErrorMessageConstants.ERR_INVALID_TEMPLATE @@ -4147,7 +4157,8 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, } // ADR-0042 — the hop target (@objectRef/@references) resolves in the package of the // entity that DECLARES the relationship/reference, i.e. currentObj (NOT the projection). - String hopPkg = currentObj.getPackage() == null ? "" : currentObj.getPackage(); + String hopPkg = References.referrerPackageFor(hop, + isReferenceHop(hop) ? MetaIdentity.ATTR_REFERENCES : MetaRelationship.ATTR_OBJECT_REF); MetaObject nextObj = resolveRootObject(root, refTarget, hopPkg); if (nextObj == null) { // FR5d — the hop's target points at a missing entity. This is the @@ -4197,27 +4208,10 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, */ public static MetaObject resolveRootObject(MetaRoot root, String ref, String referrerPkg) { if (ref == null) return null; - String pkg = (referrerPkg == null) ? "" : referrerPkg; - if (ref.indexOf(MetaData.PKG_SEPARATOR) >= 0) { - for (MetaData child : root.getChildren(MetaData.class, false)) { - if (child instanceof MetaObject && ref.equals(child.getName())) { - return (MetaObject) child; - } - } - return null; - } - String localKey = pkg.isEmpty() ? ref : pkg + MetaData.PKG_SEPARATOR + ref; - MetaObject own = null; - MetaObject rootLevel = null; - for (MetaData child : root.getChildren(MetaData.class, false)) { - if (!(child instanceof MetaObject)) continue; - String key = child.getName(); - if (key == null) continue; - if (key.equals(localKey)) own = (MetaObject) child; - if (key.equals(ref)) rootLevel = (MetaObject) child; - } - if (own != null) return own; - return localKey.equals(ref) ? null : rootLevel; + // ADR-0059 R2 — the one resolver: ::Rest, ..::Rest, pkg::Name, bare Name. An over-dropping + // ..:: resolves to nothing here; the walk that owns the ref reports ERR_BAD_ATTR_VALUE. + MetaData node = References.resolveObjectRef(root, ref, referrerPkg).node(); + return node instanceof MetaObject ? (MetaObject) node : null; } /** ADR-0042 §5 — a did-you-mean suffix for an UNRESOLVED object reference: the FQNs of @@ -5204,7 +5198,8 @@ static void validateSourceParameterRef(MetaRoot root) { ErrorCode.ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND, source.getSource()); } - MetaObject target = resolveRootObject(root, ref, referrerPkg); + MetaObject target = resolveRootObject(root, ref, + References.referrerPackageFor(source, RdbSource.ATTR_PARAMETER_REF)); if (target == null) { throw new MetaDataException( "ERR_PARAMETER_REF_UNRESOLVED" diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/BaseMetaDataParser.java b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/BaseMetaDataParser.java index 705f77d39..84d764028 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/BaseMetaDataParser.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/BaseMetaDataParser.java @@ -389,6 +389,10 @@ protected MetaData createOrOverlayMetaData( boolean isRoot, // Create the new MetaData using registry md = createNewMetaData(isRoot, parent, typeName, subTypeName, name, packageName, superData); + // ADR-0059 R10: this node's own file's default package, nested nodes included. + String filePkg = getDefaultPackageName(); + md.setFileDefaultPackage(filePkg == null ? "" : filePkg); + // Add to the parent metadata parent.addChild(md); @@ -455,85 +459,12 @@ protected ErrorSource overlayNoTargetEnvelope(String referrer, String target) { /** Get the Super MetaData if it exists - v6.0.0: Updated to use registry */ protected MetaData getSuperMetaData(MetaData parent, String typeName, String name, String packageName, String superName ) { - - MetaData superData = null; - - // If a super class was specified - if (superName != null && !superName.isEmpty()) { - - // Try to find it with the name prepended if not fully qualified - try { - if (superName.indexOf(MetaDataLoader.PKG_SEPARATOR) < 0 && packageName.length() > 0) { - // v6.0.0: Search by type and name instead of class constraint - superData = getLoader().getChildOfType(typeName, packageName + MetaDataLoader.PKG_SEPARATOR + superName ); - } - } catch (MetaDataNotFoundException e) { - // This is expected behavior - try fallback to fully qualified name resolution - log.debug("Could not find MetaData [" + packageName + MetaDataLoader.PKG_SEPARATOR + superName + "], assuming fully qualified"); - } - - // Try to find it by the provided name in the 'super' attribute - if (superData == null) { - String fullyQualifiedSuperName = getFullyQualifiedSuperMetaDataName(parent, packageName, superName); - try { - // v6.0.0: Search by type and name instead of class constraint - superData = getLoader().getChildOfType(typeName, fullyQualifiedSuperName); - } - catch (MetaDataNotFoundException e) { - - //log.info( "packageName="+packageName+", parentPkg="+(parent==null?null:parent.getPackage()) - // +", pkg="+pkg+", superName="+superName+", sn="+sn); - //log.error("Invalid MetaData [" +typeName+ "][" +name+ "] on parent ["+parent+"], the SuperClass [" + superName + "] does not exist in file ["+getFilename()+"]"); - // Defer cross-file forward references — the loader - // resolves the queue after all sources are parsed and - // throws ERR_UNRESOLVED_SUPER if any are still unbound. - // We return null here; caller proceeds to attach the child - // without a super, and resolvePendingExtends() sets it. - return null; - } - } - } - // Check to make sure people arent' defining attributes when it shouldn't - else { - if (superName != null && !superName.isEmpty()) { - logErrorOnce( parent, "getSuperMetaData("+typeName+","+name+")", - "Attribute 'super' defined on MetaData [" +typeName+ "][" +name+ "] under parent [" +parent - + "], but should not be as metadata with that name already existed: file ["+getFilename()+"]"); - } - } - - return superData; - } - - /** Get the fully qualified metadata name for the Super MetaData */ - protected String getFullyQualifiedSuperMetaDataName(MetaData parent, String packageName, String superName) { - - // For super references, we need the appropriate package context: - // - For nested elements (validators, views inside fields), use the containing hierarchy's package - // - For top-level objects, use the object's own package - // The MetaDataUtil.findPackageForMetaData traverses up the hierarchy to find the right context - String basePackage = MetaDataUtil.findPackageForMetaData(parent); - if (basePackage == null || basePackage.isEmpty()) { - // Fallback to the packageName if parent hierarchy doesn't provide context - basePackage = packageName; - } - - String expandedName = MetaDataUtil.expandPackageForMetaDataRef(basePackage, superName); - - // When superName is a simple unqualified name (no package separator) and we have a - // base package context, prepend the package. This handles the common case of a field - // inside an object extending a root-level abstract field in the same package — e.g. - // "extends": "Status" resolving to "acme::Status". The expandPackageForMetaDataRef - // utility only transforms relative (:: or ..::) paths; a bare simple name is left as-is, - // which breaks cross-type extends where the element's own packageName is "" (fields - // inside objects carry no package) but the super lives at the package root. - if (!basePackage.isEmpty() - && superName.indexOf(MetaDataLoader.PKG_SEPARATOR) < 0 - && !expandedName.contains(MetaDataLoader.PKG_SEPARATOR)) { - expandedName = basePackage + MetaDataLoader.PKG_SEPARATOR + superName; - } - - return expandedName; + // ADR-0059 R1/R2: a source does not interpret a reference, and a reference resolves once + // the whole tree is built. Binding an `extends` here would depend on which files had been + // parsed so far (a root-level `Apple` seen before `acme::Apple` arrives would win); the + // loader's deferred pass (MetaDataLoader.resolvePendingExtends) binds every one, in + // every source, by the same rule. + return null; } /** Determine if the packageName should change based on the parent metadata */ diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java index 9b764b647..272b39c46 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java @@ -127,24 +127,6 @@ public class CanonicalJsonParser extends BaseMetaDataParser implements MetaDataF KEY_IS_ARRAY, KEY_CHILDREN, KEY_VALUE ); - /** - * FR-032 (ADR-0032) — the bare (sigil-free) inline attribute names whose VALUE is a - * metadata reference. In canonical JSON these are {@code @}-prefixed ({@code @objectRef}, …). - * Mirrors {@code YamlDesugar.REF_BEARING_ATTR_NAMES} and the TS {@code REF_BEARING_ATTR_NAMES}. - * The structural {@code extends} key is a reference too, but is guarded separately (it is the - * bare {@link #KEY_EXTENDS} body key, not an {@code @}-prefixed attr). - */ - private static final Set REF_BEARING_ATTR_NAMES = Set.of( - "objectRef", "references", "from", "of", "via", - "payloadRef", "responseRef", "parameterRef" - ); - - /** FR-032 — package separator ({@code ::}); a value with this leading prefix is root-absolute-relative. */ - private static final String FR032_PKG_SEPARATOR = com.metaobjects.util.MetaDataUtil.SEP; - - /** FR-032 — parent-relative prefix ({@code ..::}). */ - private static final String FR032_PARENT_PREFIX = ".." + FR032_PKG_SEPARATOR; - // ----------------------------------------------------------------------- // Constructor // ----------------------------------------------------------------------- @@ -338,33 +320,6 @@ private ErrorSource buildCurrentSourceEnvelope() { return new JsonSource(List.of(getFilename()), path); } - /** - * FR-032 (ADR-0032) — guard a ref-bearing value against relative forms. - * - *

    Canonical JSON is the self-contained interchange form: every ref-bearing - * attribute MUST be fully qualified. A relative authoring form (leading - * {@code ::} or {@code ..::}) surviving into canonical JSON is - * {@code ERR_RELATIVE_REF_IN_CANONICAL}. {@link CanonicalJsonParser} only ever - * handles canonical JSON (it is JSON by construction), so no format check is - * needed — the YAML desugar expands these forms before the parser sees them. - * Throwing halts the parse so exactly one error is produced, mirroring the - * {@code ERR_RESERVED_ATTR} rejection style.

    - */ - private void guardRelativeRefInCanonical(String refLabel, String rawValue) { - if (rawValue == null) return; - if (!rawValue.startsWith(FR032_PKG_SEPARATOR) && !rawValue.startsWith(FR032_PARENT_PREFIX)) { - return; - } - throw new MetaDataException( - ErrorMessageConstants.ERR_RELATIVE_REF_IN_CANONICAL - + ": relative reference '" + rawValue + "' on " + refLabel - + " in file [" + getFilename() + "] is not allowed in canonical JSON — " - + "canonical JSON must be fully-qualified. Relative forms (leading '" - + FR032_PKG_SEPARATOR + "' or '" + FR032_PARENT_PREFIX + "') are " - + "YAML-authoring sugar that the desugar expands.", - ErrorCode.ERR_RELATIVE_REF_IN_CANONICAL, currentSourceEnvelope()); - } - /** * FR5a / ADR-0009 — Re-throw a {@link MetaDataException} that escaped a * registry/base-parser call site without an envelope, stamping it with the @@ -837,8 +792,6 @@ private void processNode(MetaData parent, String type, String subType, String pkg = getStringOrNull(body, KEY_PACKAGE); // "extends" (canonical) → "super" (base parser slot) String superRef = getStringOrNull(body, KEY_EXTENDS); - // FR-032 — reject a relative `extends` ref in canonical JSON. - guardRelativeRefInCanonical("\"" + KEY_EXTENDS + "\"", superRef); // "abstract" (canonical) — handled AFTER node creation as an attribute Boolean isAbstract = getBooleanOrNull(body, KEY_ABSTRACT); Boolean isOverlay = getBooleanOrNull(body, KEY_OVERLAY); @@ -1378,16 +1331,6 @@ private void processAttributes(MetaData md, JsonObject body) { // Strip the @ prefix String attrName = key.substring(JSON_ATTR_PREFIX.length()); - // FR-032 (ADR-0032) — reject a relative reference value on a ref-bearing - // inline attr (@objectRef / @references / @from / @of / @via / @payloadRef / - // @responseRef / @parameterRef). Canonical JSON must be fully-qualified. - if (REF_BEARING_ATTR_NAMES.contains(attrName)) { - JsonElement refVal = entry.getValue(); - if (refVal.isJsonPrimitive() && refVal.getAsJsonPrimitive().isString()) { - guardRelativeRefInCanonical(key, refVal.getAsString()); - } - } - // ADR-0006 §D1: reserved structural keywords must be written bare in canonical JSON. // Writing them as @-prefixed attributes (e.g. @isArray, @name) is unconditionally // invalid — there is no registry-exception. diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/yaml/YamlDesugar.java b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/yaml/YamlDesugar.java index a66361e54..e56085c69 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/yaml/YamlDesugar.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/yaml/YamlDesugar.java @@ -29,13 +29,13 @@ import com.metaobjects.attr.MetaAttribute; import com.metaobjects.attr.StringArrayAttribute; import com.metaobjects.attr.StringAttribute; +import com.metaobjects.field.MetaField; import com.metaobjects.registry.ChildRequirement; import com.metaobjects.registry.MetaDataRegistry; import com.metaobjects.source.YamlPosition; import com.metaobjects.source.YamlPositions; import com.metaobjects.source.YamlPositions.PositionMap; import com.metaobjects.source.YamlPositions.SideTable; -import com.metaobjects.util.MetaDataUtil; import org.yaml.snakeyaml.nodes.MappingNode; import org.yaml.snakeyaml.nodes.Node; import org.yaml.snakeyaml.nodes.NodeId; @@ -128,129 +128,15 @@ public final class YamlDesugar { KEY_IS_ARRAY, KEY_CHILDREN, KEY_VALUE ); - /** Package separator ({@code ::}) — used to prepend a parent package per the legacy rule. */ - private static final String PKG_SEPARATOR = MetaDataUtil.SEP; - - /** - * FR-032 (ADR-0032) — the bare (sigil-free) inline attribute names whose VALUE is a - * metadata reference subject to FQN expansion. In canonical JSON these are {@code @}-prefixed - * ({@code @objectRef}, …). The structural {@code extends} key is handled separately (it is not - * {@code @}-prefixed). {@code @from}/{@code @of}/{@code @via} carry an entity head with a - * possible dotted relationship/field tail — {@link MetaDataUtil#expandRef} preserves the tail. - */ - private static final Set REF_BEARING_ATTR_NAMES = Set.of( - "objectRef", "references", "from", "of", "via", - "payloadRef", "responseRef", "parameterRef" - ); - private YamlDesugar() { // Utility class. } - // ----------------------------------------------------------------------- - // FR-032 (ADR-0032) — reference expansion helpers - // ----------------------------------------------------------------------- - - /** - * FR-032 — compute a node's effective package from its raw {@code package} body key - * (inheriting {@code parentPkg} when absent). Mirrors the TS {@code effectivePackageFor}: - * the legacy {@code expandPackageForPath} rule prepends the parent when the child's - * {@code package} value starts with {@code ::}; otherwise it is used as-is. The - * {@code package} attribute itself is NEVER ref-expanded — it is the node's identity. - */ - private static String effectivePackageFor(String rawPkg, String parentPkg) { - if (rawPkg == null) return parentPkg; - if (!parentPkg.isEmpty() && rawPkg.startsWith(PKG_SEPARATOR)) { - return parentPkg + rawPkg; - } - return rawPkg; - } - - /** - * FR-032 — true when {@code outKey} is a ref-bearing key whose String value must be - * FQN-expanded: the reserved {@code extends} key, or an {@code @}-prefixed inline attr - * whose bare name is in {@link #REF_BEARING_ATTR_NAMES}. - */ - private static boolean isRefBearingKey(String outKey) { - if (KEY_EXTENDS.equals(outKey)) return true; - if (outKey.startsWith(ATTR_PREFIX)) { - return REF_BEARING_ATTR_NAMES.contains(outKey.substring(ATTR_PREFIX.length())); - } - return false; - } - - /** - * FR-032 — expand {@code value} when {@code outKey} is a ref-bearing key, else return it - * unchanged. Convenience wrapper that combines {@link #isRefBearingKey} + {@link #expandRefValue}. - */ - private static JsonElement maybeExpandRef(String outKey, JsonElement value, String nodePkg, - List errors, String path) { - if (isRefBearingKey(outKey)) { - return expandRefValue(value, nodePkg, outKey, errors, path); - } - return value; - } - /** Coerce a raw Java value to a String, or {@code null} if it is not a String. */ private static String asStringOrNull(Object v) { return (v instanceof String) ? (String) v : null; } - /** - * FR-032 — read the {@code package} body key (a scalar) from a SnakeYAML body - * {@link Node}, or {@code null} when absent / not a scalar string. Used to seed the - * node's effective package context for ref expansion. - */ - private static String packageKeyFromYamlBody(Node rawBody) { - if (rawBody == null || rawBody.getNodeId() != NodeId.mapping) return null; - for (NodeTuple bt : ((MappingNode) rawBody).getValue()) { - if (bt.getKeyNode().getNodeId() != NodeId.scalar) continue; - if (KEY_PACKAGE.equals(((ScalarNode) bt.getKeyNode()).getValue())) { - Node v = bt.getValueNode(); - if (v != null && v.getNodeId() == NodeId.scalar) { - return ((ScalarNode) v).getValue(); - } - return null; - } - } - return null; - } - - /** - * FR-032 — expand a ref-bearing value to FQN. A ref value is a single string (most refs) - * or a string-array ({@code @references} can carry multiple); {@link MetaDataUtil#expandRef} - * preserves any FR-024 dotted child suffix. A parent-relative over-drop throws inside - * {@code expandRef}; we collect it as {@code ERR_BAD_ATTR_VALUE} and pass the raw value - * through so a single actionable error surfaces. - */ - private static JsonElement expandRefValue(JsonElement value, String nodePkg, String refLabel, - List errors, String path) { - if (value == null) return value; - if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { - try { - return new JsonPrimitive(MetaDataUtil.expandRef(value.getAsString(), nodePkg)); - } catch (RuntimeException ex) { - errors.add(new CollectedError( - "Cannot expand reference '" + refLabel + "' at " + path + ": " + ex.getMessage(), - ErrorCode.ERR_BAD_ATTR_VALUE)); - return value; - } - } - if (value.isJsonArray()) { - JsonArray src = value.getAsJsonArray(); - JsonArray out = new JsonArray(src.size()); - for (JsonElement el : src) { - if (el != null && el.isJsonPrimitive() && el.getAsJsonPrimitive().isString()) { - out.add(expandRefValue(el, nodePkg, refLabel, errors, path)); - } else { - out.add(el); - } - } - return out; - } - return value; - } - // ----------------------------------------------------------------------- // Collected error + result types // ----------------------------------------------------------------------- @@ -321,7 +207,7 @@ public static DesugarResult desugar(Object input, MetaDataRegistry registry) { List errors = new ArrayList<>(); // FR-032: the root package context starts empty; each node's own `package` body key // seeds the context threaded down to its children for ref expansion. - JsonObject node = desugarNode(input, registry, errors, "", ""); + JsonObject node = desugarNode(input, registry, errors, ""); JsonObject canonical = node != null ? node : new JsonObject(); return new DesugarResult(canonical, errors); } @@ -352,8 +238,8 @@ public static DesugarResult desugar(Object input, MetaDataRegistry registry) { public static DesugarResult desugar(Node input, MetaDataRegistry registry) { List errors = new ArrayList<>(); SideTable positions = new SideTable(); - // FR-032: root package context starts empty (see the Object-tree entry point). - JsonObject node = desugarNodeFromYaml(input, registry, errors, positions, "", ""); + + JsonObject node = desugarNodeFromYaml(input, registry, errors, positions, ""); JsonObject canonical = node != null ? node : new JsonObject(); return new DesugarResult(canonical, errors, positions); } @@ -368,8 +254,7 @@ public static DesugarResult desugar(Node input, MetaDataRegistry registry) { * node (the caller substitutes a placeholder). */ private static JsonObject desugarNode(Object input, MetaDataRegistry registry, - List errors, String path, - String parentPkg) { + List errors, String path) { if (!(input instanceof Map)) { errors.add(new CollectedError( "Node at " + path + " must be a mapping with one type key")); @@ -411,13 +296,8 @@ private static JsonObject desugarNode(Object input, MetaDataRegistry registry, // Rule 1: a bare `type` key → the type's registry default subType. String canonicalKey = resolveKey(key, registry, errors, path); - // FR-032: this node's effective package context (its own `package` body key, else - // inherited). Used to expand its ref-bearing attrs AND threaded to its children. - String rawPkg = (rawBody instanceof Map) ? asStringOrNull(((Map) rawBody).get(KEY_PACKAGE)) : null; - String nodePkg = effectivePackageFor(rawPkg, parentPkg); - // Rule 2: a scalar body → { name: }. - JsonObject body = desugarBody(rawBody, registry, canonicalKey, errors, path, nodePkg); + JsonObject body = desugarBody(rawBody, registry, canonicalKey, errors, path); // Rule 4 (cont.): stamp isArray onto the canonical body. if (isArray) { @@ -444,7 +324,7 @@ private static JsonObject desugarNode(Object input, MetaDataRegistry registry, JsonArray children = new JsonArray(list.size()); for (int i = 0; i < list.size(); i++) { String childPath = path + "." + KEY_CHILDREN + "[" + i + "]"; - JsonObject child = desugarNode(list.get(i), registry, errors, childPath, nodePkg); + JsonObject child = desugarNode(list.get(i), registry, errors, childPath); // On a bad child keep an empty-object placeholder so sibling indices // stay stable; the error is already collected. children.add(child != null ? child : new JsonObject()); @@ -492,8 +372,7 @@ private static String resolveKey(String key, MetaDataRegistry registry, */ private static JsonObject desugarBody(Object rawBody, MetaDataRegistry registry, String canonicalKey, - List errors, String path, - String nodePkg) { + List errors, String path) { JsonObject out = new JsonObject(); if (rawBody == null) { // An empty body (`field.string:` with nothing after) → an empty node. @@ -541,7 +420,7 @@ private static JsonObject desugarBody(Object rawBody, MetaDataRegistry registry, if (RESERVED_KEYS.contains(key) || key.startsWith(ATTR_PREFIX)) { // FR-032: expand a ref-bearing value (the reserved `extends` key, or an // already-`@`-prefixed ref attr) to FQN; the `package` key is never expanded. - out.add(key, maybeExpandRef(key, toJsonElement(value), nodePkg, errors, path)); + out.add(key, toJsonElement(value)); // D2 also applies to author-written @-keys (the awkward form). if (key.startsWith(ATTR_PREFIX)) { String attrName = key.substring(ATTR_PREFIX.length()); @@ -553,7 +432,7 @@ private static JsonObject desugarBody(Object rawBody, MetaDataRegistry registry, } else { // FR-032: a bare (sigil-free) ref-bearing attr is expanded to FQN too. String outKey = ATTR_PREFIX + key; - out.add(outKey, maybeExpandRef(outKey, toJsonElement(value), nodePkg, errors, path)); + out.add(outKey, toJsonElement(value)); checkCoercion(key, value, attrSchemaIndex, registry, parent, errors, path); } } @@ -706,7 +585,11 @@ private static boolean isStringArraySubType(String declared) { */ private static boolean isArrayConstraintRegistered(MetaDataRegistry registry, ParentKey parent, String attrName) { - if (registry == null || parent == null || parent.subType.isEmpty()) return false; + if (registry == null) return false; + // commonAttrs (e.g. @aliases, @seeAlso) register their array constraint under the + // universal "*.*..array" id, not a concrete (type, subType) one. + if (registry.hasConstraint("*.*." + attrName + ".array")) return true; + if (parent == null || parent.subType.isEmpty()) return false; String constraintId = parent.type + TYPE_SUBTYPE_SEPARATOR + parent.subType + TYPE_SUBTYPE_SEPARATOR + attrName + ".array"; return registry.hasConstraint(constraintId); @@ -845,8 +728,7 @@ private static String literalRepr(Object raw) { */ private static JsonObject desugarNodeFromYaml(Node input, MetaDataRegistry registry, List errors, - SideTable positions, String path, - String parentPkg) { + SideTable positions, String path) { if (input == null) { errors.add(new CollectedError( "Node at " + path + " must be a mapping with one type key")); @@ -905,15 +787,10 @@ private static JsonObject desugarNodeFromYaml(Node input, MetaDataRegistry regis // Rule 1: a bare `type` key → the type's registry default subType. String canonicalKey = resolveKey(key, registry, errors, path); - // FR-032: this node's effective package context (its own `package` body key, else - // inherited). Used to expand its ref-bearing attrs AND threaded to its children. - String rawPkg = packageKeyFromYamlBody(rawBody); - String nodePkg = effectivePackageFor(rawPkg, parentPkg); - // Rule 2: a scalar body → { name: }. The body-builder also gets // the wrapper-key position so Rule-2 synthesis (name slot) can inherit it. JsonObject body = desugarBodyFromYaml(rawBody, registry, canonicalKey, errors, - positions, path, wrapperKeyPos, nodePkg); + positions, path, wrapperKeyPos); // Rule 4 (cont.): stamp isArray onto the canonical body. if (isArray) { @@ -935,7 +812,7 @@ private static JsonObject desugarNodeFromYaml(Node input, MetaDataRegistry regis for (int i = 0; i < seq.getValue().size(); i++) { String childPath = path + "." + KEY_CHILDREN + "[" + i + "]"; JsonObject child = desugarNodeFromYaml(seq.getValue().get(i), - registry, errors, positions, childPath, nodePkg); + registry, errors, positions, childPath); children.add(child != null ? child : new JsonObject()); } body.add(KEY_CHILDREN, children); @@ -974,8 +851,7 @@ private static JsonObject desugarBodyFromYaml(Node rawBody, MetaDataRegistry reg String canonicalKey, List errors, SideTable positions, String path, - YamlPosition wrapperKeyPos, - String nodePkg) { + YamlPosition wrapperKeyPos) { JsonObject out = new JsonObject(); if (rawBody == null) { // Empty body (`field.string:` with nothing) → empty node, no body positions. @@ -1048,7 +924,7 @@ private static JsonObject desugarBodyFromYaml(Node rawBody, MetaDataRegistry reg // FR-032: expand a ref-bearing value (reserved `extends` or an already-`@`-prefixed // ref attr) to FQN; the `package` key is never expanded. outKey = key; - out.add(key, maybeExpandRef(key, yamlNodeToJsonElement(valueNode), nodePkg, errors, path)); + out.add(key, yamlNodeToJsonElement(valueNode)); if (key.startsWith(ATTR_PREFIX)) { String attrName = key.substring(ATTR_PREFIX.length()); if (!attrName.isEmpty() && !RESERVED_KEYS.contains(attrName)) { @@ -1060,7 +936,7 @@ private static JsonObject desugarBodyFromYaml(Node rawBody, MetaDataRegistry reg // Rule 4 (D1) — sigil-free attr: re-prefix with `@` on lowering. // FR-032: a bare ref-bearing attr is expanded to FQN too. outKey = ATTR_PREFIX + key; - out.add(outKey, maybeExpandRef(outKey, yamlNodeToJsonElement(valueNode), nodePkg, errors, path)); + out.add(outKey, yamlNodeToJsonElement(valueNode)); checkCoercionYaml(key, valueNode, attrSchemaIndex, registry, parent, errors, path); } @@ -1237,6 +1113,8 @@ private static void checkCoercionYaml(String attrName, Node rawNode, if (schemaIndex == null) return; ChildRequirement spec = schemaIndex.get(attrName); if (spec == null) return; + // field.* @default is registered STRING but its value type is polymorphic; exempt. + if (MetaField.TYPE_FIELD.equals(parent.type) && MetaField.ATTR_DEFAULT.equals(attrName)) return; String declared = spec.getExpectedSubType(); if (declared == null || "*".equals(declared)) return; diff --git a/server/java/metadata/src/main/java/com/metaobjects/object/MetaObject.java b/server/java/metadata/src/main/java/com/metaobjects/object/MetaObject.java index 78ff24bd1..d744a023b 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/object/MetaObject.java +++ b/server/java/metadata/src/main/java/com/metaobjects/object/MetaObject.java @@ -774,7 +774,12 @@ public Collection getRelationshipsByTarget(String targetObject return useCache("getRelationshipsByTarget()", targetObject, target -> { Collection filtered = new ArrayList<>(); for (MetaRelationship rel : getRelationships()) { - if (target.equals(rel.getObjectRef())) { + // ADR-0059 R7: compare the bound target, not the spelling. + MetaObject bound = com.metaobjects.loader.References.refTarget(rel, "objectRef"); + MetaData top = com.metaobjects.loader.References.topLevelRoot(this); + MetaData want = top instanceof com.metaobjects.MetaRoot + ? ((com.metaobjects.MetaRoot) top).findObject(target) : null; + if (bound != null && want != null ? bound == want : target.equals(rel.getObjectRef())) { filtered.add(rel); } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java index 73de0d3d6..2913cb7b4 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java +++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/M2MFields.java @@ -19,7 +19,7 @@ import com.metaobjects.MetaRoot; import com.metaobjects.identity.MetaIdentity; import com.metaobjects.object.MetaObject; -import com.metaobjects.validation.SymbolTable; +import com.metaobjects.loader.References; import java.util.ArrayList; import java.util.List; @@ -114,29 +114,15 @@ public static M2MFields derive(MetaRelationship rel, MetaObject source, MetaRoot MetaData relParent = rel.getParent(); MetaObject declaring = (relParent instanceof MetaObject) ? (MetaObject) relParent : source; - // ADR-0041/0042 — resolve @through / @objectRef through the SAME package-local - // matcher the loader's M:N validation rule uses for @through - // (ValidationPhase.resolveRootObject: an FQN resolves exactly on its resolution - // key, a bare name resolves in the REFERRER's package). Both here delegate to - // SymbolTable — the loader's own reusable ADR-0042 resolver (RegisteredValidation - // already builds one per load) — so the loader and this derivation read the - // identical resolution and cannot silently disagree about which junction/target a - // bare name binds to. - // - // This used to be a root.getChildren() scan matching a bare name against the - // FIRST same-short-name object found (issue #174), which took whichever entity - // loaded first rather than the one in the referrer's own package — the same class - // of defect the TS reference closed in findEntity/junction resolution. - SymbolTable symbols = SymbolTable.build(root); - String referrerPkg = declaring.getPackage() == null ? "" : declaring.getPackage(); - + // ADR-0059 R7 — @through / @objectRef / @references read the target the loader bound + // (or, on a tree built without it, the declaring package's resolution), never a short name. String throughName = rel.getThrough(); if (throughName == null || throughName.isEmpty()) { throw new M2MDerivationException( "relationship \"" + declaring.getShortName() + "." + rel.getShortName() + "\" is missing @through (required for M:N derivation)"); } - MetaObject junction = findObject(symbols, throughName, referrerPkg); + MetaObject junction = References.refTarget(rel, MetaRelationship.ATTR_THROUGH); if (junction == null) { throw new M2MDerivationException( "relationship \"" + declaring.getShortName() + "." + rel.getShortName() @@ -149,7 +135,7 @@ public static M2MFields derive(MetaRelationship rel, MetaObject source, MetaRoot "relationship \"" + declaring.getShortName() + "." + rel.getShortName() + "\" is missing @objectRef (the M:N target)"); } - MetaObject target = findObject(symbols, targetName, referrerPkg); + MetaObject target = References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF); List refs = referenceIdentities(junction); if (refs.size() != 2) { @@ -176,23 +162,14 @@ public static M2MFields derive(MetaRelationship rel, MetaObject source, MetaRoot // @objectRef must bind the correct package. Falls back to a bare-name // compare only when @objectRef is unresolvable (defensive — loader // validation normally guarantees resolution before derive runs). - boolean isSelfJoin = (target != null) - ? isSubject(target, declaring, source) - : (stripPackage(targetName).equals(declaring.getShortName()) - || stripPackage(targetName).equals(source.getShortName())); + boolean isSelfJoin = target != null && isSubject(target, declaring, source); if (!isSelfJoin) { // Hetero: match each reference by the ENTITY OBJECT its @references // resolves to (FQN-exact), so a same-bare-name cross-package reference // binds the correct package rather than the first bare-tail match. - // The junction OWNS its references' @references names, so those resolve - // in the JUNCTION's package — the relationship's own @through/@objectRef - // resolve in the DECLARING entity's. Under `extends` across packages - // these differ; using declaring's package for both is what bound a bare - // @references to the wrong same-named entity. - String junctionPkg = junction.getPackage() == null ? "" : junction.getPackage(); - MetaIdentity sourceRef = findRefToSubject(symbols, junctionPkg, refs, declaring, source); - MetaIdentity targetRef = findRefToObject(symbols, junctionPkg, refs, target); + MetaIdentity sourceRef = findRefToSubject(refs, declaring, source); + MetaIdentity targetRef = findRefToObject(refs, target); String sourceField = sourceRef != null ? refFkField(sourceRef) : null; String targetField = targetRef != null ? refFkField(targetRef) : null; if (sourceField == null || targetField == null) { @@ -286,24 +263,9 @@ private static String refFkField(MetaIdentity ref) { return ReferenceFkUtil.refFkField(ref); } - /** - * The entity object a reference's {@code @references} resolves to, or {@code null}. - * ADR-0041/0042: resolve through the SAME package-local {@link SymbolTable} matcher - * as the junction/target lookups in {@link #derive} — an FQN resolves exactly on its - * resolution key, a bare name resolves in the REFERRER's package ({@code referrerPkg}, - * the JUNCTION's package — see the call site in {@link #derive}). - */ - private static MetaObject refTargetObject(SymbolTable symbols, String referrerPkg, MetaIdentity ref) { - // ADR-0039: @references is an inheritable effective identity attr — RESOLVE - // (default includeParentData=true); own-only would miss an inherited target. - if (!ref.hasMetaAttr(MetaIdentity.ATTR_REFERENCES)) return null; - String v = ref.getMetaAttr(MetaIdentity.ATTR_REFERENCES).getValueAsString(); - if (v == null || v.isEmpty()) return null; - // @references may carry a dotted Entity.field form — the entity is the head - // segment (packages use "::", never ".", so the first "." splits it off). - int dot = v.indexOf('.'); - String entity = dot >= 0 ? v.substring(0, dot) : v; - return findObject(symbols, entity, referrerPkg); + /** The entity a reference's {@code @references} is bound to (ADR-0059 R7), or {@code null}. */ + private static MetaObject refTargetObject(MetaIdentity ref) { + return References.refTarget(ref, MetaIdentity.ATTR_REFERENCES); } /** @@ -318,45 +280,23 @@ private static boolean isSubject(MetaObject candidate, MetaObject declaring, Met } /** The junction reference resolving to either subject entity, or {@code null}. */ - private static MetaIdentity findRefToSubject(SymbolTable symbols, String junctionPkg, - List refs, + private static MetaIdentity findRefToSubject(List refs, MetaObject declaring, MetaObject source) { for (MetaIdentity ref : refs) { - if (isSubject(refTargetObject(symbols, junctionPkg, ref), declaring, source)) return ref; + if (isSubject(refTargetObject(ref), declaring, source)) return ref; } return null; } - private static MetaIdentity findRefToObject(SymbolTable symbols, String junctionPkg, - List refs, MetaObject entity) { + private static MetaIdentity findRefToObject(List refs, MetaObject entity) { if (entity == null) return null; for (MetaIdentity ref : refs) { - MetaObject t = refTargetObject(symbols, junctionPkg, ref); + MetaObject t = refTargetObject(ref); if (t != null && t.getName().equals(entity.getName())) return ref; } return null; } - /** - * ADR-0041/0042 — resolve a name to a top-level {@link MetaObject} through the - * package-local {@link SymbolTable} matcher: an FQN (contains {@code ::}) resolves - * EXACTLY on its resolution key; a bare name resolves in {@code referrerPkg}, else a - * root-level (empty-package) object whose key IS the bare name. No bare-tail - * first-match-wins fallback. - * - *

    This used to scan {@code root.getChildren()} matching a bare name against the - * FIRST same-short-name object found regardless of package (issue #174) — the same - * defect class the TS reference (and {@code ValidationPhase.resolveRootObject}, which - * the loader's own M:N validation rule uses for {@code @through}) closed by resolving - * package-locally. Sharing {@link SymbolTable} — rather than reimplementing the rule a - * second time here — is what keeps the loader and this derivation from disagreeing - * about which object a bare name binds to. - */ - private static MetaObject findObject(SymbolTable symbols, String name, String referrerPkg) { - if (name == null || name.isEmpty()) return null; - return symbols.resolveObject(name, referrerPkg); - } - private static String stripPackage(String name) { return ReferenceFkUtil.stripPackage(name); } diff --git a/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java b/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java index e097afcdf..35f9d9f6a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java +++ b/server/java/metadata/src/main/java/com/metaobjects/relationship/RelationshipReferences.java @@ -17,6 +17,7 @@ import com.metaobjects.identity.MetaIdentity; import com.metaobjects.identity.ReferenceIdentity; +import com.metaobjects.loader.References; import com.metaobjects.object.MetaObject; import java.util.ArrayList; @@ -100,15 +101,14 @@ private static void addPairingKey(Set keys, String value) { *

    ADR-0039: resolving — {@code holder.getIdentities()} (no-arg, includeParentData * defaulting true) honors references inherited via extends.

    */ - public static List referenceCandidatesFor(MetaObject holder, String targetEntity) { - String target = ReferenceFkUtil.stripPackage(targetEntity); + public static List referenceCandidatesFor(MetaObject holder, MetaObject targetNode) { List out = new ArrayList<>(); + if (targetNode == null) return out; for (MetaIdentity id : holder.getIdentities()) { if (!(id instanceof ReferenceIdentity)) continue; ReferenceIdentity ref = (ReferenceIdentity) id; - if (!target.equals(ReferenceFkUtil.stripPackage(ref.getTargetEntity()))) continue; if (ReferenceFkUtil.refFkField(ref) == null) continue; - out.add(ref); + if (References.refTarget(ref, MetaIdentity.ATTR_REFERENCES) == targetNode) out.add(ref); } return out; } @@ -124,17 +124,64 @@ public static List referenceCandidatesFor(MetaObject holder, *
  • otherwise -> {@code null} (caller reports the ambiguity)
  • * * Returns {@code null} for "no candidate" and "cannot choose" alike; callers that need - * to tell them apart use {@link #referenceCandidatesFor}. + * to tell them apart use {@link #referenceCandidatesFor}. A reference matches when its {@code @references} + * is bound to the target node, never by short name. * * @param holder the entity whose effective reference set is searched * @param relationshipName the relationship's own (short) name, used for name-pairing - * @param targetEntity the relationship's {@code @objectRef} + * @param targetNode the node the relationship's {@code @objectRef} is bound to * @param sourceRefField the relationship's declared {@code @sourceRefField}, or * {@code null}/empty when absent */ + public static ReferenceIdentity resolveRelationshipReference( + MetaObject holder, String relationshipName, MetaObject targetNode, String sourceRefField) { + return pick(referenceCandidatesFor(holder, targetNode), relationshipName, sourceRefField); + } + + /** + * The earlier signature, kept so a generator ejected before 1.1.0 still compiles unchanged. + * {@code targetEntity} is read as a CANONICAL reference value and looked up exactly + * (ADR-0059 R7); it is never matched by short name. + * + * @deprecated pass the bound target node, e.g. {@code References.refTarget(rel, "objectRef")} + */ + @Deprecated public static ReferenceIdentity resolveRelationshipReference( MetaObject holder, String relationshipName, String targetEntity, String sourceRefField) { - List candidates = referenceCandidatesFor(holder, targetEntity); + return resolveRelationshipReference(holder, relationshipName, canonicalTarget(holder, targetEntity), + sourceRefField); + } + + /** + * The earlier no-{@code @sourceRefField} overload, kept for generators ejected before 1.1.0. + * + * @deprecated pass the bound target node + */ + @Deprecated + public static ReferenceIdentity resolveRelationshipReference( + MetaObject holder, String relationshipName, String targetEntity) { + return resolveRelationshipReference(holder, relationshipName, targetEntity, null); + } + + /** + * The earlier signature, kept for generators ejected before 1.1.0: {@code targetEntity} is + * looked up as an exact canonical value. + * + * @deprecated pass the bound target node + */ + @Deprecated + public static List referenceCandidatesFor(MetaObject holder, String targetEntity) { + return referenceCandidatesFor(holder, canonicalTarget(holder, targetEntity)); + } + + private static MetaObject canonicalTarget(MetaObject holder, String value) { + if (holder == null) return null; + com.metaobjects.MetaData root = References.topLevelRoot(holder); + return root == null ? null : References.lookupCanonical(root, value); + } + + private static ReferenceIdentity pick(List candidates, String relationshipName, + String sourceRefField) { if (candidates.isEmpty()) return null; if (candidates.size() == 1) return candidates.get(0); @@ -152,10 +199,4 @@ public static ReferenceIdentity resolveRelationshipReference( } return paired.size() == 1 ? paired.get(0) : null; } - - /** Overload for the common case with no declared {@code @sourceRefField}. */ - public static ReferenceIdentity resolveRelationshipReference( - MetaObject holder, String relationshipName, String targetEntity) { - return resolveRelationshipReference(holder, relationshipName, targetEntity, null); - } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java index 6f839b3c6..dfa6337b5 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java @@ -31,6 +31,7 @@ import com.metaobjects.identity.MetaIdentity; import com.metaobjects.identity.PrimaryIdentity; import com.metaobjects.identity.ReferenceIdentity; +import com.metaobjects.loader.References; import com.metaobjects.loader.ValidationPhase; import com.metaobjects.object.MetaObject; import com.metaobjects.relationship.MetaRelationship; @@ -371,9 +372,9 @@ private static boolean viaJoinsInner(List hops, MetaObject from, MetaRoo } } } - String targetRef = rel != null ? rel.getObjectRef() : refNode != null ? refNode.getTargetEntity() : null; - if (targetRef == null || targetRef.isEmpty()) return false; - MetaObject target = ValidationPhase.resolveRootObject(root, targetRef, packageOf(current)); + MetaObject target = rel != null + ? References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF) + : refNode != null ? References.refTarget(refNode, MetaIdentity.ATTR_REFERENCES) : null; if (target == null) return false; // The reference the hop crosses, as the lowering resolves it. A has-many hop, whose FK @@ -381,7 +382,7 @@ private static boolean viaJoinsInner(List hops, MetaObject from, MetaRoo ReferenceIdentity reference = refNode != null ? refNode : RelationshipReferences.resolveRelationshipReference( - current, rel.getShortName(), target.getShortName(), rel.getSourceRefField()); + current, rel.getShortName(), target, rel.getSourceRefField()); if (reference == null || reference.getFields().isEmpty()) return false; MetaField fk = current.findMetaField(reference.getFields().get(0)).orElse(null); if (fk == null || !isRequiredField(fk)) return false; @@ -452,9 +453,9 @@ private static MetaObject reportingViaTerminal(String via, MetaData declaring, M } } } - String targetRef = rel != null ? rel.getObjectRef() : refNode != null ? refNode.getTargetEntity() : null; - if (targetRef == null || targetRef.isEmpty()) return null; - MetaObject target = ValidationPhase.resolveRootObject(root, targetRef, packageOf(current)); + MetaObject target = rel != null + ? References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF) + : refNode != null ? References.refTarget(refNode, MetaIdentity.ATTR_REFERENCES) : null; if (target == null) return null; current = target; } diff --git a/server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java b/server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java index 35a5de993..6af6112fd 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java +++ b/server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java @@ -56,15 +56,6 @@ private ErrorMessageConstants() { */ public static final String ERR_RESERVED_ATTR = "ERR_RESERVED_ATTR"; - /** - * FR-032 (ADR-0032): a ref-bearing attribute in canonical JSON carries a - * relative reference form (value starting with {@code ::} or {@code ..::}). - * Canonical JSON must be fully qualified — relative forms are YAML-authoring - * sugar that the desugar expands before lowering to canonical JSON. - * Cross-language contract: {@code ERR_RELATIVE_REF_IN_CANONICAL}. - */ - public static final String ERR_RELATIVE_REF_IN_CANONICAL = "ERR_RELATIVE_REF_IN_CANONICAL"; - /** * Error code emitted when a field origin (passthrough / aggregate / collection) * declares an invalid path or attribute (e.g. malformed {@code @via} relationship diff --git a/server/java/metadata/src/main/java/com/metaobjects/util/MetaDataUtil.java b/server/java/metadata/src/main/java/com/metaobjects/util/MetaDataUtil.java index 3e25bc214..644d601a6 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/util/MetaDataUtil.java +++ b/server/java/metadata/src/main/java/com/metaobjects/util/MetaDataUtil.java @@ -15,6 +15,7 @@ */ package com.metaobjects.util; +import com.metaobjects.loader.References; import com.metaobjects.MetaData; import com.metaobjects.MetaDataNotFoundException; import com.metaobjects.field.MetaField; @@ -82,33 +83,9 @@ public static MetaObject getObjectRef( MetaField d ) throws MetaDataNotFoundExce String objectRef = d.getMetaAttr(ATTR_OBJECT_REF).getValueAsString(); if (objectRef != null) { - - String basePkg = findPackageForMetaData(d); - String name = expandPackageForMetaDataRef(basePkg, objectRef); - - try { - o = d.getLoader().getMetaObjectByName(name); - } catch (MetaDataNotFoundException e) { - // A bare (non-`::`-prefixed) ref names a SAME-PACKAGE sibling: the cross-port - // canonical form writes `@objectRef: "Settings"` for a sibling object in the same - // package (see fixtures/persistence-conformance — AllTypes → Settings). The - // relative-path expander above only prepends a package for a `::`-/`..::`-prefixed - // ref, leaving a bare name as-is, which misses the package-qualified registration - // (`fitness::Settings`). Retry with the declaring node's package prefixed before - // surfacing the not-found. - MetaObject resolved = null; - if (basePkg != null && !basePkg.isEmpty() - && !objectRef.contains(SEP)) { - String qualified = basePkg + SEP + objectRef; - try { - resolved = d.getLoader().getMetaObjectByName(qualified); - } catch (MetaDataNotFoundException ignored) { - // fall through to the original not-found below - } - } - if (resolved == null) throw MetaDataNotFoundException.forObject(name, d); - o = resolved; - } + // ADR-0059 R7: read the target the loader bound once; never re-resolve by name. + o = References.refTarget(d, ATTR_OBJECT_REF); + if (o == null) throw MetaDataNotFoundException.forObject(objectRef, d); } d.setCacheValue(KEY, o); diff --git a/server/java/metadata/src/main/java/com/metaobjects/validation/SymbolTable.java b/server/java/metadata/src/main/java/com/metaobjects/validation/SymbolTable.java index 3bc357187..a339d3adb 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/validation/SymbolTable.java +++ b/server/java/metadata/src/main/java/com/metaobjects/validation/SymbolTable.java @@ -36,22 +36,24 @@ public static SymbolTable build(MetaRoot root) { } /** - * ADR-0042 package-local resolution: an FQN ref (contains {@code ::}) matches EXACTLY - * on the resolution key; a bare ref matches the referrer's own package - * ({@code ::}), else a root-level (empty-package) object whose key IS - * the bare ref. Package-local BEFORE root-level. Returns {@code null} when nothing matches. + * ADR-0059 R2 resolution: {@code ::Rest} root-absolute, {@code ..::Rest} parent-relative, + * {@code pkg::Name} exact, bare {@code Name} in the referrer's own package then root-level. + * Exact keys only. An over-dropping {@code ..::} resolves to nothing; the walk reports it. * - * @param ref the reference value (bare or FQN) - * @param referrerPkg the effective package of the node declaring the ref ("" for root-level) + * @param ref the reference value + * @param referrerPkg the effective package of the declaring node ("" for root-level) */ public MetaObject resolveObject(String ref, String referrerPkg) { - if (ref.indexOf(MetaData.PKG_SEPARATOR) >= 0) { - return byKey.get(ref); + java.util.List keys; + try { + keys = com.metaobjects.loader.References.ownerCandidateKeys(ref, referrerPkg); + } catch (com.metaobjects.loader.References.OverDropException e) { + return null; } - String pkg = (referrerPkg == null) ? "" : referrerPkg; - String localKey = pkg.isEmpty() ? ref : pkg + MetaData.PKG_SEPARATOR + ref; - MetaObject own = byKey.get(localKey); - if (own != null) return own; - return byKey.get(ref); // root-level (empty-package) object whose key is the bare name + for (String key : keys) { + MetaObject hit = byKey.get(key); + if (hit != null) return hit; + } + return null; } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceExclusionsTest.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceExclusionsTest.java new file mode 100644 index 000000000..3aff9c817 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceExclusionsTest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.conformance; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertTrue; + +/** Loads the shared gate exclusion files and asserts every named fixture exists. */ +public class ConformanceExclusionsTest { + + static Map twinExclusions() { + return read("yaml-twin-exclusions.json"); + } + + static Map roundTripExclusions() { + return read("round-trip-exclusions.json"); + } + + private static Map read(String file) { + try { + Path p = CorpusRoot.locate().resolve(file); + JsonObject root = JsonParser.parseString( + new String(Files.readAllBytes(p), StandardCharsets.UTF_8)).getAsJsonObject(); + Map out = new LinkedHashMap<>(); + root.getAsJsonObject("exclusions").entrySet() + .forEach(e -> out.put(e.getKey(), e.getValue().getAsString())); + return out; + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + @Test + public void everyExclusionNamesAFixture() { + Path corpus = CorpusRoot.locate(); + for (String name : twinExclusions().keySet()) { + assertTrue("yaml-twin-exclusions.json names a fixture that does not exist: " + name, + Files.isDirectory(corpus.resolve(name))); + } + for (String name : roundTripExclusions().keySet()) { + assertTrue("round-trip-exclusions.json names a fixture that does not exist: " + name, + Files.isDirectory(corpus.resolve(name))); + } + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java index 5e3f6a279..4eac912cd 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java @@ -202,7 +202,7 @@ static java.util.Map> providerAliases() { return PROVIDER_ALIASES; } - private static final Set AVAILABLE_PROVIDERS = discoverAvailableProviders(); + static final Set AVAILABLE_PROVIDERS = discoverAvailableProviders(); // ERR_* token in legacy message-only exceptions; e.g. "... ERR_BAD_ATTR_VALUE: ..." private static final Pattern ERR_TOKEN = Pattern.compile("\\bERR_[A-Z][A-Z0-9_]*\\b"); @@ -694,7 +694,7 @@ private static void assertEnvelopeAlignment(String label, // helpers // ----------------------------------------------------------------------- - private static List listInputFiles(Path inputDir) { + static List listInputFiles(Path inputDir) { List files = new ArrayList<>(); try (DirectoryStream stream = Files.newDirectoryStream(inputDir, "*.json")) { for (Path p : stream) { @@ -714,7 +714,7 @@ private static List listInputFiles(Path inputDir) { * otherwise scan the message text for an {@code ERR_*} token. * Returns {@code ERR_UNKNOWN} when neither yields anything. */ - private static String extractErrorCode(MetaDataException ex) { + static String extractErrorCode(MetaDataException ex) { return ex.getCode() .map(Enum::name) .orElseGet(() -> scanMessageForErrorCode(ex.getMessage())); @@ -735,7 +735,7 @@ private static String scanMessageForErrorCode(String message) { * name to mitigate the loader-root-name leak in the Java loader (the * serializer emits the loader's name as the top-level {@code package} key). */ - private static String detectLoaderName(List inputFiles) { + static String detectLoaderName(List inputFiles) { for (Path p : inputFiles) { try { String content = new String(Files.readAllBytes(p), StandardCharsets.UTF_8); diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java new file mode 100644 index 000000000..b713282ab --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java @@ -0,0 +1,215 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.conformance; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.MetaDataException; +import com.metaobjects.MetaRoot; +import com.metaobjects.io.json.CanonicalJsonSerializer; +import com.metaobjects.loader.InMemoryStringSource; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.MetaDataSource; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Two corpus-wide gates (ADR-0059), mirroring the TypeScript + * {@code conformance-twin-roundtrip.test.ts}. + * + *

    Gate 1 (YAML twin): each fixture's JSON inputs are loaded as JSON, then the same + * texts are re-dumped as YAML and loaded as in-memory YAML sources in the same order. + * The ordered error codes must match; when clean, the canonical serialization (and the + * effective serialization, when the fixture has {@code expected-effective.json}) must be + * identical.

    + * + *

    Gate 2 (round trip): {@code expected.json} is loaded as the only input and must + * serialize back to itself.

    + * + *

    Exclusions come from the shared {@code yaml-twin-exclusions.json} and + * {@code round-trip-exclusions.json}; there are no port-local divergences.

    + */ +@RunWith(Parameterized.class) +public class YamlTwinConformanceTest { + + static final Path CORPUS = CorpusRoot.locate(); + + @Parameters(name = "{0}") + public static Collection fixtures() { + List all = FixtureDiscovery.discover(CORPUS); + List rows = new ArrayList<>(all.size()); + for (FixtureDiscovery.Fixture f : all) { + if (f.isDocsOnly() || f.isContractOnly()) continue; + rows.add(new Object[]{f.name, f}); + } + return rows; + } + + @Parameter(0) + public String fixtureName; + + @Parameter(1) + public FixtureDiscovery.Fixture fix; + + @Test + public void yamlTwin() throws IOException { + if (ConformanceExclusionsTest.twinExclusions().containsKey(fix.name)) return; + if (!canCompose(fix)) return; + List files = ConformanceTest.listInputFiles(fix.inputDir); + assertTrue(fix.name + ": no input files", !files.isEmpty()); + String loaderName = ConformanceTest.detectLoaderName(files); + + List jsonTexts = new ArrayList<>(); + List yamlTexts = new ArrayList<>(); + DumperOptions dumper = new DumperOptions(); + dumper.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + Yaml yaml = new Yaml(dumper); + for (Path file : files) { + String text = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + jsonTexts.add(text); + yamlTexts.add(yaml.dump(toPlainObject(JsonParser.parseString(text)))); + } + + Outcome base = load(fix, files, jsonTexts, loaderName, MetaDataSource.MetaDataFormat.JSON); + Outcome twin = load(fix, files, yamlTexts, loaderName, MetaDataSource.MetaDataFormat.YAML); + + assertEquals(fix.name + ": error codes (YAML twin vs JSON)", base.errorCodes, twin.errorCodes); + if (!base.errorCodes.isEmpty()) return; + assertEquals(fix.name + ": canonical serialization (YAML twin vs JSON)", + CanonicalJsonSerializer.canonicalSerialize(base.root).trim(), + CanonicalJsonSerializer.canonicalSerialize(twin.root).trim()); + if (fix.hasExpectedEffective) { + assertEquals(fix.name + ": effective serialization (YAML twin vs JSON)", + CanonicalJsonSerializer.canonicalSerializeEffective(base.root).trim(), + CanonicalJsonSerializer.canonicalSerializeEffective(twin.root).trim()); + } + } + + @Test + public void roundTrip() throws IOException { + if (!fix.hasExpected) return; + if (ConformanceExclusionsTest.roundTripExclusions().containsKey(fix.name)) return; + if (!canCompose(fix)) return; + Path expectedFile = fix.dir.resolve("expected.json"); + String expected = new String(Files.readAllBytes(expectedFile), StandardCharsets.UTF_8); + String loaderName = ConformanceTest.detectLoaderName(List.of(expectedFile)); + Outcome o = load(fix, List.of(expectedFile), List.of(expected), loaderName, + MetaDataSource.MetaDataFormat.JSON); + assertTrue(fix.name + ": expected.json did not load cleanly: " + o.errorCodes, + o.errorCodes.isEmpty()); + assertEquals(fix.name + ": expected.json does not round-trip", + expected.trim(), CanonicalJsonSerializer.canonicalSerialize(o.root).trim()); + } + + /** Fixtures whose providers this port cannot compose are skipped by both gates. */ + private static boolean canCompose(FixtureDiscovery.Fixture f) { + if (!f.hasProvidersJson) return true; + for (String required : f.requiredProviders) { + if (!ConformanceTest.AVAILABLE_PROVIDERS.contains(required)) return false; + } + boolean allTestOnly = !f.requiredProviders.isEmpty() + && f.requiredProviders.stream().allMatch(ConformanceTestProviders.TEST_PROVIDERS::containsKey); + return !allTestOnly; // composed-error fixtures are decided by compose(), not by a load + } + + private static final class Outcome { + final MetaRoot root; + final List errorCodes; + Outcome(MetaRoot root, List errorCodes) { + this.root = root; + this.errorCodes = errorCodes; + } + } + + private static Outcome load(FixtureDiscovery.Fixture f, List files, List texts, + String loaderName, MetaDataSource.MetaDataFormat format) { + LoaderOptions opts = LoaderOptions.create(false, false, true); + MetaDataLoader loader = new MetaDataLoader(opts, MetaDataLoader.SUBTYPE_MANUAL, loaderName); + if (f.hasProvidersJson && f.requiredProviders.contains("example-template-briefing")) { + com.metaobjects.registry.MetaDataRegistry reg = + com.metaobjects.registry.MetaDataRegistry.createWithCoreProviders(); + ConformanceTestProviders.BriefingTemplate.registerTypes(reg); + loader.setTypeRegistry(reg); + } + loader.init(); + MetaDataException thrown = null; + try { + List sources = new ArrayList<>(); + for (int i = 0; i < files.size(); i++) { + // Three-argument constructor: the two-argument one silently means JSON. + sources.add(new InMemoryStringSource(texts.get(i), + files.get(i).getFileName().toString(), format)); + } + loader.load(sources); + } catch (MetaDataException ex) { + thrown = ex; + } + List codes = new ArrayList<>(); + for (MetaDataException recorded : loader.getErrors()) { + codes.add(ConformanceTest.extractErrorCode(recorded)); + } + if (thrown != null) codes.add(ConformanceTest.extractErrorCode(thrown)); + return new Outcome(loader.getRoot(), codes); + } + + private static Object toPlainObject(JsonElement el) { + if (el == null || el.isJsonNull()) return null; + if (el.isJsonObject()) { + Map map = new LinkedHashMap<>(); + for (Map.Entry e : el.getAsJsonObject().entrySet()) { + map.put(e.getKey(), toPlainObject(e.getValue())); + } + return map; + } + if (el.isJsonArray()) { + List list = new ArrayList<>(); + for (JsonElement c : el.getAsJsonArray()) list.add(toPlainObject(c)); + return list; + } + com.google.gson.JsonPrimitive prim = el.getAsJsonPrimitive(); + if (prim.isBoolean()) return prim.getAsBoolean(); + if (prim.isNumber()) { + double d = prim.getAsDouble(); + if (d == Math.rint(d) && !Double.isInfinite(d)) { + long l = (long) d; + if ((double) l == d) return l; + } + return d; + } + return prim.getAsString(); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/io/json/CanonicalJsonSerializerTest.java b/server/java/metadata/src/test/java/com/metaobjects/io/json/CanonicalJsonSerializerTest.java index 9ee61cf12..ff616ce08 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/io/json/CanonicalJsonSerializerTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/io/json/CanonicalJsonSerializerTest.java @@ -324,17 +324,20 @@ public void testCanonicalSerializeEffectiveIncludesInheritedAttrs() { // ----------------------------------------------------------------------- @Test public void testChildInDifferentPackageDoesNotEmitPackageKeyUnlessAuthored() { - // Root is "acme::core"; child object lives in "acme::commerce" (different package). + // Root is "acme::core"; the only named top-level node lives in "acme::commerce". MetaRoot root = new MetaRoot("acme::core"); ValueMetaObject product = ValueMetaObject.create("acme::commerce::Product"); root.addChild(product); + // ADR-0059 document package rule: every named top-level node shares one package, + // so the root carries it and the node (unauthored) emits no redundant key. String json = CanonicalJsonSerializer.canonicalSerialize(root); - - // Effective-package difference alone must NOT emit a `package` key. - assertFalse("unauthored child must NOT emit a redundant 'package' key, got: " + json, - json.contains("\"package\": \"acme::commerce\"")); + assertTrue("root must carry the shared package, got: " + json, + json.startsWith("{\n \"package\": \"acme::commerce\"") + || json.contains("\"package\": \"acme::commerce\"")); + assertEquals("unauthored child must NOT repeat the package key, got: " + json, + 1, json.split("\"package\": \"acme::commerce\"", -1).length - 1); // When the author explicitly wrote the package (parser sets this flag), // it round-trips on the way out. diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/InheritedReferenceBindingTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/InheritedReferenceBindingTest.java new file mode 100644 index 000000000..1d1d800b0 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/InheritedReferenceBindingTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.loader; + +import com.metaobjects.MetaDataException; +import com.metaobjects.identity.MetaIdentity; +import com.metaobjects.identity.ReferenceIdentity; +import com.metaobjects.object.MetaObject; +import com.metaobjects.relationship.MetaRelationship; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * ADR-0059 Decision 7: an attribute a root-level object declares keeps the meaning it had + * where it was written when a packaged object inherits it, even when that package declares an + * object of the same short name. The root file also feeds a reporting {@code @via} hop, which + * validates before the resolve pass and so must apply the declaring package too. + */ +public class InheritedReferenceBindingTest { + + private static final String ROOT = "{\"metadata.root\":{\"children\":[" + + "{\"object.entity\":{\"name\":\"Customer\",\"children\":[" + + "{\"source.rdb\":{\"@table\":\"customers\"}}," + + "{\"field.long\":{\"name\":\"id\"}}," + + "{\"field.string\":{\"name\":\"region\"}}," + + "{\"identity.primary\":{\"name\":\"id\",\"@fields\":[\"id\"]}}]}}," + + "{\"object.entity\":{\"name\":\"BaseOrder\",\"abstract\":true,\"children\":[" + + "{\"field.long\":{\"name\":\"id\"}}," + + "{\"field.long\":{\"name\":\"customerId\"}}," + + "{\"identity.primary\":{\"name\":\"id\",\"@fields\":[\"id\"]}}," + + "{\"identity.reference\":{\"name\":\"customerRef\",\"@references\":\"Customer\",\"@fields\":[\"customerId\"]}}," + + "{\"relationship.association\":{\"name\":\"customer\",\"@objectRef\":\"Customer\",\"@cardinality\":\"one\"}}]}}" + + "]}}"; + + private static final String ACME = "{\"metadata.root\":{\"package\":\"acme\",\"children\":[" + + "{\"object.entity\":{\"name\":\"Customer\",\"children\":[" + + "{\"source.rdb\":{\"@table\":\"acme_customers\"}}," + + "{\"field.string\":{\"name\":\"code\"}}," + + "{\"identity.primary\":{\"name\":\"id\",\"@fields\":[\"code\"]}}]}}," + + "{\"object.entity\":{\"name\":\"Order\",\"extends\":\"::BaseOrder\",\"children\":[" + + "{\"source.rdb\":{\"@table\":\"orders\"}}," + + "{\"dimension.attribute\":{\"name\":\"region\",\"@of\":\"Customer.region\",\"@via\":\"Order.customer\"}}]}}" + + "]}}"; + + private static MetaDataLoader load() { + MetaDataLoader loader = new MetaDataLoader(LoaderOptions.create(false, false, true), + MetaDataLoader.SUBTYPE_MANUAL, ""); + loader.init(); + try { + loader.load(List.of(new InMemoryStringSource(ACME, "acme.json"), + new InMemoryStringSource(ROOT, "root.json"))); + } catch (MetaDataException ex) { + loader.addError(ex); + } + return loader; + } + + @Test + public void viaHopOfAnInheritedRelationshipResolvesInTheDeclaringPackage() { + MetaDataLoader loader = load(); + assertTrue("unexpected errors: " + loader.getErrors(), loader.getErrors().isEmpty()); + } + + @Test + public void inheritedReferencesBindTheRootObject() { + MetaDataLoader loader = load(); + assertTrue("unexpected errors: " + loader.getErrors(), loader.getErrors().isEmpty()); + MetaObject root = loader.getMetaObjectByName("Customer"); + MetaObject acme = loader.getMetaObjectByName("acme::Customer"); + MetaObject order = loader.getMetaObjectByName("acme::Order"); + assertNotNull(root); + assertNotNull(acme); + + MetaRelationship customer = null; + for (MetaRelationship r : order.getRelationships()) { + if ("customer".equals(r.getShortName())) customer = r; + } + assertNotNull(customer); + assertSame(root, References.refTarget(customer, MetaRelationship.ATTR_OBJECT_REF)); + assertSame(root, References.lookupCanonical(loader.getRoot(), + customer.getMetaAttr(MetaRelationship.ATTR_OBJECT_REF).getValueAsString())); + + ReferenceIdentity customerRef = null; + for (MetaIdentity id : order.getIdentities()) { + if (id instanceof ReferenceIdentity && "customerRef".equals(id.getShortName())) { + customerRef = (ReferenceIdentity) id; + } + } + assertNotNull(customerRef); + assertSame(root, References.refTarget(customerRef, MetaIdentity.ATTR_REFERENCES)); + assertSame(root, References.lookupCanonical(loader.getRoot(), + customerRef.getMetaAttr(MetaIdentity.ATTR_REFERENCES).getValueAsString())); + assertEquals("acme::Customer", acme.getName()); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/OverlayFileDefaultPackageTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/OverlayFileDefaultPackageTest.java new file mode 100644 index 000000000..41496c955 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/OverlayFileDefaultPackageTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.loader; + +import com.metaobjects.MetaDataException; +import com.metaobjects.io.json.CanonicalJsonSerializer; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * ADR-0059 R3/R10: a node records the default package of the FILE that declared it. A nested + * node merged in from an overlay file with a different default package resolves its bare + * {@code extends} in THAT file's package, not in the overlaid top-level node's package. + * Pinned against the TypeScript reference, which gives the same results for these two texts. + */ +public class OverlayFileDefaultPackageTest { + + private static final String A = "{\"metadata.root\":{\"package\":\"a\",\"children\":[" + + "{\"object.entity\":{\"name\":\"Order\",\"children\":[]}}," + + "{\"object.entity\":{\"name\":\"Base\",\"children\":[{\"field.long\":{\"name\":\"id\"}}]}}]}}"; + + private static String b(String ref) { + return "{\"metadata.root\":{\"package\":\"b\",\"children\":[" + + "{\"object.entity\":{\"name\":\"Base\",\"children\":[{\"field.long\":{\"name\":\"bid\"}}]}}," + + "{\"object.entity\":{\"name\":\"Order\",\"package\":\"a\",\"overlay\":true,\"children\":[" + + "{\"field.long\":{\"name\":\"x\",\"extends\":\"" + ref + "\"}}]}}]}}"; + } + + private static MetaDataLoader load(String ref) { + MetaDataLoader loader = new MetaDataLoader(LoaderOptions.create(false, false, true), + MetaDataLoader.SUBTYPE_MANUAL, ""); + loader.init(); + try { + loader.load(List.of(new InMemoryStringSource(A, "a.json"), + new InMemoryStringSource(b(ref), "b.json"))); + } catch (MetaDataException ex) { + loader.addError(ex); + } + return loader; + } + + @Test + public void bareExtendsResolvesInTheOverlayFilesPackage() { + MetaDataLoader loader = load("Base.bid"); + assertTrue("unexpected errors: " + loader.getErrors(), loader.getErrors().isEmpty()); + String json = CanonicalJsonSerializer.canonicalSerialize(loader.getRoot()); + assertTrue("extends must bind b::Base.bid and spell canonically, got: " + json, + json.contains("\"extends\": \"b::Base.bid\"")); + } + + @Test + public void bareExtendsDoesNotFallBackToTheOverlaidNodesPackage() { + MetaDataLoader loader = load("Base.id"); // a::Base.id exists; b::Base.id does not + List errors = loader.getErrors(); + assertEquals("errors: " + errors, 1, errors.size()); + assertEquals("ERR_UNRESOLVED_SUPER", errors.get(0).getCode().map(Enum::name).orElse("?")); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/parser/json/CanonicalJsonParserTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/parser/json/CanonicalJsonParserTest.java index 7171db745..f1344d27b 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/loader/parser/json/CanonicalJsonParserTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/parser/json/CanonicalJsonParserTest.java @@ -113,6 +113,9 @@ public void readsExtendsInheritance() { CanonicalJsonParser parser = new CanonicalJsonParser(loader, "extends-test.json"); parser.loadFromStream(new ByteArrayInputStream(canonical.getBytes(StandardCharsets.UTF_8))); + // extends is bound by the loader's deferred pass (ADR-0059), never at parse time. + loader.drainPendingExtends(); + MetaData concrete = loader.getRoot().getChildOfType("object", "acme::ConcreteProduct"); assertNotNull("ConcreteProduct should exist", concrete); assertEquals("ConcreteProduct should be subType entity", "entity", concrete.getSubType()); diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java index 4f2171143..a6b119a63 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/Issue368RelationshipReferenceLadderTest.java @@ -58,9 +58,14 @@ private MetaObject loadMatch() { return (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match"); } + private static MetaObject teamOf(MetaObject holder) { + return ((com.metaobjects.MetaRoot) holder.getParent()).findObject("repro::Team"); + } + @Test public void enumeratesEveryCandidateReferenceForTheTarget() { - List candidates = RelationshipReferences.referenceCandidatesFor(loadMatch(), "Team"); + MetaObject match = loadMatch(); + List candidates = RelationshipReferences.referenceCandidatesFor(match, teamOf(match)); assertEquals(2, candidates.size()); assertEquals("homeTeamRef", candidates.get(0).getShortName()); assertEquals("awayTeamRef", candidates.get(1).getShortName()); @@ -70,16 +75,16 @@ public void enumeratesEveryCandidateReferenceForTheTarget() { public void namePairingResolvesEachAssociationToItsOwnReference() { MetaObject match = loadMatch(); assertEquals("homeTeamRef", - RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team").getShortName()); + RelationshipReferences.resolveRelationshipReference(match, "homeTeam", teamOf(match), null).getShortName()); assertEquals("awayTeamRef", - RelationshipReferences.resolveRelationshipReference(match, "awayTeam", "Team").getShortName()); + RelationshipReferences.resolveRelationshipReference(match, "awayTeam", teamOf(match), null).getShortName()); } @Test public void sourceRefFieldWinsOverNamePairing() { MetaObject match = loadMatch(); assertEquals("awayTeamRef", - RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId") + RelationshipReferences.resolveRelationshipReference(match, "homeTeam", teamOf(match), "awayTeamId") .getShortName()); } @@ -89,7 +94,7 @@ public void sourceRefFieldMissShortCircuitsRatherThanFallingThroughToNamePairing // return null -- it must NOT fall through to step 3 (name-pairing), even // though "homeTeam" would otherwise pair cleanly with "homeTeamRef". MetaObject match = loadMatch(); - assertNull(RelationshipReferences.resolveRelationshipReference(match, "homeTeam", "Team", "doesNotExist")); + assertNull(RelationshipReferences.resolveRelationshipReference(match, "homeTeam", teamOf(match), "doesNotExist")); } @Test @@ -110,7 +115,7 @@ public void aSingleCandidateResolvesRegardlessOfName() { assertTrue(loader.getErrors().isEmpty()); MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match"); assertEquals("anythingAtAll", - RelationshipReferences.resolveRelationshipReference(match, "champion", "Team").getShortName()); + RelationshipReferences.resolveRelationshipReference(match, "champion", teamOf(match), null).getShortName()); } @Test @@ -141,7 +146,7 @@ public void unpairableNamesReturnNullRatherThanGuessing() { // expected -- rule (e) rejects this model } MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match"); - assertNull(RelationshipReferences.resolveRelationshipReference(match, "winner", "Team")); + assertNull(RelationshipReferences.resolveRelationshipReference(match, "winner", teamOf(match), null)); } @Test @@ -169,6 +174,6 @@ public void suffixStrippingNeverAppliesToTheRelationshipName() { // expected -- "valid" must not pair with "valRef" } MetaObject match = (MetaObject) loader.getRoot().getChildOfType("object", "repro::Match"); - assertNull(RelationshipReferences.resolveRelationshipReference(match, "valid", "Team")); + assertNull(RelationshipReferences.resolveRelationshipReference(match, "valid", teamOf(match), null)); } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java b/server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java index 229f9f6a1..c829ae2cd 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/relationship/M2MSlimVocabularyTest.java @@ -108,11 +108,11 @@ private MetaObject obj(MetaDataLoader loader, String entityFqn) { public void accessorsReadSlimVocabulary() { MetaDataLoader loader = loadThrough(DIRECTED, "directed.json"); MetaRelationship r = rel(loader, "acme::User", "follows"); - assertEquals("Follow", r.getThrough()); + assertEquals("acme::Follow", r.getThrough()); assertEquals("followerId", r.getSourceRefField()); assertFalse(r.isSymmetric()); assertEquals("many", r.getCardinality()); - assertEquals("User", r.getObjectRef()); + assertEquals("acme::User", r.getObjectRef()); } @Test @@ -120,7 +120,7 @@ public void symmetricAccessorReadsBoolean() { MetaDataLoader loader = loadThrough(SYMMETRIC, "symmetric.json"); MetaRelationship r = rel(loader, "acme::User", "friends"); assertTrue(r.isSymmetric()); - assertEquals("Friendship", r.getThrough()); + assertEquals("acme::Friendship", r.getThrough()); assertNull(r.getSourceRefField()); } diff --git a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java index 56dea23b5..7930ef690 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportingTest.java @@ -117,13 +117,13 @@ public void dimensionAccessors() throws IOException { MetaDimension program = member(purchase, MetaDimension.class, "program"); assertFalse(program.isTime()); - assertEquals("Purchase.programId", program.getOf()); + assertEquals("acme::shop::Purchase.programId", program.getOf()); assertNull(program.getVia()); assertEquals(List.of(), program.getGrains()); MetaDimension title = member(purchase, MetaDimension.class, "programTitle"); - assertEquals("Program.title", title.getOf()); - assertEquals("Purchase.program", title.getVia()); + assertEquals("acme::shop::Program.title", title.getOf()); + assertEquals("acme::shop::Purchase.program", title.getVia()); MetaDimension purchasedAt = member(purchase, MetaDimension.class, "purchasedAt"); assertTrue(purchasedAt.isTime()); @@ -138,7 +138,7 @@ public void dimensionAccessors() throws IOException { public void aBareStringOfIsAOneColumnList() throws IOException { MetaObject purchase = object(loadFixture("reporting-vocabulary"), "Purchase"); MetaMeasure purchases = member(purchase, MetaMeasure.class, "purchases"); - assertEquals(List.of("Purchase.id"), purchases.getOfColumns()); + assertEquals(List.of("acme::shop::Purchase.id"), purchases.getOfColumns()); assertEquals(ReportingConstants.AGG_COUNT, purchases.getAgg()); assertFalse(purchases.isDistinct()); assertEquals("active", purchases.getSegmentName()); @@ -172,8 +172,8 @@ public void aBareStringWithACommaStaysOneItemAndAJsonArrayStillSplitsIntoItems() public void aListOfIsTheTupleForm() throws IOException { MetaObject workout = object(loadFixture("reporting-vocabulary"), "WorkoutEvent"); MetaMeasure days = member(workout, MetaMeasure.class, "daysEngaged"); - assertEquals(List.of("WorkoutEvent.programId", "WorkoutEvent.customerEmail", "WorkoutEvent.weekNumber", - "WorkoutEvent.dayNumber"), + assertEquals(List.of("acme::shop::WorkoutEvent.programId", "acme::shop::WorkoutEvent.customerEmail", "acme::shop::WorkoutEvent.weekNumber", + "acme::shop::WorkoutEvent.dayNumber"), days.getOfColumns()); assertTrue(days.isDistinct()); assertFalse(days.isRatio()); @@ -204,7 +204,7 @@ public void membersDeclaredOnAnAbstractBaseResolveOnTheConcreteEntity() throws I MetaRoot root = loadFixture("reporting-inherited-members"); MetaObject workout = object(root, "WorkoutEvent"); MetaDimension occurredAt = member(workout, MetaDimension.class, "occurredAt"); - assertEquals("BaseEvent.occurredAt", occurredAt.getOf()); + assertEquals("acme::shop::BaseEvent.occurredAt", occurredAt.getOf()); assertEquals(List.of("day", "week"), occurredAt.getGrains()); assertNotNull(member(workout, MetaMeasure.class, "events")); // Same-named field and dimension coexist on the base: children are keyed by type + name. @@ -220,7 +220,7 @@ public void membersDeclaredOnAnAbstractBaseResolveOnTheConcreteEntity() throws I public void reportAccessors() throws IOException { MetaRoot root = loadFixture("reporting-vocabulary"); MetaObject daily = object(root, "DailyRevenue"); - assertEquals("Purchase", ReportAccessors.reportFrom(daily)); + assertEquals("acme::shop::Purchase", ReportAccessors.reportFrom(daily)); assertEquals(List.of(new ReportDimensionItem("purchasedAt", "day")), ReportAccessors.reportDimensionItems(daily)); assertEquals(List.of("purchases", "revenue"), ReportAccessors.reportMeasureNames(daily)); @@ -233,8 +233,8 @@ public void reportAccessors() throws IOException { @Test public void reportSpineIsTheDeclaredPathAndNullWhenNoneIsDeclared() throws IOException { MetaRoot root = loadFixture("reporting-spine-and-default"); - assertEquals("Purchase.program", ReportAccessors.reportSpine(object(root, "ProgramSales"))); - assertEquals("Purchase.program.catalog", ReportAccessors.reportSpine(object(root, "CatalogSales"))); + assertEquals("acme::shop::Purchase.program", ReportAccessors.reportSpine(object(root, "ProgramSales"))); + assertEquals("acme::shop::Purchase.program.catalog", ReportAccessors.reportSpine(object(root, "CatalogSales"))); assertNull(ReportAccessors.reportSpine(object(root, "SalesByDay"))); } diff --git a/server/java/metadata/src/test/java/com/metaobjects/template/TemplateLoaderTest.java b/server/java/metadata/src/test/java/com/metaobjects/template/TemplateLoaderTest.java index c1175b645..edc38f521 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/template/TemplateLoaderTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/template/TemplateLoaderTest.java @@ -100,7 +100,7 @@ public void promptTemplateLoadsAndExposesTypedAttrs() { assertTrue("expected PromptTemplate instance", node instanceof PromptTemplate); PromptTemplate p = (PromptTemplate) node; - assertEquals("PromptVo", p.getPayloadRef()); + assertEquals("acme::ai::PromptVo", p.getPayloadRef()); assertEquals("ai/prompt", p.getTextRef()); // @format is not set in PROMPT_FIXTURE → the default ("text") applies. assertEquals(TemplateConstants.FORMAT_DEFAULT, p.getFormat()); diff --git a/server/java/metadata/src/test/java/com/metaobjects/template/TemplateToolcallTest.java b/server/java/metadata/src/test/java/com/metaobjects/template/TemplateToolcallTest.java index 872ef1753..8b1ed0da4 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/template/TemplateToolcallTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/template/TemplateToolcallTest.java @@ -110,7 +110,7 @@ public void toolcallLoadsWithRequiredAttrs() { ToolcallTemplate tc = (ToolcallTemplate) node; assertEquals("lookup_weather", tc.getToolName()); - assertEquals("WeatherToolOutput", tc.getPayloadRef()); + assertEquals("acme::tools::WeatherToolOutput", tc.getPayloadRef()); assertEquals("tools-team", tc.getOwner()); assertEquals("0.7.0", tc.getSince()); } diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/M2MResolver.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/M2MResolver.java index 5fbe01c03..edf124d1a 100644 --- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/M2MResolver.java +++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/M2MResolver.java @@ -79,8 +79,8 @@ public Collection resolve(ObjectConnection conn, Object source, MetaObject so M2MFields fields = M2MFields.derive(rel, sourceMeta, root); - MetaObject junction = mustGetEntity(root, rel.getThrough()); - MetaObject target = mustGetEntity(root, rel.getObjectRef()); + MetaObject junction = mustGetEntity(rel, MetaRelationship.ATTR_THROUGH, rel.getThrough()); + MetaObject target = mustGetEntity(rel, MetaRelationship.ATTR_OBJECT_REF, rel.getObjectRef()); String sourcePkField = primaryKeyField(sourceMeta); Object sourceId = readField(sourceMeta, source, sourcePkField); @@ -159,25 +159,9 @@ private static boolean keyEquals(Object a, Object b) { return String.valueOf(a).equals(String.valueOf(b)); } - private static MetaObject mustGetEntity(MetaRoot root, String name) { - // ADR-0041: a FULLY-QUALIFIED ref (contains "::") resolves EXACTLY on the - // object's package-qualified name — never a bare-tail fallback (the closed - // bug: an FQN @objectRef / @through binding a same-named entity in the WRONG - // package). A bare ref matches the entity's short name. - boolean fqn = name != null && name.contains(com.metaobjects.MetaData.PKG_SEPARATOR); - // ADR-0039: root-level entity scan — root is never extended, so own children - // is correct (entities are declared directly under root, not inherited into it). - for (var child : root.getChildren(MetaObject.class, false)) { - if (fqn ? name.equals(child.getName()) : stripPackage(name).equals(child.getShortName())) { - return child; - } - } - throw new IllegalStateException("Entity '" + name + "' not found in model root"); - } - - private static String stripPackage(String name) { - if (name == null) return null; - int idx = name.lastIndexOf(com.metaobjects.MetaData.PKG_SEPARATOR); - return (idx >= 0) ? name.substring(idx + com.metaobjects.MetaData.PKG_SEPARATOR.length()) : name; + private static MetaObject mustGetEntity(MetaRelationship rel, String attr, String name) { + MetaObject found = com.metaobjects.loader.References.refTarget(rel, attr); + if (found == null) throw new IllegalStateException("Entity '" + name + "' not found in model root"); + return found; } } diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index dc24416c3..dc1c80a19 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -90,7 +90,11 @@ summarise_requirements, ) from metaobjects.codegen.config import GenConfig -from metaobjects.codegen.overwrite_policy import has_hash_manifest, read_generated_hash +from metaobjects.codegen.overwrite_policy import ( + has_hash_manifest, + is_pristine_generated, + read_generated_hash, +) from metaobjects.codegen.project_config import ( CONFIG_FILENAME, ConfigError, @@ -121,12 +125,13 @@ unsatisfied_requires, ) from metaobjects.codegen.project_import import import_project_symbol +from metaobjects.codegen.orphan_sweep import refused_orphan_message, sweep_orphans from metaobjects.codegen.runner import run_gen from metaobjects.codegen import eject as owned from metaobjects.codegen.generators.render_helper_generator import ( _derive_payload_field_tree, ) -from metaobjects.codegen.value_objects import pkg_of, resolve_payload_vo +from metaobjects.codegen.value_objects import bound_payload_vo from metaobjects.meta.template import template_constants as tc from metaobjects.naming import ( COLUMN_NAMING_STRATEGIES, @@ -866,9 +871,16 @@ def _run_suite( baseline: str = "default", refused_out: list[str] | None = None, select: Callable[[str], bool] | None = None, + overwrite_hand_edits: frozenset[str] = frozenset(), + overwrite_paths_out: set[str] | None = None, ) -> list[str]: """Run a generator suite against an ALREADY-LOADED ``root`` into ``out_dir``. + ``overwrite_hand_edits`` names the generators (by ``.name``) declared + ``handEdits: overwrite``: a hand-edited file of theirs is replaced with fresh output + instead of refused. ``overwrite_paths_out`` collects every path those generators emitted, + which is how ``verify --codegen`` knows that ``gen`` would replace an edit. + Split out from :func:`_generate` so a single gen invocation can load the metadata once and run multiple passes against it (the default Python suite + the ``--template-spec`` pass). See :func:`_generate` for the parameter @@ -910,6 +922,7 @@ def _run_suite( # exists for is the pre-manifest one, so it reaches every real gen path and no # verify path (those pass gen_state_dir=None and record nothing at all). baseline=baseline, + overwrite_hand_edits=overwrite_hand_edits, ) # No default suite — see NO_GENERATORS_SELECTED. A caller that names none gets a # usage error and an empty out dir, never a shape this CLI picked. @@ -917,6 +930,10 @@ def _run_suite( raise NoGeneratorsSelectedError suite = generators result = run_gen(config, root, generators=suite, entity_filter=entity_filter, select=select) + if overwrite_paths_out is not None: + overwrite_paths_out.update( + path for path, by in result.generated_by.items() if by in overwrite_hand_edits + ) for warning in result.warnings: print(f"warning: {warning}") # A refusal is a FAILED gate, and the return value carries only what was WRITTEN — so @@ -1386,6 +1403,7 @@ def _run_gen_targets( column_naming: str = DEFAULT_COLUMN_NAMING, selection: list[str] | None = None, targeted_out: set[str] | None = None, + overwrite_paths_out: set[str] | None = None, ) -> tuple[list[str], list[str]]: """Run each target's declared generators into its ``outDir``. Returns (all_written, errors). @@ -1413,6 +1431,10 @@ def _run_gen_targets( ``targeted_out`` collects the ``outDir`` of every target with a declared entry left to run after ``selection``: decided from the config, whatever the entries then emit. + + A generator entry written as ``{name: ..., handEdits: overwrite}`` replaces its + hand-edited files instead of refusing them; ``overwrite_paths_out`` collects the paths + such entries emitted (see :func:`_run_suite`). """ all_written: list[str] = [] seen: dict[str, str] = {} # full path -> target name @@ -1447,7 +1469,7 @@ def selected(gen: Generator, token: str) -> bool: # Resolve every target first, so a bad entry or an undeclared --generators name fails # the run before any target writes. - plan: list[tuple[TargetConfig, list[Generator], list[Generator]]] = [] + plan: list[tuple[TargetConfig, list[Generator], list[Generator], frozenset[str]]] = [] for t in targets: gens: list[Generator] = [] commas = [tok for tok in t.generators or [] if "," in tok] @@ -1463,7 +1485,12 @@ def selected(gen: Generator, token: str) -> bool: if gen_errors: errors.extend(f"target '{t.name}': {m}" for m in gen_errors) continue + overwrite = frozenset( + g.name for g, tok in zip(gens, t.generators) if tok in t.overwrite_hand_edits + ) gens = [g for g, tok in zip(gens, t.generators) if selected(g, tok)] + else: + overwrite = frozenset() spec_gens: list[Generator] = [] spec_path = config.template_spec_for(t) if spec_path is not None: @@ -1473,7 +1500,7 @@ def selected(gen: Generator, token: str) -> bool: errors.append(f"target '{t.name}': {spec_err}") continue spec_gens = [g for g in spec_gens if selected(g, g.name)] - plan.append((t, gens, spec_gens)) + plan.append((t, gens, spec_gens, overwrite)) if selection is not None: undeclared = [n for n in selection if n not in matched] if undeclared: @@ -1487,7 +1514,7 @@ def selected(gen: Generator, token: str) -> bool: from metaobjects.render.renderer import RenderError - for t, gens, spec_gens in plan: + for t, gens, spec_gens, overwrite in plan: if not gens and not spec_gens: print(f"target '{t.name}' declares no generators; nothing written") continue @@ -1501,6 +1528,7 @@ def selected(gen: Generator, token: str) -> bool: root, out_dir, gens, t.entities, gen_state_dir=gen_state_dir, column_naming=column_naming, project_root=project_root, baseline=baseline, refused_out=refused_out, select=select, + overwrite_hand_edits=overwrite, overwrite_paths_out=overwrite_paths_out, ) if spec_gens: written = written + _run_suite( @@ -1603,16 +1631,21 @@ def _cmd_gen_config(args: argparse.Namespace) -> int: return 2 refused: list[str] = [] + targeted: set[str] = set() + baseline = getattr(args, "baseline", None) or "default" + gen_state_dir = gen_state_dir_for(config.metadata_dir()) + project_root = str(project_root_for(config.metadata_dir())) written, errors = _run_gen_targets( config, targets, root, - gen_state_dir=gen_state_dir_for(config.metadata_dir()), - project_root=str(project_root_for(config.metadata_dir())), - baseline=getattr(args, "baseline", None) or "default", + gen_state_dir=gen_state_dir, + project_root=project_root, + baseline=baseline, refused_out=refused, select=collection.in_scope, build_ctx=GeneratorBuildContext(template_root), column_naming=column_naming, selection=selection, + targeted_out=targeted, ) if errors: for msg in errors: @@ -1620,8 +1653,15 @@ def _cmd_gen_config(args: argparse.Namespace) -> int: return 1 for path in written: print(path) + removed = _sweep_orphans_after_gen( + args, config, targets, targeted, selection, baseline, gen_state_dir, project_root, + emitted={*written, *refused}, + ) + for path in removed: + print(f"removed {path}") print( f"metaobjects gen: wrote {len(written)} file(s) across {len(targets)} target(s)." + + (f" Removed {len(removed)} file(s) no generator emits any more." if removed else "") ) if refused: # A refusal is a failed gate, matching TypeScript's `meta gen` (exit 1). Python and @@ -1637,13 +1677,84 @@ def _cmd_gen_config(args: argparse.Namespace) -> int: return 0 +def _sweep_orphans_after_gen( + args: argparse.Namespace, + config: ProjectConfig, + targets: list[TargetConfig], + targeted: set[str], + selection: list[str] | None, + baseline: str, + gen_state_dir: str, + project_root: str, + *, + emitted: set[str], +) -> list[str]: + """Delete what this run no longer generates (``codegen/orphan_sweep.py``) and return + the paths removed. Only a run of the WHOLE declared output into a directory sweeps it. + + ``--generators`` and ``--entities`` narrow the output by construction, so every file of + the part left out would look orphaned: those runs sweep nothing and say so when there + was something to sweep. ``--target`` sweeps a directory only when every target writing + into it ran. ``--baseline=adopt`` writes nothing, so it deletes nothing either. + """ + if baseline == "adopt": + return [] + ran = {t.name for t in targets} + out_dirs = sorted( + out for out in targeted + if all(t.name in ran for t in config.targets if config.out_dir_for(t) == out) + ) + configured = frozenset(config.out_dir_for(t) for t in config.targets) + narrowed = selection is not None or _parse_entities(getattr(args, "entities", None)) is not None + if narrowed: + pending = sweep_orphans( + gen_state_dir, project_root, out_dirs, emitted, dry_run=True, exclude_nested=configured) + if pending.removed or pending.refused: + print( + "warning: Skipped orphan cleanup: this run was narrowed by --generators or " + "--entities, so it cannot tell a file the rest of the list writes from one no " + "generator emits any more. Run `metaobjects gen` without them to delete " + "generated files that are no longer generated." + ) + return [] + result = sweep_orphans( + gen_state_dir, project_root, out_dirs, emitted, + overwrite_dirs=frozenset(out for out in out_dirs if _overwrites_hand_edits(config, out)), + exclude_nested=configured, + ) + for key in result.discarded: + print( + f"warning: deleted hand-edited {key}, which no generator emits any more " + "(handEdits: overwrite)" + ) + if result.refused: + print(f"warning: {refused_orphan_message(result.refused)}") + return result.removed + + +def _overwrites_hand_edits(config: ProjectConfig, out_dir: str) -> bool: + """True when every generator writing into ``out_dir`` is declared ``handEdits: overwrite``. + + The manifest does not record which generator wrote a file, so an edited orphan can be + deleted only when no generator sharing its directory asked to keep hand edits. A + ``templateSpec`` pass has no entry to carry the setting, so it always keeps. + """ + sharing = [t for t in config.targets if config.out_dir_for(t) == out_dir] + return bool(sharing) and all( + t.generators + and config.template_spec_for(t) is None + and all(tok in t.overwrite_hand_edits for tok in t.generators) + for t in sharing + ) + + #: Never compared: interpreter droppings, not generated artifacts. A committed #: ``__pycache__`` would otherwise read as drift on every run. _UNCOMPARED_DIRS = {"__pycache__", ".gen-state", ".metaobjects"} _UNCOMPARED_SUFFIXES = {".pyc", ".pyo"} -def _relative_set(root: Path) -> dict[str, str]: +def _relative_set(root: Path, nested: list[Path] | None = None) -> dict[str, str]: """Map every generated-artifact file under ``root`` to its content, keyed by rel path. Deliberately NOT scoped to ``*.py``. It was, with a note saying to broaden it "if a @@ -1660,7 +1771,7 @@ def _relative_set(root: Path) -> dict[str, str]: if not root.exists(): return files for p in sorted(root.rglob("*")): - if not p.is_file(): + if not p.is_file() or any(p.is_relative_to(n) for n in nested or []): continue rel = p.relative_to(root) if any(part in _UNCOMPARED_DIRS for part in rel.parts): @@ -1749,6 +1860,20 @@ def _temp_slot_for(temp_root: Path, real_outdir: str, config_dir: Path) -> str: return str(temp_root / rel) +@dataclasses.dataclass(frozen=True) +class _DirReport: + """One committed outDir diffed against a fresh regen (``verify --codegen`` config mode).""" + + names: str + expected_count: int + changed: list[str] + missing: list[str] + extra: list[str] + edited_extra: list[str] + replaced: list[str] + overwrite_dir: bool + + def _verify_codegen_config(args: argparse.Namespace) -> int: """``verify --codegen`` with no positional ``metadata_dir`` → config mode (#267). @@ -1845,10 +1970,20 @@ def _verify_codegen_config(args: argparse.Namespace) -> int: # Map each unique real outDir to ONE temp slot name (shared real dir => shared slot). real_outdirs = sorted({config.out_dir_for(t) for t in closure}) - - def compare(naming: str) -> tuple[list[str], list[tuple[str, int, list[str], list[str], list[str]]]]: - """Regenerate the closure with ``naming`` and diff it against every committed outDir: - ``(errors, [(target names, expected count, changed, missing, extra)])``.""" + # Every configured outDir, run or not: one nested in a verified outDir is its own + # target's, as gen's sweep treats it, so its files are never this outDir's extras. + configured_outdirs = sorted({config.out_dir_for(t) for t in config.targets}) + + def compare(naming: str) -> tuple[list[str], list[_DirReport]]: + """Regenerate the closure with ``naming`` and diff it against every committed outDir. + + ``edited_extra`` is the subset of ``extra`` edited since ``gen`` wrote it. ``gen`` + deletes an untouched ``extra`` file and refuses an edited one + (``codegen/orphan_sweep.py``), so the two need different remedies, unless every + generator writing into the directory is declared ``handEdits: overwrite`` + (``overwrite_dir``), when ``gen`` deletes the edited one too. ``replaced`` is the + subset of ``changed`` that was edited by hand and that ``gen`` replaces anyway, + because its generator is declared ``handEdits: overwrite``.""" with tempfile.TemporaryDirectory() as temp_root, tempfile.TemporaryDirectory() as declared_root: temp_for = { real: _temp_slot_for(Path(temp_root), real, config.config_dir) for real in real_outdirs @@ -1866,6 +2001,7 @@ def compare(naming: str) -> tuple[list[str], list[tuple[str, int, list[str], lis # template root differently would regenerate render-helper against another # directory and convict the committed output of being stale. targeted: set[str] = set() + overwrite_paths: set[str] = set() regen = dict( select=collection.in_scope, build_ctx=GeneratorBuildContext(template_root), @@ -1874,7 +2010,8 @@ def compare(naming: str) -> tuple[list[str], list[tuple[str, int, list[str], lis column_naming=naming, ) _written, errors = _run_gen_targets( - config, remapped, root, selection=selection, targeted_out=targeted, **regen) + config, remapped, root, selection=selection, targeted_out=targeted, + overwrite_paths_out=overwrite_paths, **regen) # A narrowed run's paths get the content the whole declared list writes, which is # what `gen` committed: a second regen of the un-narrowed closure supplies it. declared_for: dict[str, str] = {} @@ -1890,21 +2027,28 @@ def compare(naming: str) -> tuple[list[str], list[tuple[str, int, list[str], lis if errors: return errors, [] - reports: list[tuple[str, int, list[str], list[str], list[str]]] = [] + reports: list[_DirReport] = [] for real_outdir in real_outdirs: tmp_slot = temp_for[real_outdir] if narrowed and tmp_slot not in targeted: continue - expected = _relative_set(Path(tmp_slot)) - committed = _relative_set(Path(real_outdir)) + nested = [ + o for o in configured_outdirs if o != real_outdir and Path(o).is_relative_to(real_outdir) + ] + inside = [o for o in nested if o in temp_for] + expected = _relative_set(Path(tmp_slot), [Path(temp_for[o]) for o in inside]) + committed = _relative_set(Path(real_outdir), [Path(o) for o in nested]) if narrowed: - declared = _relative_set(Path(declared_for[real_outdir])) + declared = _relative_set( + Path(declared_for[real_outdir]), [Path(declared_for[o]) for o in inside]) expected = {k: declared[k] for k in expected if k in declared} changed = sorted( k for k in expected if k in committed and expected[k] != committed[k] ) missing = sorted(k for k in expected if k not in committed) + project_gen_state = gen_state_dir_for(config.metadata_dir()) + prefix = _out_prefix(real_outdir, project_root_for(config.metadata_dir())) if narrowed: extra: list[str] = [] else: @@ -1915,16 +2059,32 @@ def compare(naming: str) -> tuple[list[str], list[tuple[str, int, list[str], lis # PER-TARGET prefix, because that is the whole point on this rung: two # targets with different outDirs share one manifest, and the key that tells # their identically-named files apart is the one anchored on the project. - is_ours = _is_ours_for( - gen_state_dir_for(config.metadata_dir()), - _out_prefix(real_outdir, project_root_for(config.metadata_dir())), - ) + is_ours = _is_ours_for(project_gen_state, prefix) extra = sorted(k for k in committed if k not in expected and is_ours(k)) + # The sweep's own test, on the same evidence: with no manifest nothing is + # proven untouched, and every extra file reads as edited. + edited_extra = [ + k for k in extra + if not is_pristine_generated( + project_gen_state, os.path.join(prefix, k), committed[k], legacy_rel_path=k + ) + ] + + replaced = [ + k for k in changed + if os.path.join(tmp_slot, k) in overwrite_paths + and not is_pristine_generated( + project_gen_state, os.path.join(prefix, k), committed[k], legacy_rel_path=k + ) + ] names = "+".join( t.name for t in closure if config.out_dir_for(t) == real_outdir ) - reports.append((names, len(expected), changed, missing, extra)) + reports.append(_DirReport( + names, len(expected), changed, missing, extra, edited_extra, replaced, + _overwrites_hand_edits(config, real_outdir), + )) return [], reports errors, reports = compare(column_naming) @@ -1934,21 +2094,39 @@ def compare(naming: str) -> tuple[list[str], list[tuple[str, int, list[str], lis return 1 failed = False - for names, expected_count, changed, missing, extra in reports: - if _report_clean(changed, missing, extra): - print(f"metaobjects verify [{names}]: in sync ({expected_count} file(s)).") + regenerable = False + edited_orphans: list[str] = [] + for rep in reports: + if _report_clean(rep.changed, rep.missing, rep.extra): + print(f"metaobjects verify [{rep.names}]: in sync ({rep.expected_count} file(s)).") continue failed = True print( - f"error: [{names}] generated code is out of sync with metadata.", + f"error: [{rep.names}] generated code is out of sync with metadata.", file=sys.stderr, ) - for k in changed: - print(f" drifted: {k}", file=sys.stderr) - for k in missing: + for k in rep.changed: + if k in rep.replaced: + print(f" drifted: {k} (edited by hand; its generator sets handEdits: overwrite, " + "so gen replaces it with fresh output)", file=sys.stderr) + else: + print(f" drifted: {k}", file=sys.stderr) + for k in rep.missing: print(f" missing: {k}", file=sys.stderr) - for k in extra: - print(f" extra: {k}", file=sys.stderr) + kept_orphans = [] if rep.overwrite_dir else rep.edited_extra + for k in rep.extra: + if k in kept_orphans: + print(f" extra: {k} (no generator emits it any more, and it was edited by hand)", + file=sys.stderr) + elif k in rep.edited_extra: + print(f" extra: {k} (no generator emits it any more, and it was edited by hand; " + "handEdits is overwrite, so gen deletes it)", file=sys.stderr) + else: + print(f" extra: {k} (no generator emits it any more; gen deletes it)", + file=sys.stderr) + edited_orphans.extend(kept_orphans) + regenerable = regenerable or bool( + rep.changed or rep.missing or len(rep.extra) > len(kept_orphans)) if not failed: return 0 @@ -1965,12 +2143,19 @@ def compare(naming: str) -> tuple[list[str], list[tuple[str, int, list[str], lis finally: root.naming = used if not other_errors and all( - _report_clean(changed, missing, extra) - for _names, _count, changed, missing, extra in other_reports + _report_clean(rep.changed, rep.missing, rep.extra) for rep in other_reports ): print(naming_differs_message(candidate, used), file=sys.stderr) return 1 - print("regenerate (metaobjects gen) and commit the result.", file=sys.stderr) + if regenerable: + print("regenerate (metaobjects gen) and commit the result.", file=sys.stderr) + if edited_orphans: + # `gen` refuses to delete these, so "regenerate" alone would loop. + print( + "For the extra file(s) edited by hand, `metaobjects gen` will NOT delete them: " + "delete each one, or move your edits into a file of your own first.", + file=sys.stderr, + ) return 1 @@ -2130,9 +2315,7 @@ def _verify_templates(args: argparse.Namespace) -> int: print(f"error: [{tmpl.name}] missing @payloadRef.", file=sys.stderr) error_count += 1 continue - # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef - # resolves in ITS OWN package first. - vo = resolve_payload_vo(root, payload_ref, pkg_of(tmpl)) + vo = bound_payload_vo(tmpl, tc.TEMPLATE_ATTR_PAYLOAD_REF) if vo is None: print( f"error: [{tmpl.name}] @payloadRef '{payload_ref}' did not " diff --git a/server/python/src/metaobjects/codegen/config.py b/server/python/src/metaobjects/codegen/config.py index 508c626a3..6132121be 100644 --- a/server/python/src/metaobjects/codegen/config.py +++ b/server/python/src/metaobjects/codegen/config.py @@ -60,6 +60,9 @@ class GenConfig: # refusal used to prescribe committing a manifest nothing had yet produced). A value # this port does not implement is REFUSED in __post_init__ rather than ignored. baseline: str = "default" + # The names of the generators declared ``handEdits: overwrite``: a hand edit to one of + # their files is replaced with fresh output (and re-baselined) instead of refused. + overwrite_hand_edits: frozenset[str] = frozenset() provided_enum_namespace: str | None = None provided_enum_packages: dict[str, str] = field(default_factory=dict) diff --git a/server/python/src/metaobjects/codegen/generators/extractor_generator.py b/server/python/src/metaobjects/codegen/generators/extractor_generator.py index 792e57105..47781a702 100644 --- a/server/python/src/metaobjects/codegen/generators/extractor_generator.py +++ b/server/python/src/metaobjects/codegen/generators/extractor_generator.py @@ -45,7 +45,7 @@ inbound_templates, response_shape, ) -from metaobjects.codegen.value_objects import is_field_required, model_class_name, pkg_of +from metaobjects.codegen.value_objects import is_field_required, model_class_name from metaobjects.meta.core.field import field_constants as fc from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.meta.meta_data import MetaData @@ -136,7 +136,7 @@ def render_extractor( ``@responseFormat`` is a closed json|xml set, so every responding prompt has a tolerant ``extract_lenient_*_with_loader`` to sit over.""" # ADR-0052: the extract tier reads a REPLY, so it binds @responseRef. - shape = response_shape(root, template, pkg_of(template)) + shape = response_shape(template) if shape is None: return None payload = shape.vo diff --git a/server/python/src/metaobjects/codegen/generators/find_inbound.py b/server/python/src/metaobjects/codegen/generators/find_inbound.py index ff34658b7..c5dd42ba2 100644 --- a/server/python/src/metaobjects/codegen/generators/find_inbound.py +++ b/server/python/src/metaobjects/codegen/generators/find_inbound.py @@ -19,7 +19,7 @@ from dataclasses import dataclass -from metaobjects.codegen.value_objects import resolve_payload_vo +from metaobjects.codegen.value_objects import bound_payload_vo from metaobjects.meta.meta_data import MetaData from metaobjects.meta.template import template_constants as tc from metaobjects.shared.base_types import TYPE_TEMPLATE @@ -68,14 +68,14 @@ def inbound_templates(root: MetaData) -> list[MetaData]: ) -def response_shape(root: MetaData, template: MetaData, referrer_pkg: str) -> InboundShape | None: +def response_shape(template: MetaData) -> InboundShape | None: """Resolve a prompt's response value-object and reply syntax, or ``None``. ``None`` when the template declares no ``@responseRef`` or the ref does not resolve — callers skip rather than raise, matching the pre-ADR-0052 contract for an unresolvable payload ref. - Resolution goes through ``resolve_payload_vo``, the SAME target rule ``@payloadRef`` + Resolution goes through ``bound_payload_vo``, the SAME target rule ``@payloadRef`` obeys, so a parser can never bind a shape that has no generated model. (C# used the any-object resolver here and shipped exactly that defect: a ``@responseRef`` naming an ``object.entity`` produced a parser returning a type nobody declared.) @@ -83,7 +83,7 @@ def response_shape(root: MetaData, template: MetaData, referrer_pkg: str) -> Inb ref = response_ref_of(template) if ref is None: return None - vo = resolve_payload_vo(root, ref, referrer_pkg) + vo = bound_payload_vo(template, tc.TEMPLATE_ATTR_RESPONSE_REF) if vo is None: return None return InboundShape(vo=vo, ref=ref, format=response_format_of(template)) diff --git a/server/python/src/metaobjects/codegen/generators/output_parser_generator.py b/server/python/src/metaobjects/codegen/generators/output_parser_generator.py index 4aee3894e..fd3e084c9 100644 --- a/server/python/src/metaobjects/codegen/generators/output_parser_generator.py +++ b/server/python/src/metaobjects/codegen/generators/output_parser_generator.py @@ -39,7 +39,7 @@ is_xml, response_shape, ) -from metaobjects.codegen.value_objects import model_class_name, model_import, pkg_of +from metaobjects.codegen.value_objects import model_class_name, model_import from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.meta.meta_data import MetaData @@ -90,7 +90,7 @@ def render_output_parser(template: MetaData, root: MetaData) -> str | None: # ADR-0052: the shape parsed INTO is @responseRef — the reply. `response_shape` # resolves it through the SAME value-object target rule @payloadRef obeys, so a # parser can never bind a shape that has no generated model. - shape = response_shape(root, template, pkg_of(template)) + shape = response_shape(template) if shape is None: return None payload = shape.vo diff --git a/server/python/src/metaobjects/codegen/generators/output_prompt_generator.py b/server/python/src/metaobjects/codegen/generators/output_prompt_generator.py index 002ef037d..11c8e958d 100644 --- a/server/python/src/metaobjects/codegen/generators/output_prompt_generator.py +++ b/server/python/src/metaobjects/codegen/generators/output_prompt_generator.py @@ -31,7 +31,6 @@ inbound_templates, response_shape, ) -from metaobjects.codegen.value_objects import pkg_of from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.meta.meta_data import MetaData @@ -65,7 +64,7 @@ def render_output_prompt( parser generator share this contract).""" # ADR-0052: the fragment describes the RESPONSE shape, so it binds @responseRef — # never @payloadRef, which types the request this prompt renders outbound. - shape = response_shape(root, template, pkg_of(template)) + shape = response_shape(template) if shape is None: return None payload = shape.vo diff --git a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py index 0dbbb5afb..ea4f265af 100644 --- a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py +++ b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py @@ -56,7 +56,7 @@ from metaobjects.codegen.constants import generated_header from metaobjects.codegen.format import ruff_format from metaobjects.codegen.generator import EmittedFile, GenContext, Generator -from metaobjects.codegen.value_objects import model_class_name, pkg_of, resolve_payload_vo +from metaobjects.codegen.value_objects import bound_payload_vo, model_class_name from metaobjects.meta.core.field import field_constants as fc from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.core.object.meta_object import MetaObject @@ -69,7 +69,7 @@ PayloadField, verify, ) -from metaobjects.naming_refs import resolve_object_ref +from metaobjects.naming_refs import ref_target from metaobjects.shared.base_types import TYPE_FIELD, TYPE_TEMPLATE from metaobjects.shared.separators import PACKAGE_SEP @@ -91,26 +91,6 @@ def _py_str(s: str) -> str: -def _resolve_nested_object_ref( - root: MetaData, reference: str, referrer_pkg: str -) -> MetaObject | None: - """Resolve a ``field.object``'s ``@objectRef`` to its target ``object.value``. - - ADR-0042: package-local — an FQN ref (contains ``::``) resolves EXACTLY on the - package-qualified name; a bare ref resolves in the referrer's package FIRST - (else a root-level object), never a same-named ``object.value`` in another - package via a bare-tail fallback. *referrer_pkg* is the DECLARING field's - package (which differs from the VO's when the field is inherited via extends - from an abstract VO in another package). Shares the single ``resolve_object_ref`` - matcher. Mirrors the TS ``findObject`` in payload-field-tree.ts.""" - if not reference: - return None - target = resolve_object_ref(root, reference, referrer_pkg) - if target is not None and target.sub_type == OBJECT_SUBTYPE_VALUE: - return target # type: ignore[return-value] - return None - - def _derive_payload_field_tree( root: MetaData, vo: MetaObject, seen: frozenset[str] ) -> list[PayloadField]: @@ -121,10 +101,6 @@ def _derive_payload_field_tree( if vo is None or vo.resolution_key() in seen: return [] next_seen = seen | {vo.resolution_key()} - # A nested @objectRef resolves in the FIELD's own declaring package (below), - # which differs from this VO's when the field is inherited via extends from an - # abstract VO in another package (the bare ref was authored there). - vo_pkg = pkg_of(vo) fields: list[PayloadField] = [] for f in vo.children(): if f.type != TYPE_FIELD or not isinstance(f, MetaField): @@ -132,8 +108,7 @@ def _derive_payload_field_tree( if f.sub_type == fc.FIELD_SUBTYPE_OBJECT: ref = f.attrs().get(fc.FIELD_ATTR_OBJECT_REF) if isinstance(ref, str) and ref: - field_pkg = pkg_of(f.parent) if f.parent is not None else vo_pkg - target = _resolve_nested_object_ref(root, ref, field_pkg) + target = ref_target(f, fc.FIELD_ATTR_OBJECT_REF) if target is not None and target.sub_type == OBJECT_SUBTYPE_VALUE: children = _derive_payload_field_tree(root, target, next_seen) # ADR-0039 resolving read: an array of this VO (vs a single @@ -253,9 +228,7 @@ def generate(self, ctx: GenContext) -> list[EmittedFile]: "@payloadRef — skipped." ) continue - # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef - # resolves in ITS OWN package first. - vo = resolve_payload_vo(root, payload_ref, pkg_of(tmpl)) + vo = bound_payload_vo(tmpl, tc.TEMPLATE_ATTR_PAYLOAD_REF) if vo is None: ctx.warn( f"{_GENERATOR_NAME}: template.{tmpl.sub_type} '{tmpl.name}' @payloadRef " diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index d2ada70d8..241987730 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -60,19 +60,17 @@ from metaobjects.meta.persistence.db import db_constants as dbc from metaobjects.meta.core.identity.identity_constants import ( IDENTITY_ATTR_FIELDS, + IDENTITY_REFERENCE_ATTR_REFERENCES, IDENTITY_SUBTYPE_REFERENCE, ) from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT -from metaobjects.meta.core.relationship.relationship_references import ( - reference_target_entity, -) from metaobjects.meta.persistence.source.source_constants import ( SOURCE_KIND_TABLE, ) from metaobjects.naming import DEFAULT_COLUMN_NAMING +from metaobjects.naming_refs import ref_target from metaobjects.shared.base_types import TYPE_IDENTITY -from metaobjects.shared.separators import PACKAGE_SEP from ..runtime.fr010_field_mapping import is_required from ..runtime.m2m_codegen import ( @@ -400,15 +398,12 @@ def reverse_fks_for(entity: MetaObject) -> list[ReverseFk]: fk_field = fields.split(",")[0].strip() or None # #368 fallout: @references may be the dotted `Entity.field` / # `Entity.fieldA,fieldB` explicit-fields form (e.g. "acme::sport::Team.id"), - # not just a bare/qualified entity name. reference_target_entity() already - # drops that dotted tail (ADR-0039: resolving); strip_package here then bares - # the remaining qualified entity name, same as the pre-existing package strip. - target = reference_target_entity(c) - if isinstance(target, str) and PACKAGE_SEP in target: - target = target[target.rfind(PACKAGE_SEP) + len(PACKAGE_SEP):] - if not fk_field or not target: + # not just a bare/qualified entity name; ref_target binds the owner entity and + # drops that dotted tail, and its short name is the generated identifier. + bound = ref_target(c, IDENTITY_REFERENCE_ATTR_REFERENCES) + if not fk_field or bound is None: continue - out.append(ReverseFk(fk_field=fk_field, target_entity=target)) + out.append(ReverseFk(fk_field=fk_field, target_entity=bound.name)) return out diff --git a/server/python/src/metaobjects/codegen/generators/trace_helper_generator.py b/server/python/src/metaobjects/codegen/generators/trace_helper_generator.py index 500e47a2f..400b8a872 100644 --- a/server/python/src/metaobjects/codegen/generators/trace_helper_generator.py +++ b/server/python/src/metaobjects/codegen/generators/trace_helper_generator.py @@ -32,8 +32,8 @@ Skips (no helper emitted) when the entity is abstract, does not derive from ``LlmCallBase``, has no nested ``template.prompt``, or that prompt carries NEITHER ``@responseRef`` NOR ``@payloadRef`` (both gate the helper — matching the TS -reference). The response VO is resolved via ``resolve_payload_vo`` (short-name OR -FQN); a non-``object.value`` target is a hard generator error (matching Java). +reference). The response VO is the target the loader bound (``bound_payload_vo``); +a non-``object.value`` target is a hard generator error (matching Java). STI/TPH discriminator handling is DEFERRED (matching the Java port's Slice 2 — a plain trace entity is the target). @@ -46,7 +46,7 @@ from metaobjects.codegen.format import ruff_format from metaobjects.codegen.generator import EmittedFile, GenContext, Generator from metaobjects.codegen.generators.find_inbound import response_format_of -from metaobjects.codegen.value_objects import pkg_of, resolve_payload_vo +from metaobjects.codegen.value_objects import bound_payload_vo from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_ENTITY from metaobjects.meta.meta_data import MetaData @@ -164,7 +164,7 @@ def render_trace_helper(entity: MetaObject, root: MetaData) -> str | None: # its own package first — the prompt is nested inside `entity` but carries its # OWN effective package via resolution_key()'s ancestor walk). response_vo = ( - resolve_payload_vo(root, response_ref, pkg_of(prompt)) if response_ref else None + bound_payload_vo(prompt, tc.TEMPLATE_ATTR_RESPONSE_REF) if response_ref else None ) if response_ref is not None and response_vo is None: raise ValueError( diff --git a/server/python/src/metaobjects/codegen/metaobjects-config.schema.json b/server/python/src/metaobjects/codegen/metaobjects-config.schema.json index dba2d0d10..a28f470da 100644 --- a/server/python/src/metaobjects/codegen/metaobjects-config.schema.json +++ b/server/python/src/metaobjects/codegen/metaobjects-config.schema.json @@ -85,8 +85,25 @@ }, "generators": { "type": "array", - "items": { "type": "string" }, - "description": "Stable generator names (see `metaobjects gen --list`). Required: there is no default suite, so a target that names none generates nothing and `verify --codegen` checks nothing." + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1, "description": "A stable generator name, or an owned copy as \"module:symbol\"." }, + "handEdits": { + "enum": ["keep", "overwrite"], + "default": "keep", + "description": "What a hand edit to this generator's output gets. keep (the default) refuses the edited file, and an edited file no generator emits any more. overwrite regenerates over the edit and deletes such an orphan, naming each one." + } + } + } + ] + }, + "description": "Generators to run: stable names (see `metaobjects gen --list`), or {name, handEdits} mappings. Required: there is no default suite, so a target that names none generates nothing and `verify --codegen` checks nothing." }, "entities": { "type": "array", diff --git a/server/python/src/metaobjects/codegen/orphan_sweep.py b/server/python/src/metaobjects/codegen/orphan_sweep.py new file mode 100644 index 000000000..4d60d36c1 --- /dev/null +++ b/server/python/src/metaobjects/codegen/orphan_sweep.py @@ -0,0 +1,179 @@ +"""The orphan sweep: delete a file ``gen`` wrote earlier and no longer generates. + +Mirrors the run-wide sweep in ``codegen-ts`` (``runner.ts`` / ``orphan-sweep.ts``), so every +port shares one rule (``docs/features/own-your-codegen.md``, "Files that are no longer +generated"): + +- A candidate is a file under an output directory of a FULL run that the hash manifest + records writing and that no generator of the run emitted. A file the manifest does not + record is not ours, whatever its name, and is never touched. +- An untouched candidate (its content still hashes to what was recorded) is deleted and its + manifest entry dropped. +- An edited candidate is REFUSED by name and stays recorded, so the refusal repeats until + someone deletes the file or moves the edits. +- A recorded path whose file is already gone is forgotten. + +The jurisdiction is the one ``verify --codegen`` convicts ``extra:`` files on +(``cli._is_ours_for``): the same disk walk, the same manifest keys, the same legacy +out-dir-relative fallback. ``gen`` therefore removes exactly the untouched files the gate +reports, which is what makes the gate's "regenerate" remedy work. + +The caller decides whether a run may sweep at all: a run narrowed by ``--generators``, +``--entities`` or a ``--target`` that leaves out another target writing into the same +directory produces a subset of the output by construction, so every file of the unselected +part would look orphaned. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +from .overwrite_policy import ( + _load_hashes, + _save_hashes, + content_hash, + has_hash_manifest, +) + +# Never walked: interpreter droppings and tool state, the same set ``verify --codegen`` +# skips, so a candidate here is always a file the gate would also see. +_SKIPPED_DIRS = {"__pycache__", ".gen-state", ".metaobjects"} +_SKIPPED_SUFFIXES = {".pyc", ".pyo"} + + +@dataclass +class SweepResult: + #: Absolute paths deleted (or, with ``dry_run``, that would be). + removed: list[str] = field(default_factory=list) + #: Project-relative paths of edited orphans, left in place. + refused: list[str] = field(default_factory=list) + #: Project-relative paths of edited orphans deleted anyway, because every generator + #: writing into their directory is declared ``handEdits: overwrite``. Also in ``removed``. + discarded: list[str] = field(default_factory=list) + + +def _under(path: Path, dirs: list[Path]) -> bool: + return any(path.is_relative_to(d) for d in dirs) + + +def _walk(out_dir: Path, nested: list[Path] | None = None) -> list[Path]: + if not out_dir.is_dir(): + return [] + files: list[Path] = [] + for p in sorted(out_dir.rglob("*")): + if not p.is_file() or _under(p, nested or []): + continue + rel = p.relative_to(out_dir) + if any(part in _SKIPPED_DIRS for part in rel.parts) or p.suffix in _SKIPPED_SUFFIXES: + continue + files.append(p) + return files + + +def sweep_orphans( + gen_state_dir: str, + project_root: str, + out_dirs: list[str], + emitted: set[str], + *, + dry_run: bool = False, + overwrite_dirs: frozenset[str] = frozenset(), + exclude_nested: frozenset[str] = frozenset(), +) -> SweepResult: + """Delete the untouched files under ``out_dirs`` that the manifest records and + ``emitted`` (absolute paths this run produced, refused ones included) does not hold. + + In a directory of ``overwrite_dirs`` (every generator writing there is declared + ``handEdits: overwrite``) an edited such file is deleted too, and listed in + ``discarded``. A file under another output directory nested in a swept one, swept or + listed in ``exclude_nested`` (every configured output directory), is that directory's, + and is left alone. ``dry_run`` decides and reports without touching the disk or the + manifest. + """ + overwrite_real = {os.path.realpath(d) for d in overwrite_dirs} + result = SweepResult() + if not has_hash_manifest(gen_state_dir): + return result + hashes = _load_hashes(gen_state_dir) + root = Path(project_root).resolve() + emitted_real = {os.path.realpath(p) for p in emitted} + forget: set[str] = set() + + resolved = list(dict.fromkeys(Path(out).resolve() for out in out_dirs)) + every_dir = set(resolved) | {Path(out).resolve() for out in exclude_nested} + for out_path in resolved: + nested = [o for o in every_dir if o != out_path and o.is_relative_to(out_path)] + # The out dir relative to the project: the prefix that turns an out-dir-relative + # name into a manifest key. Both sides resolved, as `cli._out_prefix` does. + prefix = os.path.relpath(out_path, root) + outside = prefix.startswith("..") or os.path.isabs(prefix) + seen_keys: set[str] = set() + for p in _walk(out_path, nested): + if os.path.realpath(p) in emitted_real: + continue + rel = str(p.relative_to(out_path)) + key = os.path.join(prefix, rel) + # A file written outside the project is never recorded (runner.py), so only + # the legacy out-dir-relative spelling could match, and that spelling is + # ambiguous across out dirs. Nothing outside the project is ours to delete. + if outside: + continue + recorded_key = key if key in hashes else (rel if rel in hashes else None) + if recorded_key is None: + continue # not ours + seen_keys.add(key) + try: + with open(p, encoding="utf-8") as fh: + current = fh.read() + except (UnicodeDecodeError, OSError): + current = None + if current is None: + result.refused.append(key) + continue + if hashes[recorded_key] == content_hash(current): + result.removed.append(str(p)) + forget.add(recorded_key) + elif os.path.realpath(out_path) in overwrite_real: + result.removed.append(str(p)) + result.discarded.append(key) + forget.add(recorded_key) + else: + result.refused.append(key) + # Recorded under this out dir, not emitted, and no longer on disk: forget it, so a + # stranger's file created later under that name is not mistaken for ours. + if not outside: + for key in hashes: + if key in seen_keys or not key.startswith(prefix + os.sep): + continue + full = root / key + if _under(full.resolve(), nested): + continue + if os.path.realpath(full) in emitted_real or full.exists(): + continue + forget.add(key) + + if not dry_run: + for path in result.removed: + try: + os.remove(path) + except FileNotFoundError: + pass + if forget: + _save_hashes(gen_state_dir, {k: v for k, v in hashes.items() if k not in forget}) + result.removed.sort() + result.refused.sort() + result.discarded.sort() + return result + + +def refused_orphan_message(paths: list[str], owner: str = "metaobjects gen") -> str: + """The warning for edited orphans. The same wording as ``codegen-ts``'s + ``refusedOrphanMessage``: it says what happened, why nothing was deleted, and the two + ways out, because an unexplained refusal invites deleting the file blind.""" + return ( + f"{owner}: {len(paths)} generated file(s) are no longer generated but have been " + f"edited by hand, so they were NOT deleted: {', '.join(paths)}. Delete each one, or, " + f"if what produced it was renamed, move your edits into the newly generated file " + f"first and then delete it (regeneration cannot follow a rename)." + ) diff --git a/server/python/src/metaobjects/codegen/overwrite_policy.py b/server/python/src/metaobjects/codegen/overwrite_policy.py index 1acd517af..c83a0e10d 100644 --- a/server/python/src/metaobjects/codegen/overwrite_policy.py +++ b/server/python/src/metaobjects/codegen/overwrite_policy.py @@ -74,7 +74,7 @@ def has_hash_manifest(gen_state_dir: str) -> bool: return os.path.exists(_manifest_path(gen_state_dir)) -def _load_hashes(gen_state_dir: str) -> dict[str, str]: +def _read_raw(gen_state_dir: str) -> dict[str, object]: path = _manifest_path(gen_state_dir) if not os.path.exists(path): return {} @@ -87,15 +87,39 @@ def _load_hashes(gen_state_dir: str) -> dict[str, str]: return {} if not isinstance(parsed, dict): return {} - return {k: v for k, v in parsed.items() if isinstance(k, str) and isinstance(v, str)} + return parsed + + +def _entry_hash(value: object) -> str | None: + if isinstance(value, str): + return value + if isinstance(value, dict) and isinstance(value.get("hash"), str): + return value["hash"] + return None + + +def _load_hashes(gen_state_dir: str) -> dict[str, str]: + hashes: dict[str, str] = {} + for key, value in _read_raw(gen_state_dir).items(): + recorded = _entry_hash(value) + if isinstance(key, str) and recorded is not None: + hashes[key] = recorded + return hashes def _save_hashes(gen_state_dir: str, hashes: dict[str, str]) -> None: os.makedirs(gen_state_dir, exist_ok=True) + raw = _read_raw(gen_state_dir) # Keys SORTED, because this file is committed. Insertion order would make the # diff — and any merge conflict between two people who both regenerated — depend # on which entity happened to generate first. - ordered = {k: hashes[k] for k in sorted(hashes)} + ordered: dict[str, object] = {} + for k in sorted(hashes): + existing = raw.get(k) + if isinstance(existing, dict): + ordered[k] = {**existing, "hash": hashes[k]} + else: + ordered[k] = hashes[k] with open(_manifest_path(gen_state_dir), "w", encoding="utf-8") as fh: json.dump(ordered, fh, indent=2) fh.write("\n") @@ -151,6 +175,10 @@ def _write(path: str, content: str) -> None: fh.write(content) +#: The status of a hand-edited file replaced under ``handEdits: overwrite``. +STATUS_OVERWROTE_EDIT = "overwrote-edit" + + def decide_and_write( path: str, content: str, @@ -160,9 +188,15 @@ def decide_and_write( rel_path: str | None = None, legacy_rel_path: str | None = None, baseline: str = "default", + hand_edits: str = "keep", ) -> str: """Decide and perform the write for one generated file. + ``hand_edits="overwrite"`` (the generator's ``handEdits`` setting) replaces a file we + recorded writing that was edited since with fresh output, re-records it, and returns + :data:`STATUS_OVERWROTE_EDIT` so the caller names it. A file with no record of being + generated is refused under either setting. + ``gen_state_dir`` enables hash-based hand-edit detection; ``rel_path`` is the key it is recorded under (defaults to the basename, which is correct for a single flat output directory). ``legacy_rel_path`` is the key the same file may already @@ -212,6 +246,12 @@ def decide_and_write( _record(gen_state_dir, key, content, legacy_rel_path) return "overwrite" + # Edited since we wrote it, and the generator says to discard hand edits. + if hand_edits == "overwrite" and read_generated_hash(gen_state_dir, key, legacy_rel_path) is not None: + _write(path, content) + _record(gen_state_dir, key, content, legacy_rel_path) + return STATUS_OVERWROTE_EDIT + # Edited, or never recorded. Deliberately does NOT record the current content: # doing so would make the file look pristine next run and turn this into a silent # overwrite one run later. diff --git a/server/python/src/metaobjects/codegen/project_config.py b/server/python/src/metaobjects/codegen/project_config.py index fee81e671..c699597b0 100644 --- a/server/python/src/metaobjects/codegen/project_config.py +++ b/server/python/src/metaobjects/codegen/project_config.py @@ -62,6 +62,17 @@ #: Every key a ``targets.`` mapping may carry. Same contract as above. TARGET_KEYS: tuple[str, ...] = ("outDir", "generators", "entities", "templateSpec") +#: Every key a mapping entry of a target's ``generators`` list may carry: the generator's +#: name and its ``handEdits`` setting. Same contract as above. +GENERATOR_ENTRY_KEYS: tuple[str, ...] = ("name", "handEdits") + +#: The values ``handEdits`` accepts, the same in every port. ``keep`` (the default) refuses +#: a generated file edited by hand, and an edited file no generator emits any more; +#: ``overwrite`` regenerates over the edit and deletes such an orphan, naming each one. +HAND_EDITS_VALUES: tuple[str, ...] = ("keep", "overwrite") +HAND_EDITS_KEEP = "keep" +HAND_EDITS_OVERWRITE = "overwrite" + #: Every key the ``apiDocs`` block may carry (the ``api-docs`` generator's options). Same #: contract as above. API_DOCS_KEYS: tuple[str, ...] = ("apiSubDir", "projectName", "modelBaseUrl") @@ -127,6 +138,9 @@ class TargetConfig: #: A declarative template-spec (SP-1) whose generators also write into this target, #: relative to the config file's directory (or absolute); ``None`` => none. template_spec: str | None = None + #: The ``generators`` entries (as written in ``generators``) declared with + #: ``handEdits: overwrite``. Every other entry keeps hand edits. + overwrite_hand_edits: frozenset[str] = frozenset() @dataclass(frozen=True) @@ -237,6 +251,36 @@ def _require_str_list(value: object, ctx: str) -> list[str]: return list(value) +def _parse_generator_entries(value: object, ctx: str) -> tuple[list[str], frozenset[str]]: + """A target's ``generators``: each entry a name, or a mapping ``{name, handEdits}``. + + Returns the names in order and the ones declared ``handEdits: overwrite``.""" + if not isinstance(value, list): + raise ConfigError(f"{ctx} must be a list of generator names or {{name, handEdits}} mappings.") + names: list[str] = [] + overwrite: set[str] = set() + for entry in value: + if isinstance(entry, str): + names.append(entry) + continue + if not isinstance(entry, dict): + raise ConfigError(f"{ctx}: each entry must be a generator name or a {{name, handEdits}} mapping.") + _reject_unknown_keys(entry, GENERATOR_ENTRY_KEYS, ctx) + name = entry.get("name") + if not isinstance(name, str) or not name: + raise ConfigError(f"{ctx}: a mapping entry must declare a non-empty 'name'.") + hand_edits = entry.get("handEdits", HAND_EDITS_KEEP) + if hand_edits not in HAND_EDITS_VALUES: + raise ConfigError( + f"{ctx}: entry {name!r} has handEdits {hand_edits!r}; expected one of " + f"{', '.join(HAND_EDITS_VALUES)}." + ) + names.append(name) + if hand_edits == HAND_EDITS_OVERWRITE: + overwrite.add(name) + return names, frozenset(overwrite) + + def _parse_requirement_tests(raw: object, ctx: str) -> RequirementTestsConfig: if not isinstance(raw, dict): raise ConfigError(f"{ctx} must be a mapping.") @@ -385,8 +429,10 @@ def load_project_config(path: Path) -> ProjectConfig: if not isinstance(out_dir, str) or not out_dir: raise ConfigError(f"{ctx} must declare a non-empty 'outDir' string.") generators = spec.get("generators") + overwrite_hand_edits: frozenset[str] = frozenset() if generators is not None: - generators = _require_str_list(generators, f"{ctx} 'generators'") + generators, overwrite_hand_edits = _parse_generator_entries( + generators, f"{ctx} 'generators'") entities = spec.get("entities") if entities is not None: entities = _require_str_list(entities, f"{ctx} 'entities'") @@ -396,7 +442,7 @@ def load_project_config(path: Path) -> ProjectConfig: targets.append( TargetConfig( name=str(name), out_dir=out_dir, generators=generators, entities=entities, - template_spec=template_spec, + template_spec=template_spec, overwrite_hand_edits=overwrite_hand_edits, ) ) diff --git a/server/python/src/metaobjects/codegen/runner.py b/server/python/src/metaobjects/codegen/runner.py index d4445ca0a..8f1110a68 100644 --- a/server/python/src/metaobjects/codegen/runner.py +++ b/server/python/src/metaobjects/codegen/runner.py @@ -17,7 +17,7 @@ from .constants import generated_package_init from .generator import GenContext, Generator from .instance_artifacts import is_served_report -from .overwrite_policy import decide_and_write, has_hash_manifest +from .overwrite_policy import STATUS_OVERWROTE_EDIT, decide_and_write, has_hash_manifest _VALID_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -31,6 +31,9 @@ class RunGenResult: files: list[tuple[str, str]] = field(default_factory=list) # (path, status) warnings: list[str] = field(default_factory=list) + #: Which generator emitted each path (``verify --codegen`` reads it to apply a + #: generator's ``handEdits`` to the file). + generated_by: dict[str, str] = field(default_factory=dict) def _objects(root: MetaData) -> list[MetaObject]: @@ -185,7 +188,8 @@ def run_gen( refused: list[str] = [] adopted: list[str] = [] untracked = 0 - for full, (content, _by) in emitted.items(): + for full, (content, by) in emitted.items(): + result.generated_by[full] = by # The out-dir-relative name: what the manifest used to be keyed by, and still # what a refusal is REPORTED as (it is the name the reader sees in `--out`). rel = os.path.relpath(full, config.out_dir) @@ -221,8 +225,13 @@ def run_gen( rel_path=key, legacy_rel_path=rel if key != rel else None, baseline=config.baseline, + hand_edits="overwrite" if by in config.overwrite_hand_edits else "keep", ) result.files.append((full, status)) + if status == STATUS_OVERWROTE_EDIT: + result.warnings.append( + f"overwrote hand-edited {rel} with fresh output (handEdits: overwrite on {by})" + ) if status == "refused": refused.append(rel) elif status == "adopted": diff --git a/server/python/src/metaobjects/codegen/runtime/api_docs_builder.py b/server/python/src/metaobjects/codegen/runtime/api_docs_builder.py index 12087e470..a783a9741 100644 --- a/server/python/src/metaobjects/codegen/runtime/api_docs_builder.py +++ b/server/python/src/metaobjects/codegen/runtime/api_docs_builder.py @@ -44,7 +44,7 @@ resolve_m2m_descriptors, ) from metaobjects.codegen.generators.find_inbound import is_xml, response_shape -from metaobjects.codegen.value_objects import is_field_required, pkg_of, resolve_payload_vo +from metaobjects.codegen.value_objects import bound_payload_vo, is_field_required, pkg_of from metaobjects.codegen.instance_artifacts import ( has_item_route, is_abstract, @@ -78,9 +78,7 @@ def _payload_resolves(tmpl: MetaData, root: MetaData) -> MetaObject | None: payload_ref = tmpl.get_meta_attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) # ADR-0039: template attr resolves via extends (not origin; templates CAN extend) if not isinstance(payload_ref, str) or not payload_ref: return None - # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef resolves - # in ITS OWN package first. - return resolve_payload_vo(root, payload_ref, pkg_of(tmpl)) + return bound_payload_vo(tmpl, tc.TEMPLATE_ATTR_PAYLOAD_REF) def _is_email_kind(tmpl: MetaData) -> bool: @@ -326,8 +324,7 @@ def add(verb_path: str, usage: str) -> None: # strategy through would mean inventing a config seam this command doesn't # have, for a value nothing here would read. for d in resolve_m2m_descriptors(entity, object_index): - target = object_index.get(d.target_entity) - target_name = target.name if target is not None else d.target_entity + target_name = d.target_entity add( "GET " + base_path + "/{" + pk + "}/" + d.relation_name, f"M:N traversal — the related {target_name} rows", @@ -376,7 +373,7 @@ def _build_template_unit(self, tmpl: MetaData, root: MetaData) -> ApiUnit: # to an output, and the gate is @responseRef PRESENCE rather than a format # value. Shares `response_shape` with the generators, so the docs can never # claim a symbol codegen suppressed. - inbound = response_shape(root, tmpl, pkg_of(tmpl)) + inbound = response_shape(tmpl) if inbound is not None: # The RESPONSE shape the parser actually returns — the @responseRef value # object's own model (ADR-0056). Documented once: when the prompt renders and diff --git a/server/python/src/metaobjects/codegen/runtime/extract_delegate_emitter.py b/server/python/src/metaobjects/codegen/runtime/extract_delegate_emitter.py index 258011557..f30e263f3 100644 --- a/server/python/src/metaobjects/codegen/runtime/extract_delegate_emitter.py +++ b/server/python/src/metaobjects/codegen/runtime/extract_delegate_emitter.py @@ -40,31 +40,19 @@ from __future__ import annotations from . import fr010_field_mapping as fm -from metaobjects.codegen.value_objects import model_class_name, pkg_of +from metaobjects.codegen.value_objects import model_class_name from metaobjects.meta.core.field import field_constants as fc from metaobjects.meta.meta_data import MetaData -from metaobjects.naming_refs import resolve_object_ref +from metaobjects.naming_refs import ref_target def ref_vo(field: MetaData, root: MetaData) -> MetaData | None: """The ``@objectRef`` target VO for a nested-object field, or ``None`` when unresolvable. - ADR-0042 (#228) — resolves via the canonical `resolve_object_ref` package-local - contract: an FQN ref resolves EXACTLY; a bare ref resolves in the DECLARING - field's own package first, else a root-level object. NO bare-tail short-name - fallback — that pattern (matching an FQN ref by its trailing simple-name segment - against ANY same-named object root-wide) is the #219/ADR-0042-banned "wrong - node" bug: under a cross-package short-name collision it silently binds - whichever same-named object happens to load first, regardless of which package - the ref actually pointed at. *referrer_pkg* is the field's OWN declaring - package (which differs from the VO's when the field is inherited via `extends` - from an abstract VO in another package).""" - ref = field.attrs().get(fc.FIELD_ATTR_OBJECT_REF) - if not isinstance(ref, str) or not ref: - return None - referrer_pkg = pkg_of(field.parent) if field.parent is not None else "" - return resolve_object_ref(root, ref, referrer_pkg) + ADR-0059 R7 — the target the loader bound once, read through ``ref_target``: never + re-resolved in some package, never a short-name match.""" + return ref_target(field, fc.FIELD_ATTR_OBJECT_REF) def _is_object_field(field: MetaData) -> bool: diff --git a/server/python/src/metaobjects/codegen/runtime/m2m_codegen.py b/server/python/src/metaobjects/codegen/runtime/m2m_codegen.py index 813794dbb..090073128 100644 --- a/server/python/src/metaobjects/codegen/runtime/m2m_codegen.py +++ b/server/python/src/metaobjects/codegen/runtime/m2m_codegen.py @@ -46,9 +46,13 @@ derive_m2m_fields, ) from metaobjects.meta.core.relationship.meta_relationship import MetaRelationship -from metaobjects.meta.core.relationship.relationship_constants import CARDINALITY_MANY +from metaobjects.meta.core.relationship.relationship_constants import ( + CARDINALITY_MANY, + RELATIONSHIP_ATTR_OBJECT_REF, + RELATIONSHIP_ATTR_THROUGH, +) +from metaobjects.naming_refs import ref_target from metaobjects.shared.base_types import TYPE_IDENTITY -from metaobjects.shared.separators import PACKAGE_SEP @dataclass(frozen=True) @@ -77,11 +81,6 @@ class M2mDescriptor: target_discriminator: str | None = None -def _strip_package(name: str) -> str: - idx = name.rfind(PACKAGE_SEP) - return name[idx + len(PACKAGE_SEP):] if idx >= 0 else name - - def _physical_table(entity: MetaObject, *, relation_context: str) -> str: """The entity's physical SQL table name, via the shared :func:`~metaobjects.source_resolution.resolve_table_name` resolver. @@ -168,7 +167,7 @@ def resolve_m2m_descriptors( ) -> list[M2mDescriptor]: """Resolve every M:N navigation on *entity* into a build-time descriptor. - *object_index* is a bare-name → entity map of the loaded model. A relationship + *object_index* is a resolution-key → entity map of the loaded model. A relationship that fails derivation (missing/malformed junction, ambiguous self-join) raises :class:`M2MDerivationError` — the loader validates this; reaching it here is a metadata error, surfaced loudly (never silently skipped). @@ -178,11 +177,12 @@ def resolve_m2m_descriptors( # loop-invariant; only the target varies per relation. source_plural = route_path(entity.name) for rel in m2m_relationships(entity): - target_name = _strip_package(rel.object_ref() or "") - junction_name = _strip_package(rel.through() or "") - target = object_index.get(target_name) - junction = object_index.get(junction_name) - if target is None or junction is None: + # ADR-0059 R7: the BOUND targets, not a name stripped from the ref. + target = ref_target(rel, RELATIONSHIP_ATTR_OBJECT_REF) + junction = ref_target(rel, RELATIONSHIP_ATTR_THROUGH) + target_name = rel.object_ref() or "" + junction_name = rel.through() or "" + if not isinstance(target, MetaObject) or not isinstance(junction, MetaObject): raise M2MDerivationError( f'M:N relationship "{entity.name}.{rel.name}": @objectRef ' f'"{target_name}" / @through "{junction_name}" must resolve to ' @@ -235,6 +235,7 @@ def resolve_m2m_descriptors( def build_object_index(entities: list[MetaObject]) -> dict[str, MetaObject]: - """Bare-name → entity map (the codegen resolution surface, mirroring the - runtime resolver's ``object_index``).""" - return {e.name: e for e in entities} + """Resolution-key → entity map (the codegen resolution surface, mirroring the + runtime resolver's ``object_index``). Keyed by ``resolution_key()`` so two + same-short-name entities in different packages stay distinct.""" + return {e.resolution_key(): e for e in entities} diff --git a/server/python/src/metaobjects/codegen/value_objects.py b/server/python/src/metaobjects/codegen/value_objects.py index 2c6652aa0..4263f903b 100644 --- a/server/python/src/metaobjects/codegen/value_objects.py +++ b/server/python/src/metaobjects/codegen/value_objects.py @@ -38,7 +38,7 @@ ) from metaobjects.meta.meta_data import MetaData from metaobjects.naming import package_of_resolution_key -from metaobjects.naming_refs import resolve_object_ref +from metaobjects.naming_refs import lookup_canonical, ref_target from metaobjects.shared.base_types import TYPE_OBJECT, TYPE_SOURCE from metaobjects.shared.separators import PACKAGE_SEP @@ -81,18 +81,26 @@ def is_legal_payload_target(obj: MetaData) -> bool: return not any(c.type == TYPE_SOURCE for c in obj.children()) +def bound_payload_vo(node: MetaData, attr_name: str) -> MetaObject | None: + """The value-object shape *node*'s ``@payloadRef`` / ``@responseRef`` is bound to + (ADR-0059 R7): the target the loader recorded once, never a re-resolution in the + template's package. Rejects entities and sourced projections (#210).""" + obj = ref_target(node, attr_name) + if not isinstance(obj, MetaObject) or not is_legal_payload_target(obj): + return None + return obj + + def resolve_payload_vo(root: MetaData, ref: str, referrer_pkg: str) -> MetaObject | None: - """Resolve a ``@payloadRef`` / ``@responseRef`` to its value-object shape, PACKAGE-LOCAL - (ADR-0042) — the SAME canonical ``resolve_object_ref`` contract the loader's own - ``_validate_templates`` pass uses to validate this exact ref: an FQN resolves exactly; a - bare ref resolves in the referrer's own package first, else a root-level object. Rejects - entities and sourced projections (#210). - - *referrer_pkg* is the REFERENCING TEMPLATE's effective package — pass - ``pkg_of(template)``, not the template's bare ``.package`` attr: for a loader-parsed tree - the two agree, but ``pkg_of`` is ALSO correct for hand-built test trees that set a - package only on an ancestor.""" - obj = resolve_object_ref(root, ref, referrer_pkg) + """Resolve a CANONICAL ``@payloadRef`` / ``@responseRef`` value to its value-object shape, + exactly (ADR-0059 R7): ``pkg::Name`` binds that package's object, ``::Name`` and a bare + ``Name`` bind the root-level object. No short-name scan and no package applied; ``None`` + when nothing matches exactly. Rejects entities and sourced projections (#210). + + Prefer :func:`bound_payload_vo` on the template node: it returns the target the loader + bound. *referrer_pkg* is ignored, because a canonical value is absolute; it is kept so + existing callers and owned generator copies keep working.""" + obj = lookup_canonical(root, ref, TYPE_OBJECT) if ref else None if not isinstance(obj, MetaObject) or not is_legal_payload_target(obj): return None return obj @@ -192,18 +200,13 @@ def model_import(obj: MetaData) -> str: def object_ref_target(field: MetaData) -> MetaObject | None: - """The object a field's ``@objectRef`` names, resolved FQN-exact / package-local - (ADR-0042) in the FIELD's declaring package — which differs from the owner's when the - field is inherited through ``extends``. Never a bare short-name scan: that binds whichever - same-named object loaded first.""" - ref = field.attrs().get(fc.FIELD_ATTR_OBJECT_REF) - if not isinstance(ref, str) or not ref: - return None + """The object a field's ``@objectRef`` is bound to (ADR-0059 R7), which for an + inherited field is the object it named in the package that declared it. Never a bare + short-name scan: that binds whichever same-named object loaded first.""" hit = _REF_TARGET.get(field, _MISSING) if hit is not _MISSING: return hit - referrer_pkg = pkg_of(field.parent) if field.parent is not None else "" - target = resolve_object_ref(root_of(field), ref, referrer_pkg) + target = ref_target(field, fc.FIELD_ATTR_OBJECT_REF) result = target if isinstance(target, MetaObject) else None _REF_TARGET[field] = result return result diff --git a/server/python/src/metaobjects/errors.py b/server/python/src/metaobjects/errors.py index d9524aa4b..47a6c47c8 100644 --- a/server/python/src/metaobjects/errors.py +++ b/server/python/src/metaobjects/errors.py @@ -242,11 +242,6 @@ class ErrorCode(str, Enum): # ADR-0023 — a registration was attempted against a registry sealed after its # agreed metamodel-provider bootstrap. Codegen cannot invent metamodel attrs. ERR_REGISTRY_SEALED = "ERR_REGISTRY_SEALED" - # FR-032 (ADR-0032) — a relative (``::``/``..::``) reference survived into - # canonical JSON (canonical JSON must be FQN-only). The Python loader does not - # emit this yet (the T6 guard is deferred — like TS, it belongs in the parser); - # the enum tracks the shared corpus code so resolution/desugar stay aligned. - ERR_RELATIVE_REF_IN_CANONICAL = "ERR_RELATIVE_REF_IN_CANONICAL" # ADR-0042 — a field.object / field.map @objectRef does not resolve to any # object in the loaded tree (a dangling target). Bare refs resolve # package-locally (referrer's package, else root-level); FQN refs resolve diff --git a/server/python/src/metaobjects/fmt.py b/server/python/src/metaobjects/fmt.py index b283f44e3..a5684c6ae 100644 --- a/server/python/src/metaobjects/fmt.py +++ b/server/python/src/metaobjects/fmt.py @@ -99,7 +99,7 @@ def format_file(content: str, registry: TypeRegistry, source_id: str) -> FormatF message="this file declares an overlay fmt cannot resolve standalone", ) - return FormatFileResult(ok=True, text=canonical_serialize(result.root)) + return FormatFileResult(ok=True, text=canonical_serialize(result.root, refs="authored")) @dataclass diff --git a/server/python/src/metaobjects/loader/meta_data_loader.py b/server/python/src/metaobjects/loader/meta_data_loader.py index e391bb381..44f0eb4f0 100644 --- a/server/python/src/metaobjects/loader/meta_data_loader.py +++ b/server/python/src/metaobjects/loader/meta_data_loader.py @@ -30,6 +30,7 @@ from ..source import CodeSource, ErrorSource, JsonSource, LoaderWarning from ..super_resolve import resolve_supers from .merge import merge_roots +from .resolve_references import resolve_references from .sources import ( DirectorySource, InMemoryStringSource, @@ -140,6 +141,10 @@ def load(self, sources: list[MetaDataSource]) -> LoadResult: envelope_warnings=result.envelope_warnings, strict=self._strict, ) + if roots: + # ADR-0059 R5 -- after the last validation pass, before freeze: resolve every + # reference once, record its target and rewrite it to its canonical spelling. + resolve_references(result.root) result.root.freeze() return result diff --git a/server/python/src/metaobjects/loader/registered_validation.py b/server/python/src/metaobjects/loader/registered_validation.py index f8425ddfe..2c0d2f810 100644 --- a/server/python/src/metaobjects/loader/registered_validation.py +++ b/server/python/src/metaobjects/loader/registered_validation.py @@ -12,10 +12,15 @@ from ..meta.core.object.meta_object import MetaObject from ..meta.meta_data import MetaData from ..meta.meta_root import MetaRoot -from ..naming_refs import did_you_mean_hint +from ..naming_refs import ( + RefOverDropError, + did_you_mean_hint, + owner_candidate_keys, + referrer_package_for, + split_child_tail, +) from ..registry import TypeRegistry from ..shared.base_types import TYPE_OBJECT -from ..shared.separators import PACKAGE_SEP class SymbolTable: @@ -39,15 +44,19 @@ def build(cls, root: MetaData) -> "SymbolTable": return t def resolve_object(self, reference: str, referrer_pkg: str) -> MetaData | None: - """ADR-0042 package-local resolution: FQN → exact resolution-key match; - bare → the referrer's own package (``::``), else a - root-level object. Mirrors the TS ``resolveObject``.""" - if PACKAGE_SEP in reference: - return self._index.get(reference) - local_key = ( - f"{referrer_pkg}{PACKAGE_SEP}{reference}" if referrer_pkg else reference - ) - return self._index.get(local_key) or self._index.get(reference) + """ADR-0059 R2 resolution: ``::Rest`` root-absolute, ``..::Rest`` parent-relative, + ``pkg::Name`` exact, bare ``Name`` in the referrer's own package then root-level. + Exact keys only. An over-dropping ``..::`` resolves to nothing here; the walk + reports it as ERR_BAD_ATTR_VALUE. Mirrors the TS ``resolve``.""" + try: + keys = owner_candidate_keys(reference, referrer_pkg) + except RefOverDropError: + return None + for key in keys: + hit = self._index.get(key) + if hit is not None: + return hit + return None class ValidationContext: @@ -68,22 +77,11 @@ def error(self, code: str, node: MetaData, message: str) -> None: def run(root: MetaRoot, registry: TypeRegistry) -> list[MetaError]: ctx = ValidationContext(SymbolTable.build(root), root) - _walk(root, registry, ctx, "") + _walk(root, registry, ctx) return ctx.errors -def _walk( - node: MetaData, registry: TypeRegistry, ctx: ValidationContext, referrer_pkg: str -) -> None: - # ADR-0042: a top-level object establishes the package context for its subtree; - # nested ref-bearing nodes (relationship/field.object/identity.reference) resolve - # BARE refs against it. Nested nodes carry no `package`, so they inherit the - # enclosing object's (via file_default_package, else the threaded context). - pkg = ( - (node.package or node.file_default_package or referrer_pkg) - if node.type == TYPE_OBJECT - else referrer_pkg - ) +def _walk(node: MetaData, registry: TypeRegistry, ctx: ValidationContext) -> None: type_def = registry.find(node.type, node.sub_type) if type_def is not None: for desc in type_def.references: @@ -94,12 +92,24 @@ def _walk( raw = node.get_meta_attr(desc.attr) if not isinstance(raw, str) or raw == "": continue # absence is the required-attr pass's job - entity_ref = raw.split(".", 1)[0] if desc.dotted_field_path else raw - # ADR-0042: bare refs resolve package-local against the enclosing object. - target = ctx.symbols.resolve_object(entity_ref, pkg) + entity_ref = split_child_tail(raw)[0] if desc.dotted_field_path else raw + # ADR-0059 R3 (P4): an inherited reference resolves in the package of the + # top-level node that physically declares it, not the inheritor's. + ref_pkg = referrer_package_for(node, desc.attr) # Qualify the node name with its owning entity (e.g. "Order.items") so the error # is locatable from the message alone, not just the source envelope. qname = f"{node.parent.name}.{node.name}" if node.parent and node.parent.name else node.name + try: + owner_candidate_keys(entity_ref, ref_pkg) + except RefOverDropError: + ctx.error( + "ERR_BAD_ATTR_VALUE", node, + f'{node.type}.{node.sub_type} "{node.name}" @{desc.attr} "{raw}" climbs ' + "above the root package \u2014 each '..::' drops one segment of the " + "declaring package.", + ) + continue + target = ctx.symbols.resolve_object(entity_ref, ref_pkg) if target is None: ctx.error( desc.error_code, node, @@ -125,4 +135,4 @@ def _walk( # node once at its declaration site (own children); inherited nodes are # validated where they are declared. Mirrors the TS ``node.ownChildren()`` walk. for child in node.own_children(): - _walk(child, registry, ctx, pkg) + _walk(child, registry, ctx) diff --git a/server/python/src/metaobjects/loader/resolve_references.py b/server/python/src/metaobjects/loader/resolve_references.py new file mode 100644 index 000000000..f4cc5ca69 --- /dev/null +++ b/server/python/src/metaobjects/loader/resolve_references.py @@ -0,0 +1,154 @@ +"""ADR-0059 R5 -- resolve every reference once, after validation and before freeze. + +Sources do not interpret references (R1): YAML and canonical JSON hand the loader each +reference string exactly as written. The validation passes resolve those authored +strings through the shared resolver and report failures with their own codes. This pass +then walks the physical tree once and, for every reference that resolves, records the +target and rewrites the value to its canonical spelling (R6), keeping the authored +string beside it. After the pass every consumer reads values that mean the same thing +whatever package they are read from, and the serializer writes them as they are. + +A reference that does not resolve is left exactly as written: validation has already +reported it. Mirrors TS ``loader/resolve-references.ts``. +""" +from __future__ import annotations + +from typing import Optional + +from ..meta.core.attr.meta_attr import MetaAttr +from ..meta.core.object.object_constants import ( + OBJECT_REPORT_ATTR_FROM, + OBJECT_REPORT_ATTR_SPINE, + OBJECT_SUBTYPE_REPORT, +) +from ..meta.core.reporting.reporting_constants import REPORTING_ATTR_OF, REPORTING_ATTR_VIA +from ..meta.meta_data import MetaData +from ..naming_refs import ( + REF_BEARING_ATTR_NAMES, + RefOverDropError, + effective_package_of, + owner_candidate_keys, + resolve_object_ref, + spell_ref, + split_child_tail, + top_level_node_of, +) +from ..shared.base_types import TYPE_DIMENSION, TYPE_OBJECT +from .validate_reporting import dimension_via_terminal + + +def _resolve_one( + root: MetaData, value: str, ref_pkg: str, decl_pkg: str +) -> Optional[tuple[MetaData, str]]: + """Resolve one reference string's owner in *ref_pkg*, and spell it canonically. + Root-level targets are spelled ``::Name`` unless both the resolution package and the + declaring package are root-level, so a consumer reading from either package binds + the same node.""" + owner, tail = split_child_tail(value) + target = resolve_object_ref(root, owner, ref_pkg) + if target is None: + return None + return target, spell_ref(target, ref_pkg if ref_pkg != "" else decl_pkg, tail) + + +def _referrer_package(root: MetaData, node: MetaData, attr: str, decl_pkg: str) -> str: + """R3 -- the package an own reference attribute of *node* resolves in.""" + # `@of` on a dimension that walks `@via`: the package of the entity the walk reaches. + if node.type == TYPE_DIMENSION and attr == REPORTING_ATTR_OF: + # ADR-0039: resolving -- the @via that governs this @of may be inherited. + via = node.get_meta_attr(REPORTING_ATTR_VIA) + declaring = node.parent + if isinstance(via, str) and via != "" and declaring is not None: + terminal = dimension_via_terminal(root, declaring, via) + return effective_package_of(terminal) if terminal is not None else decl_pkg + # `@spine` on a report: the package of the `@from` entity. + if node.type == TYPE_OBJECT and node.sub_type == OBJECT_SUBTYPE_REPORT and attr == OBJECT_REPORT_ATTR_SPINE: + # ADR-0039: resolving -- @from may be inherited. + from_ = node.get_meta_attr(OBJECT_REPORT_ATTR_FROM) + if isinstance(from_, str) and from_ != "": + from_node = resolve_object_ref(root, from_, decl_pkg) + if from_node is not None: + return effective_package_of(from_node) + return decl_pkg + + +def _resolve_attr(root: MetaData, node: MetaData, attr: MetaAttr, decl_pkg: str) -> None: + value = attr.value + ref_pkg = _referrer_package(root, node, attr.name, decl_pkg) + if isinstance(value, str): + if value == "": + return + r = _resolve_one(root, value, ref_pkg, decl_pkg) + if r is None: + return + target, canonical = r + attr.resolved_target = target + if canonical != value: + if attr.authored_value is None: + attr.authored_value = value + attr.value = canonical + return + if isinstance(value, list) and all(isinstance(v, str) for v in value): + resolved = [ + None if v == "" else _resolve_one(root, v, ref_pkg, decl_pkg) for v in value + ] + if all(r is None for r in resolved): + return + attr.resolved_target = [r[0] if r is not None else None for r in resolved] + canonical = [r[1] if r is not None else v for v, r in zip(value, resolved)] + if canonical != value: + if attr.authored_value is None: + attr.authored_value = list(value) + attr.value = canonical + + +def _resolve_extends(root: MetaData, node: MetaData) -> None: + """Canonical ``extends``: the owner spelled from the declaring node's package, tail kept.""" + ref = node.super_ref + if ref is None or node.super_data is None: + return + pkg = effective_package_of(node) + owner, tail = split_child_tail(ref) + try: + keys = owner_candidate_keys(owner, pkg) + except RefOverDropError: + return # an over-drop never resolved, so super_data would be unset + owner_node: Optional[MetaData] = None + # ADR-0039 sanctioned own: extends targets are top-level nodes; the root has no super. + for key in keys: + owner_node = next( + (c for c in root.own_children() if c.name != "" and c.resolution_key() == key), None + ) + if owner_node is not None: + break + if owner_node is None: + return + canonical = spell_ref(owner_node, pkg, tail) + if canonical == ref: + return + if node.authored_super_ref is None: + node.authored_super_ref = ref + node.super_ref = canonical + + +def resolve_references(root: MetaData) -> None: + """Resolve every own reference attribute (ADR-0059 R4) and every ``extends`` in the + tree, record the targets, and rewrite resolved values to their canonical spelling. + Must run after the last validation pass and before ``freeze()``.""" + + def visit(node: MetaData) -> None: + if node is not root: + _resolve_extends(root, node) + # R3 default: the effective package of the top-level node that physically + # declares the attribute. Own attributes are declared here, by definition. + decl_pkg = effective_package_of(top_level_node_of(node)) + # ADR-0039 sanctioned own: this pass rewrites the declared-here layer; an + # inherited attribute is resolved once, on the node that declares it. + for attr in node.own_meta_attrs(): + if attr.name in REF_BEARING_ATTR_NAMES and isinstance(attr, MetaAttr): + _resolve_attr(root, node, attr, decl_pkg) + # ADR-0039 sanctioned own: structural walk over the physical declaration tree. + for child in node.own_children(): + visit(child) + + visit(root) diff --git a/server/python/src/metaobjects/loader/validate_reporting.py b/server/python/src/metaobjects/loader/validate_reporting.py index cc8f1db24..b6d33a4cc 100644 --- a/server/python/src/metaobjects/loader/validate_reporting.py +++ b/server/python/src/metaobjects/loader/validate_reporting.py @@ -107,7 +107,7 @@ from ..meta.meta_data import MetaData from ..meta.meta_root import MetaRoot from ..meta.persistence.source.meta_source import MetaSource -from ..naming_refs import CHILD_REF_SEP, resolve_object_ref +from ..naming_refs import CHILD_REF_SEP, ref_target, resolve_object_ref from ..shared.base_types import ( TYPE_DIMENSION, TYPE_FIELD, @@ -588,12 +588,8 @@ def name_of(entity: MetaData) -> str: f"{walk.to_one_reason}" ) return None - # ADR-0039: resolving. - target_ref = hop.get_meta_attr( - IDENTITY_REFERENCE_ATTR_REFERENCES if is_reference else RELATIONSHIP_ATTR_OBJECT_REF - ) - # ADR-0042 — a hop target resolves in the package of the entity declaring the hop. - target = resolve_object_ref(root, target_ref, _pkg_of(current)) if isinstance(target_ref, str) else None + # ADR-0059 R3 — a hop target resolves in the package of the node declaring the hop's reference. + target = ref_target(hop, IDENTITY_REFERENCE_ATTR_REFERENCES if is_reference else RELATIONSHIP_ATTR_OBJECT_REF) if target is None: err(f"{named} hop '{hop_name}' on '{name_of(current)}' targets no object.") return None @@ -1127,3 +1123,11 @@ def _check_no_relative_dates(node: MetaData, sink: _ErrorSink) -> None: # ADR-0039: own — a tree walk; each declared node is visited once, at its declaration. for child in node.own_children(): _check_no_relative_dates(child, sink) + + +def dimension_via_terminal(root: MetaData, declaring: MetaData, via: str) -> MetaData | None: + """The entity a dimension's ``@via`` reaches, walked from ``declaring`` with D2's rules, + or ``None`` when the walk fails (D2 reports why). ADR-0059 R3: a dimension's ``@of`` + resolves in this entity's package, so the loader's ``resolve_references`` pass reuses + the walk rather than restating it.""" + return _walk_to_one_via(root, declaring, via, lambda _message: None, _d2_walk(declaring)) diff --git a/server/python/src/metaobjects/loader/validate_source_parameter_ref.py b/server/python/src/metaobjects/loader/validate_source_parameter_ref.py index bb9201ffc..05495464e 100644 --- a/server/python/src/metaobjects/loader/validate_source_parameter_ref.py +++ b/server/python/src/metaobjects/loader/validate_source_parameter_ref.py @@ -34,7 +34,7 @@ OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE, ) -from ..naming_refs import resolve_object_ref +from ..naming_refs import resolve_object_ref, referrer_package_of from ..shared.base_types import TYPE_OBJECT, TYPE_SOURCE _CALLABLE_KINDS = frozenset({SOURCE_KIND_STORED_PROC, SOURCE_KIND_TABLE_FUNCTION}) @@ -54,7 +54,7 @@ def validate_source_parameter_ref(root: MetaData, errors: list[MetaError]) -> No # package, else root-level); an FQN resolves exactly. Shares the single # resolve_object_ref matcher — NO bare-name-anywhere fallback (which would # silently bind a same-named value-object in another package). - referrer_pkg = obj.package or obj.file_default_package or "" + referrer_pkg = referrer_package_of(obj) for source in obj.own_children(): if source.type != TYPE_SOURCE or source.sub_type != SOURCE_SUBTYPE_RDB: continue diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index 6869e78e4..421bc6c4b 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -137,7 +137,8 @@ IDENTITY_SECONDARY_ATTR_EXPR as IDENTITY_ATTR_EXPR, ) from ..source import resolved_source -from ..naming_refs import did_you_mean_hint, resolve_object_ref +from ..naming_refs import did_you_mean_hint, resolve_object_ref, referrer_package_of +from ..naming_refs import ref_target as bound_ref_target from ..meta.core.relationship.relationship_references import ( reference_candidates_for, reference_fields, @@ -1715,8 +1716,10 @@ def _validate_via_path( # ADR-0042 — the hop target (@objectRef/@references) resolves in the package # of the entity that DECLARES the relationship/reference, i.e. current_entity. - hop_pkg = current_entity.package or current_entity.file_default_package or "" - next_entity = resolve_object_ref(root, obj_ref, hop_pkg) + next_entity = bound_ref_target( + rel_node, + IDENTITY_REFERENCE_ATTR_REFERENCES if _is_reference_hop(rel_node) else RELATIONSHIP_ATTR_OBJECT_REF, + ) if next_entity is None: # FR5d — the hop's target points at a missing entity. kind = "reference" if _is_reference_hop(rel_node) else "relationship" @@ -1893,7 +1896,7 @@ def _infer_via_single_hop( for rel in base.children() if rel.type == TYPE_RELATIONSHIP and isinstance(rel.get_meta_attr(RELATIONSHIP_ATTR_OBJECT_REF), str) - and _strip_package(rel.get_meta_attr(RELATIONSHIP_ATTR_OBJECT_REF)) == target_entity.name + and bound_ref_target(rel, RELATIONSHIP_ATTR_OBJECT_REF) is target_entity ] if len(candidates) == 1: return [candidates[0]] @@ -2170,7 +2173,7 @@ def _validate_origin_paths( continue # ADR-0042 — a bare origin head (@from/@of/@via) resolves in the host/ # projection's package. - host_pkg = obj.package or obj.file_default_package or "" + host_pkg = referrer_package_of(obj) # FR-024 B5: object.value hosts are EXEMPT from @via inference and # cardinality checks — a value's origin.passthrough is FR-015 parameter # lineage (constructed, not assembled). @from is still resolution-checked. @@ -2377,7 +2380,7 @@ def _validate_origin_paths( # ADR-0042 — a bare @objectRef resolves in the DECLARING owner's # package (an inherited field resolves in the package that declared it). ref_owner = node.parent or obj - ref_pkg = ref_owner.package or ref_owner.file_default_package or "" + ref_pkg = referrer_package_of(ref_owner) ref_target = resolve_object_ref(root, object_ref, ref_pkg) if ref_target is not None and ref_target.sub_type != OBJECT_SUBTYPE_VALUE: errors.append(MetaError( @@ -2598,11 +2601,6 @@ def _resolve_field(name: str, _base: MetaData = base) -> str | None: # reference (validation-passes.ts validateRelationships). -def _strip_package(name: str) -> str: - idx = name.rfind(PACKAGE_SEP) - return name[idx + len(PACKAGE_SEP):] if idx >= 0 else name - - def _junction_reference_fk_fields(junction: MetaData) -> list[str]: """FK field names declared by an entity's effective identity.reference children. @@ -2670,7 +2668,7 @@ def _validate_relationships(root: MetaData, errors: list[MetaError]) -> None: # makes checking each node exactly once correct. declaring_entity = rel.parent if rel.parent is not None else obj referrer_pkg = ( - declaring_entity.package or declaring_entity.file_default_package or "" + referrer_package_of(declaring_entity) ) # ADR-0039: resolving — a relationship may inherit its M:N attrs via extends. @@ -2862,7 +2860,10 @@ def _validate_one_side_reference_resolution( if not isinstance(object_ref, str) or object_ref == "": continue - candidates = reference_candidates_for(obj, object_ref) + bound_target = bound_ref_target(rel, RELATIONSHIP_ATTR_OBJECT_REF) + if bound_target is None: + continue + candidates = reference_candidates_for(obj, bound_target) source_ref_field = rel.get_meta_attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) declared = ( @@ -2899,7 +2900,7 @@ def _validate_one_side_reference_resolution( # (ladder step 3) decides. if len(candidates) <= 1: continue - resolved = resolve_relationship_reference(obj, rel.name, object_ref) + resolved = resolve_relationship_reference(obj, rel.name, bound_target) if resolved is not None: continue @@ -3042,7 +3043,7 @@ def _is_junction_shape_error_owned_by_rule_d(rel: MetaData, root: MetaData) -> b # with the derivation and emit rule (d)'s finding a second time. declaring = rel.parent referrer_pkg = ( - (declaring.package or declaring.file_default_package or "") + referrer_package_of(declaring) if declaring is not None else "" ) @@ -3862,7 +3863,7 @@ def _check_nested_payload_refs_value_only( # ADR-0042: a bare ref resolves in the DECLARING owner's package (an # inherited field resolves in the package that declared it). owner = getattr(field, "parent", None) or payload - owner_pkg = owner.package or owner.file_default_package or "" + owner_pkg = referrer_package_of(owner) target = resolve_object_ref(root, ref, owner_pkg) if target is None: continue # dangling — reported by the @objectRef resolution check @@ -3902,7 +3903,7 @@ def _validate_templates(root: MetaData, errors: list[MetaError]) -> None: for tpl in root.children(): if tpl.type != TYPE_TEMPLATE: continue - referrer_pkg = tpl.package or tpl.file_default_package or "" + referrer_pkg = referrer_package_of(tpl) is_prompt = tpl.sub_type == tc.TEMPLATE_SUBTYPE_PROMPT # ADR-0039: resolving — a template may inherit @payloadRef via extends. payload_ref = tpl.get_meta_attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) diff --git a/server/python/src/metaobjects/meta/core/attr/meta_attr.py b/server/python/src/metaobjects/meta/core/attr/meta_attr.py index f7657ae6c..a88cab3ce 100644 --- a/server/python/src/metaobjects/meta/core/attr/meta_attr.py +++ b/server/python/src/metaobjects/meta/core/attr/meta_attr.py @@ -33,6 +33,13 @@ class MetaAttr(MetaData): def __init__(self, type_: str, sub_type: str, name: str) -> None: super().__init__(type_, sub_type, name) self.value: object = None + # ADR-0059 R5 -- for a reference attribute, the top-level object each reference + # names, set once by the loader's ``resolve_references`` pass (one node for a + # scalar value; a list with ``None`` where an element did not resolve). + self.resolved_target: object = None + # ADR-0059 R5 -- the value exactly as authored, kept when ``resolve_references`` + # rewrites ``value`` to its canonical spelling. ``meta fmt`` writes this back. + self.authored_value: object = None @property def data_type(self) -> DataType: diff --git a/server/python/src/metaobjects/meta/core/object/object_extract.py b/server/python/src/metaobjects/meta/core/object/object_extract.py index 8a4b6697f..c7eb7ed65 100644 --- a/server/python/src/metaobjects/meta/core/object/object_extract.py +++ b/server/python/src/metaobjects/meta/core/object/object_extract.py @@ -39,6 +39,7 @@ from metaobjects.meta.core.validator import validator_constants as vc from metaobjects.meta.template.template_constants import TEMPLATE_ATTR_XML_TEXT from metaobjects.meta.meta_data import MetaData +from metaobjects.naming_refs import ref_target from metaobjects.shared.base_types import TYPE_VALIDATOR from metaobjects.render.extract import ( FieldKind, @@ -425,25 +426,12 @@ def _resolved_attr_string(node: MetaData, attr: str) -> str | None: def _resolve_object_ref(field: MetaField) -> MetaObject | None: - """Resolve a field's ``@objectRef`` to its ``MetaObject`` by walking to the tree - root and matching on ``resolution_key()`` (the package-folded FQN the objectRef - uses), with a short-name fallback. Returns ``None`` when unresolvable (→ the - opaque-leaf / leave-unset guard). Reflection-free: a pure metadata-tree walk.""" - ref = field.object_ref - if ref is None: - return None - root = _root_of(field) - if root is None: - return None - objects = [c for c in root.children() if isinstance(c, MetaObject)] - direct = next((o for o in objects if o.resolution_key() == ref), None) - if direct is not None: - return direct - # Short-name fallback: a bare ref or the trailing segment after the last "::". - from metaobjects.shared.separators import PACKAGE_SEP - - short = ref.rsplit(PACKAGE_SEP, 1)[-1] if PACKAGE_SEP in ref else ref - return next((o for o in objects if o.name == short), None) + """Resolve a field's ``@objectRef`` to its ``MetaObject``: the target the loader bound + once (ADR-0059 R7), else the ref resolved from the declaring node's package. Never a + short-name match. Returns ``None`` when unresolvable (-> the opaque-leaf / + leave-unset guard). Reflection-free: a pure metadata-tree walk.""" + target = ref_target(field, fc.FIELD_ATTR_OBJECT_REF) + return target if isinstance(target, MetaObject) else None def _root_of(node: MetaData) -> MetaData | None: diff --git a/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py b/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py index 806693c0b..91df41234 100644 --- a/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py +++ b/server/python/src/metaobjects/meta/core/relationship/derive_m2m_fields.py @@ -44,15 +44,15 @@ from dataclasses import dataclass from ...meta_data import MetaData -from ....naming_refs import resolve_object_ref +from ....naming_refs import ref_target from ....shared.base_types import TYPE_IDENTITY, TYPE_OBJECT -from ....shared.separators import PACKAGE_SEP from ..identity.identity_constants import ( IDENTITY_ATTR_FIELDS, IDENTITY_SUBTYPE_REFERENCE, ) from .meta_relationship import MetaRelationship -from .relationship_references import reference_target_entity +from ..identity.identity_constants import IDENTITY_REFERENCE_ATTR_REFERENCES +from .relationship_constants import RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH class M2MDerivationError(Exception): @@ -69,12 +69,6 @@ class M2MFields: target_field: str -def _strip_package(name: str) -> str: - """Return the bare object name from a (possibly package-qualified) reference.""" - idx = name.rfind(PACKAGE_SEP) - return name[idx + len(PACKAGE_SEP):] if idx >= 0 else name - - def _reference_children(junction: MetaData) -> list[MetaData]: """The junction's identity.reference children (declaration order). @@ -100,85 +94,6 @@ def _ref_fk_field(ref: MetaData) -> str | None: return None -def _ref_target_entity(ref: MetaData) -> str | None: - """The @references target-entity name of a reference (bare, package-stripped). - - GAP FIXED (was pre-existing, pre-dates #368; parked during that fix because - repairing it changes M:N derivation behaviour, which was out of scope there): - a junction reference authored with the dotted ``Entity.field`` explicit-fields - form ("Team.id") used to compare the WHOLE @references value against a bare - entity name and never match, so a M:N relationship through such a junction - derived no fields at all. Delegates the dotted-tail split to - ``relationship_references.reference_target_entity`` (the #368 fix's canonical - head-parse) and then strips any package prefix to keep this function's - bare-name contract. - - Note this function is now reached only on the DEFENSIVE fallback path — when - ``@objectRef`` does not resolve to an entity at all, which loader validation - normally prevents. Both junction matches otherwise run through - :func:`_ref_target_qualified`, which keeps the package and compares by - resolved entity identity. - """ - target = reference_target_entity(ref) - return _strip_package(target) if target else None - - -def _ref_target_qualified(ref: MetaData) -> str | None: - """The target-entity half of a reference's ``@references``, PACKAGE INTACT. - - Distinct from :func:`_ref_target_entity`, which strips the package for the - legacy bare-name compare. Identity resolution needs the qualified form to tell - ``a::NodeBase`` from ``b::NodeBase``, and needs the dotted ``Entity.field`` - form reduced to its entity head — which is exactly what the canonical - :func:`~metaobjects.meta.core.relationship.relationship_references.reference_target_entity` - already computes, so this delegates rather than keeping a third copy of the - parse. That helper searches the dot only AFTER the last ``::``, so a package - segment can never be mistaken for the field separator; a local ``find(".")`` - would have assumed packages never contain a dot instead of declining the - assumption. - - Delegating also finishes the seam with the branch that makes - :func:`_ref_target_entity` split: one parse, one behaviour, both sides of the - same ``if``. - """ - return reference_target_entity(ref) - - -def _find_root(node: MetaData) -> MetaData: - """Walk up to the tree root from *node* -- the ``MetaData`` ``resolve_object_ref`` - needs (it reads ``root.children()`` itself), computed on demand rather than taking - the caller's ``object_index``: that map is keyed by BARE name, so two same-short- - name entities in different packages collapse to one entry and neither FQN-exact - nor package-local resolution is possible from it -- exactly the shape of bug this - function now avoids (see ``_find_entity``'s replacement below).""" - root = node - while root.parent is not None: - root = root.parent - return root - - -def _find_entity(root: MetaData, name: str | None, referrer_pkg: str) -> MetaData | None: - """The root entity a reference name denotes, or ``None`` -- resolved by the ONE - matcher the loader uses (``resolve_object_ref``, ADR-0041/0042): a fully-qualified - name binds exactly on its resolution key, a bare name binds in the REFERRER's - package. - - This exists so the SUBJECT comparison below can be made on object IDENTITY. A - bare-name compare cannot tell ``a::NodeBase`` from ``b::NodeBase``, which made a - genuine cross-package hetero M:N read as a self-join the moment the subject set - held two names -- and a first-match-wins bare arm (this function's own prior - behaviour, mirroring the Java reference's deferred issue #174) bound a bare name to - whichever same-short-named entity happened to load first, which is load-ORDER - dependence, not a naming rule. Callers pass the referrer that owns the name they - are resolving -- the DECLARING entity for the relationship's own ``@objectRef``/ - ``@through``, the JUNCTION for its references' ``@references`` -- because those are - different packages whenever a base is inherited across one. - """ - if not name: - return None - return resolve_object_ref(root, name, referrer_pkg) - - def derive_m2m_fields( rel: MetaRelationship, source: MetaData, @@ -190,10 +105,9 @@ def derive_m2m_fields( compatibility but no longer used for resolution — it used to be a bare-name → object map, which collapses two same-short-name entities in different packages to one entry and cannot represent the ADR-0042 package-local - contract. Resolution instead runs through the SAME matcher the loader uses - (``resolve_object_ref``), computed against the real tree root walked up from - *rel*/*source* — see ``_find_root``/``_find_entity``. A caller with no index - handy (e.g. the loader's own pairing validation, rule (f)) may simply omit it. + contract. Resolution instead reads the bound targets of ``@through``, + ``@objectRef`` and each junction reference's ``@references`` (``ref_target``, + ADR-0059), so an inherited reference means what it meant where it was written. *source* is the entity the caller is navigating from; it is accepted alongside ``rel.parent`` as a name for the relationship's subject, and used as the declaring entity when *rel* has no object parent — see the module docstring. @@ -210,29 +124,13 @@ def derive_m2m_fields( if rel_parent is not None and rel_parent.type == TYPE_OBJECT else source ) - root = _find_root(declaring) - # ADR-0041/0042 — resolve @through through the SAME matcher the loader uses - # (resolve_object_ref, via validation_passes' _validate_relationships rule - # (c)): an FQN resolves exactly on its resolution key, a bare name resolves - # package-locally in the DECLARING entity's package. - # - # This used to be ``object_index.get(_strip_package(through_name))`` — a dict - # keyed by BARE name alone, so it always took the FIRST (or last, by - # insertion) entity with that short name — the wrong package's whenever two - # share one (`xpkg-m2n-collision` is exactly that model: two same-short-named - # junctions in different packages). A valid cross-package M:N would silently - # derive against the wrong junction, whose references point nowhere near the - # navigating entity — see the TS reference's derive-m2m-fields.ts header for - # the same fix. - referrer_pkg = declaring.package or declaring.file_default_package or "" - through_name = rel.through() if through_name is None: raise M2MDerivationError( f'relationship "{declaring.name}.{rel.name}" is missing @through ' f"(required for M:N derivation)" ) - junction = resolve_object_ref(root, through_name, referrer_pkg) + junction = ref_target(rel, RELATIONSHIP_ATTR_THROUGH) if junction is None: raise M2MDerivationError( f'relationship "{declaring.name}.{rel.name}" @through "{through_name}" ' @@ -276,25 +174,17 @@ def derive_m2m_fields( def _is_subject(entity: MetaData | None) -> bool: return entity is not None and (entity is declaring or entity is source) - # @objectRef resolves in the DECLARING entity's package (referrer_pkg), same - # as @through above. Defensive bare fallback when it does not resolve — - # loader validation normally guarantees it does. Same carve-out the Java - # reference makes. - target_entity_node = _find_entity(root, target_name, referrer_pkg) - is_self_join = ( - _is_subject(target_entity_node) - if target_entity_node is not None - else _strip_package(target_name) in subject_names - ) - - # The junction owns its references' @references names, so those resolve in - # the JUNCTION's package; the relationship's own @objectRef resolves in the - # DECLARING entity's. Under `extends` across packages these differ, and using - # one for both is what bound a bare @objectRef to the wrong same-named entity. - junction_pkg = junction.package or junction.file_default_package or "" + target_entity_node = ref_target(rel, RELATIONSHIP_ATTR_OBJECT_REF) + if target_entity_node is None: + # ADR-0059 R7: no short-name fallback. An unbound @objectRef is a model error. + raise M2MDerivationError( + f'relationship "{declaring.name}.{rel.name}" @objectRef "{target_name}" ' + f"does not resolve to an entity" + ) + is_self_join = _is_subject(target_entity_node) def _ref_entity(r: MetaData) -> MetaData | None: - return _find_entity(root, _ref_target_qualified(r), junction_pkg) + return ref_target(r, IDENTITY_REFERENCE_ATTR_REFERENCES) if not is_self_join: # Hetero: match each reference by the ENTITY OBJECT it resolves to. @@ -307,21 +197,14 @@ def _ref_entity(r: MetaData) -> MetaData | None: # bare compare could match the SOURCE-side reference again whenever the # target's short name equals the source's, and silently return (src_fk, src_fk). # Java matches identity on both sides (findRefToSubject + findRefToObject). - target_ref = ( - next((r for r in refs if _ref_entity(r) is target_entity_node), None) - if target_entity_node is not None - else next( - (r for r in refs if _ref_target_entity(r) == _strip_package(target_name)), - None, - ) - ) + target_ref = next((r for r in refs if _ref_entity(r) is target_entity_node), None) source_field = _ref_fk_field(source_ref) if source_ref is not None else None target_field = _ref_fk_field(target_ref) if target_ref is not None else None if source_field is None or target_field is None: raise M2MDerivationError( f'junction "{through_name}" for relationship ' f'"{declaring.name}.{rel.name}" must declare one identity.reference ' - f'to {subject_label} and one to "{_strip_package(target_name)}"' + f'to {subject_label} and one to "{target_entity_node.name}"' ) return M2MFields(source_field=source_field, target_field=target_field) diff --git a/server/python/src/metaobjects/meta/core/relationship/relationship_references.py b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py index f7b3f83b2..b3f00cada 100644 --- a/server/python/src/metaobjects/meta/core/relationship/relationship_references.py +++ b/server/python/src/metaobjects/meta/core/relationship/relationship_references.py @@ -17,7 +17,7 @@ from __future__ import annotations from ...meta_data import MetaData -from ....naming import strip_package +from ....naming_refs import ref_target from ....shared.base_types import TYPE_IDENTITY from ....shared.separators import PACKAGE_SEP from ..identity.identity_constants import ( @@ -71,7 +71,7 @@ def reference_target_entity(ref: MetaData) -> str | None: None when the attr is absent or empty. Reused (not reimplemented) by ``codegen.generators.router_generator.reverse_fks_for`` - and ``derive_m2m_fields._ref_target_entity`` — both had their own copy of this exact + and ``derive_m2m_fields`` — both had their own copy of this exact blind spot before #368's follow-up fixed them onto this one. ``naming_refs. _split_child_tail`` runs the same character-level search but stays a separate, private implementation (desugar-phase, over raw pre-resolution strings, five @@ -110,22 +110,21 @@ def _add(value: str | None) -> None: return keys -def reference_candidates_for(holder: MetaData, target_entity: str) -> list[MetaData]: - """Every identity.reference on ``holder`` whose @references targets - ``target_entity``. Package-insensitive on both sides: @references and - @objectRef may each be bare or fully qualified, and @references may use the - dotted ``Entity.field`` form (see :func:`reference_target_entity`). +def reference_candidates_for(holder: MetaData, target: MetaData | None) -> list[MetaData]: + """Every identity.reference on ``holder`` whose @references binds to ``target``. + + ADR-0059 R7: compared by the BOUND object, never by a short name. ``target`` is the + entity node. A ``None`` target matches nothing. """ - target = strip_package(target_entity) + target_node = target + if target_node is None: + return [] candidates: list[MetaData] = [] # ADR-0039: resolving -- children() honors references inherited via extends. for child in holder.children(): if child.type != TYPE_IDENTITY or child.sub_type != IDENTITY_SUBTYPE_REFERENCE: continue - references = reference_target_entity(child) - if references is None: - continue - if strip_package(references) != target: + if ref_target(child, IDENTITY_REFERENCE_ATTR_REFERENCES) is not target_node: continue if _first_fk_field(child) is None: continue @@ -136,7 +135,7 @@ def reference_candidates_for(holder: MetaData, target_entity: str) -> list[MetaD def resolve_relationship_reference( holder: MetaData, relationship_name: str, - target_entity: str, + target_entity: MetaData | None, source_ref_field: str | None = None, ) -> MetaData | None: """Which identity.reference does this ``@cardinality: one`` relationship diff --git a/server/python/src/metaobjects/meta/core/reporting/report_shape.py b/server/python/src/metaobjects/meta/core/reporting/report_shape.py index 1d2689620..c56efafad 100644 --- a/server/python/src/metaobjects/meta/core/reporting/report_shape.py +++ b/server/python/src/metaobjects/meta/core/reporting/report_shape.py @@ -13,7 +13,7 @@ from dataclasses import dataclass -from ....naming_refs import CHILD_REF_SEP, resolve_object_ref +from ....naming_refs import CHILD_REF_SEP, ref_target, resolve_object_ref from ....shared.base_types import TYPE_IDENTITY, TYPE_RELATIONSHIP from ....shared.separators import PACKAGE_SEP from ...meta_data import MetaData @@ -208,8 +208,7 @@ def _resolve_via_hop(current: MetaObject, hop_name: str, root: MetaRoot) -> Meta ) if hop is None: return None - ref = hop.get_meta_attr(IDENTITY_REFERENCE_ATTR_REFERENCES if is_reference else RELATIONSHIP_ATTR_OBJECT_REF) - target = resolve_object_ref(root, ref, _package_of_key(current.resolution_key())) if isinstance(ref, str) else None + target = ref_target(hop, IDENTITY_REFERENCE_ATTR_REFERENCES if is_reference else RELATIONSHIP_ATTR_OBJECT_REF) return target if isinstance(target, MetaObject) else None @@ -269,7 +268,7 @@ def report_spine_hops(report: MetaObject, from_: MetaObject, root: MetaRoot) -> return None hops = reporting_via_hops(spine, from_, from_, root) if hops is None: - raise _unresolved(report.name, f"@spine '{spine}'") + raise _unresolved(report.name, f"@spine '{report.authored_attr('spine')}'") return hops @@ -330,12 +329,9 @@ def _via_joins_inner(hops: list[str], from_: MetaObject, root: MetaRoot) -> bool hop = rel if rel is not None else ref_node if hop is None: return False - target_ref = hop.get_meta_attr( - RELATIONSHIP_ATTR_OBJECT_REF if rel is not None else IDENTITY_REFERENCE_ATTR_REFERENCES + target = ref_target( + hop, RELATIONSHIP_ATTR_OBJECT_REF if rel is not None else IDENTITY_REFERENCE_ATTR_REFERENCES ) - if not isinstance(target_ref, str): - return False - target = resolve_object_ref(root, target_ref, _package_of_key(current.resolution_key())) if not isinstance(target, MetaObject): return False # The reference the hop crosses, as the lowering resolves it. A has-many hop, whose @@ -348,7 +344,7 @@ def _via_joins_inner(hops: list[str], from_: MetaObject, root: MetaRoot) -> bool reference = resolve_relationship_reference( current, hop_name, - target.name, + target, source_ref_field if isinstance(source_ref_field, str) else None, ) if reference is None: @@ -387,13 +383,13 @@ def _dimension_required( return False hops = reporting_via_hops(via, reporting_member_owner(dim, from_), from_, root) if hops is None: - raise _unresolved(report_name, f"dimension '{dim.name}' @via '{via}'") + raise _unresolved(report_name, f"dimension '{dim.name}' @via '{dim.authored_attr('via')}'") return _via_joins_inner(hops, from_, root) if via is None: return False # a column of @from: null in a spine row with no facts hops = reporting_via_hops(via, reporting_member_owner(dim, from_), from_, root) if hops is None: - raise _unresolved(report_name, f"dimension '{dim.name}' @via '{via}'") + raise _unresolved(report_name, f"dimension '{dim.name}' @via '{dim.authored_attr('via')}'") return hops == spine and (of_required or _is_primary_key_field(named, of)) @@ -410,7 +406,7 @@ def _dimension_field( via = dim.via() # A @via that does not walk is the @via's error, not the @of's (the @of resolves at its end). if via is not None and _reporting_via_terminal(via, reporting_member_owner(dim, from_), from_, root) is None: - raise _unresolved(report_name, f"dimension '{dim.name}' @via '{via}'") + raise _unresolved(report_name, f"dimension '{dim.name}' @via '{dim.authored_attr('via')}'") ref = resolve_dimension_of(dim, from_, root) if ref is None: raise _unresolved(report_name, f"dimension '{item.name}' @of") diff --git a/server/python/src/metaobjects/meta/core/requirement/resolve_claim.py b/server/python/src/metaobjects/meta/core/requirement/resolve_claim.py index 73af4c002..17185f806 100644 --- a/server/python/src/metaobjects/meta/core/requirement/resolve_claim.py +++ b/server/python/src/metaobjects/meta/core/requirement/resolve_claim.py @@ -9,7 +9,7 @@ from __future__ import annotations from metaobjects.meta.meta_data import MetaData -from metaobjects.naming_refs import resolve_object_ref +from metaobjects.naming_refs import RefOverDropError, owner_candidate_keys, resolve_object_ref from metaobjects.shared.base_types import TYPE_OBJECT, TYPE_REQUIREMENT from metaobjects.shared.separators import PACKAGE_SEP @@ -47,21 +47,20 @@ def resolve_claim_target(root: MetaData, owner: str, referrer_pkg: str) -> MetaD candidates = [c for c in root.children() if c.type != TYPE_OBJECT and c.type != TYPE_REQUIREMENT] - # A fully-qualified reference binds exactly, like every other FQN in the model. - if PACKAGE_SEP in owner: - return next((c for c in candidates if c.resolution_key() == owner), None) - # A bare reference prefers the referrer's own package, then a root-level node of that - # bare name. An ambiguous bare name binds NOTHING — the same fail-closed rule objects - # use, because silently picking one of two same-named nodes is how a claim ends up - # pointing at the wrong thing without anyone noticing. - if referrer_pkg: - local = [c for c in candidates if c.resolution_key() == f"{referrer_pkg}{PACKAGE_SEP}{owner}"] - if len(local) == 1: - return local[0] - # Root-level (unpackaged) only, matching resolve_object_ref's own bare fallback. A bare - # ref must not reach into an arbitrary package just because the name is unique there. - bare = [c for c in candidates if c.name == owner and c.resolution_key() == owner] - return bare[0] if len(bare) == 1 else None + # Same exact-key forms as every other reference (ADR-0059 R2): `::Rest`, `..::Rest`, + # `pkg::Name`, and a bare name in the referrer's own package then root-level. An + # ambiguous key binds NOTHING -- the same fail-closed rule objects use, because + # silently picking one of two same-named nodes is how a claim ends up pointing at + # the wrong thing without anyone noticing. + try: + keys = owner_candidate_keys(owner, referrer_pkg) + except RefOverDropError: + return None + for key in keys: + hits = [c for c in candidates if c.name and c.resolution_key() == key] + if len(hits) == 1: + return hits[0] + return None def resolve_member(obj: MetaData, path: list[str]) -> MetaData | None: diff --git a/server/python/src/metaobjects/meta/meta_data.py b/server/python/src/metaobjects/meta/meta_data.py index b4342bcac..bbc0e907b 100644 --- a/server/python/src/metaobjects/meta/meta_data.py +++ b/server/python/src/metaobjects/meta/meta_data.py @@ -26,7 +26,11 @@ def __init__(self, type_: str, sub_type: str, name: str) -> None: # per-file root packages are no longer reachable via the parent chain. # Mirrors TS ``MetaData.fileDefaultPackage`` / ``resolutionKey()``. self.file_default_package: Optional[str] = None + # ADR-0059 R5 -- ``super_ref`` is canonical after the loader's + # ``resolve_references`` pass; the ``extends`` value as authored is kept here + # (set only when the pass rewrote it) for ``meta fmt``. self.super_ref: Optional[str] = None + self.authored_super_ref: Optional[str] = None self.super_data: Optional[MetaData] = None self.is_abstract = False self.is_overlay = False @@ -107,15 +111,26 @@ def effective_package(self) -> str: this node in": ``package`` is ``None`` on an object that inherits its file's root package. Mirrors C# ``NamingRefs.EffectivePackage``. """ - pkg = self.package or self.file_default_package - if not pkg: - node = self.parent - while node is not None: - if node.package: - pkg = node.package - break - node = node.parent - return pkg or "" + if self.package: + return self.package + # ADR-0059 R10 -- the default package is per FILE: a parsed node records its + # file's package, "" when the file declares none, and never inherits the merge + # target's. Only a node built in code (no file) falls back to an ancestor. + if self.file_default_package is not None: + return self.file_default_package + node = self.parent + while node is not None: + if node.package: + return node.package + node = node.parent + return "" + + def root(self) -> "MetaData": + """The topmost ancestor of this node (itself when it has no parent).""" + node = self + while node.parent is not None: + node = node.parent + return node def _require_mutable(self) -> None: if self._frozen: @@ -165,6 +180,23 @@ def get_meta_attr(self, name: str) -> object: """ return self.attrs().get(name) + def authored_attr(self, name: str) -> object: + """ADR-0059 -- effective attr value for *name* as the author WROTE it: for a + reference the loader rewrote to its canonical spelling, the authored string; for + every other attr, the value. For human-facing text (descriptions) only -- code + that binds a reference reads :meth:`get_meta_attr`.""" + node: Optional[MetaData] = self + seen: set[int] = set() + while node is not None and id(node) not in seen: + seen.add(id(node)) + # ADR-0039 sanctioned own: locating the layer that declares the attr. + attr = node._attr_nodes.get(name) + if attr is not None: + authored = getattr(attr, "authored_value", None) + return authored if authored is not None else getattr(attr, "value", None) + node = node.super_data + return None + def resolved_is_array(self) -> bool: """RESOLVING array-ness — the effective ``isArray`` for this node. diff --git a/server/python/src/metaobjects/naming_refs.py b/server/python/src/metaobjects/naming_refs.py index 159214ddc..17d4b6d10 100644 --- a/server/python/src/metaobjects/naming_refs.py +++ b/server/python/src/metaobjects/naming_refs.py @@ -1,31 +1,27 @@ -"""FR-032 (ADR-0032) — canonical reference expansion. - -Mirrors the TS reference at -``server/typescript/packages/metadata/src/naming-refs.ts``. - -``expand_ref(raw, package_context)`` is the SINGLE ref-expansion primitive. It -lowers an authored metadata reference to its fully-qualified canonical form per -ADR-0032 §2.1 — deterministically, with NO root fallback: - - bare ``Name`` (no ``::``, no leading ``.``) → ``

    ::Name`` (current package - only; stays bare when P is - empty/root). - qualified ``pkg::Name`` (contains ``::``, → unchanged (absolute from root). - NOT leading ``::``) - ``::Rest`` (leading ``::``) → strip the leading ``::``; the - remainder is absolute from root. - ``..::Rest`` (one or more leading ``..::``) → drop one package segment from P - per ``..::``, then resolve Rest - against the reduced package. - Over-drop raises. - -The trailing FR-024 DOTTED CHILD suffix (``.child`` / ``.child.grandchild``) is -PRESERVED verbatim: only the OWNER part (everything before the first ``.`` in the -final ``::``-segment) is expanded; the ``.child...`` tail is reattached. - -The desugar runs this on every ref-bearing attr so canonical JSON is FQN-only; -the resolution layer then does pure FQN matching. The ``package`` attribute is -NEVER expanded — it is the node's identity. +"""References: the one resolver every reference form goes through (ADR-0059). + +Mirrors the TS reference at ``server/typescript/packages/metadata/src/naming-refs.ts``. + +Sources do not interpret references: YAML, canonical JSON and any future source build +the tree with every reference string exactly as authored. This module gives each form +its meaning, once, against the built tree: + + ``::Rest`` root-absolute -- the node whose resolution key is exactly ``Rest`` + (``::Apple`` is the root-level Apple; ``::a::b::C`` is ``a::b::C``). + ``..::Rest`` parent-relative -- drop one segment of the referrer's package per + ``..::``, then the exact key ``::Rest``. More ``..::`` + than the package has segments is ``ERR_BAD_ATTR_VALUE``. + ``pkg::Name`` exact. + ``Name`` the referrer's own package (``::Name``), else the + root-level ``Name`` (ADR-0042 section 2). Never another package. + +No form falls back to another: ``::Apple`` never binds a local ``acme::Apple``. A +FR-024 dotted child tail (``Owner.child.grand``) starts at the first ``.`` after the +last ``::``; only the owner part is resolved by form, and the tail is kept as written. + +:func:`spell_ref` is the inverse: the one canonical spelling of a resolved target that +resolves to the same node with or without a referrer. The ``resolve_references`` loader +pass rewrites every reference to it, so consumers read canonical values. """ from __future__ import annotations @@ -37,6 +33,7 @@ RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH, ) +from .meta.core.object.object_constants import OBJECT_REPORT_ATTR_SPINE from .meta.persistence.origin.origin_constants import ( ORIGIN_ATTR_FROM, ORIGIN_ATTR_OF, @@ -61,29 +58,18 @@ PARENT_PREFIX = PACKAGE_PARENT + PACKAGE_SEP # "..::" -def is_relative_ref(raw: str) -> bool: - """FR-032 — True when *raw* is a relative reference form (``::Rest`` or - ``..::Rest``) that the YAML desugar must expand before canonical JSON. - Canonical JSON must be FQN; a relative ref surviving into it is - ``ERR_RELATIVE_REF_IN_CANONICAL``. Mirrors TS ``isRelativeRef``. - """ - return raw.startswith(PACKAGE_SEP) or raw.startswith(PARENT_PREFIX) - -#: FR-032 — the inline (``@``-prefixed in canonical JSON; bare in YAML) attribute -#: names whose VALUE is a metadata reference subject to FQN expansion (ADR-0032 -#: §3). The structural ``extends`` key is handled separately (not ``@``-prefixed). -#: ``@objectRef``/``@references`` are pure object refs; ``@from``/``@of``/``@via`` -#: carry an entity HEAD with a possible dotted relationship/field tail (expand_ref -#: preserves the tail); ``@parameterRef``/``@payloadRef``/``@responseRef`` -#: reference value-objects. ``@through`` (the M:N junction ref) is in the set per -#: ADR-0042 §4 — it desugars to FQN and resolves package-local like every other -#: object ref. ``@sourceRefField`` (a FK FIELD name, not an object ref) is NOT in -#: the set. The ``package`` attr is intentionally NOT here — it is the node's -#: identity. Mirrors TS REF_BEARING_ATTR_NAMES. +#: The inline attribute names whose VALUE is a metadata object reference (ADR-0059 R4). +#: The structural ``extends`` key is handled separately (not ``@``-prefixed). +#: ``@objectRef``/``@references``/``@through`` are pure object refs; +#: ``@from``/``@of``/``@via``/``@spine`` carry an entity HEAD with a dotted +#: relationship/field tail; ``@parameterRef``/``@payloadRef``/``@responseRef`` name +#: value-objects. The same set is used in every port. ``@sourceRefField`` (a FK FIELD +#: name) and ``@implementedBy`` (which may name a template as well as an object) are +#: NOT in the set. Mirrors TS REF_BEARING_ATTR_NAMES. REF_BEARING_ATTR_NAMES: frozenset[str] = frozenset({ RELATIONSHIP_ATTR_OBJECT_REF, # == FIELD_ATTR_OBJECT_REF ("objectRef") FIELD_ATTR_OBJECT_REF, - RELATIONSHIP_ATTR_THROUGH, # ADR-0042: the M:N junction ref joins the desugar+resolution set. + RELATIONSHIP_ATTR_THROUGH, IDENTITY_REFERENCE_ATTR_REFERENCES, ORIGIN_ATTR_FROM, ORIGIN_ATTR_VIA, @@ -91,10 +77,11 @@ def is_relative_ref(raw: str) -> bool: SOURCE_ATTR_PARAMETER_REF, TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_RESPONSE_REF, + OBJECT_REPORT_ATTR_SPINE, }) -def _split_child_tail(raw: str) -> tuple[str, str]: +def split_child_tail(raw: str) -> tuple[str, str]: """Split a ref into its owner part and any FR-024 dotted child tail. The ``.`` that marks a child can only appear in the FINAL ``::``-segment @@ -156,81 +143,188 @@ def _expand_owner(owner: str, package_context: str) -> str: def expand_ref(raw: str, package_context: str) -> str: - """Expand an authored reference to its fully-qualified canonical form per - ADR-0032 §2.1, preserving any FR-024 dotted child suffix. *package_context* - is the declaring node's effective package. Raises ``ValueError`` on a - parent-relative over-drop — the caller emits an error. + """Expand an authored reference against a package context, preserving any FR-024 + dotted child suffix. Raises ``ValueError`` on a parent-relative over-drop. """ - owner, tail = _split_child_tail(raw) + owner, tail = split_child_tail(raw) return _expand_owner(owner, package_context) + tail -# --------------------------------------------------------------------------- -# ADR-0042 — package-local OBJECT reference resolution contract -# --------------------------------------------------------------------------- -# The SINGLE resolver every object-ref site shares so the package-local contract -# is uniform. Mirrors the TS reference ``resolveObjectRef`` / ``refMatchesObject`` -# / ``didYouMeanHint`` in naming-refs.ts. A bare ref resolves in the referrer's -# OWN package first, else a root-level (empty-package) object of that name — there -# is NO unique-anywhere scan and NO bare-tail fallback, so cross-package ambiguity -# is unreachable (ADR-0042 retires ERR_AMBIGUOUS_REF). +class RefOverDropError(ValueError): + """A parent-relative reference with more ``..::`` than its referrer's package has + segments. Reported as ``ERR_BAD_ATTR_VALUE``.""" + code = "ERR_BAD_ATTR_VALUE" + + +def owner_candidate_keys(owner: str, referrer_pkg: str) -> list[str]: + """The resolution keys an OWNER reference (no child tail) may bind, in the order to + try them (ADR-0059 R2). Every form but bare is one exact key; bare is the referrer's + own package, then root-level. Raises :class:`RefOverDropError` on over-drop.""" + if owner.startswith(PACKAGE_SEP): + return [owner[len(PACKAGE_SEP):]] + if owner.startswith(PARENT_PREFIX): + try: + return [_expand_owner(owner, referrer_pkg)] + except ValueError as err: + raise RefOverDropError(str(err)) from err + if PACKAGE_SEP in owner: + return [owner] + return [f"{referrer_pkg}{PACKAGE_SEP}{owner}", owner] if referrer_pkg else [owner] + + +def _top_level_of_type(root: "MetaData", type_: Optional[str]) -> list["MetaData"]: + # ADR-0039: the root has no super, so its children ARE its own children. + kids = root.children() + return kids if type_ is None else [c for c in kids if c.type == type_] -def ref_matches_object(node: "MetaData", ref: str, referrer_pkg: str = "") -> bool: - """ADR-0042 — does root-level object *node* satisfy object reference *ref*, - declared by a node whose effective package is *referrer_pkg*? - - * **FQN** *ref* (contains ``::``) → EXACT match on ``resolution_key()``. No - bare-tail fallback, so an FQN pointing at one package never binds a - same-named object in another. - * **bare** *ref* (no ``::``) → the referrer's OWN package - (``::``), else a **root-level** (empty-package) object - whose resolution key IS *ref*. - - *referrer_pkg* defaults to ``""`` — the fail-closed FQN-exact-plus-root-level - behavior. Mirrors the TS ``refMatchesObject``. - """ - key = node.resolution_key() - if PACKAGE_SEP in ref: - return key == ref - if referrer_pkg and key == f"{referrer_pkg}{PACKAGE_SEP}{ref}": - return True - return key == ref # root-level (empty-package) object whose key is the bare name + +def _bind_owner( + candidates: list["MetaData"], owner: str, referrer_pkg: str +) -> tuple[Optional["MetaData"], Optional[str]]: + """Bind an owner reference among *candidates* by exact resolution key (R2). + Returns ``(node, error)``: *error* is the over-drop message, else ``None``.""" + try: + keys = owner_candidate_keys(owner, referrer_pkg) + except RefOverDropError as err: + return None, str(err) + for key in keys: + for c in candidates: + if c.resolution_key() == key: + return c, None + return None, None + + +def resolve_object_ref_checked( + root: "MetaData", ref: str, referrer_pkg: str +) -> tuple[Optional["MetaData"], Optional[str]]: + """Resolve a metadata OBJECT reference (no child tail) under ADR-0059 R2. + Returns ``(node, error)``; *error* is set for a parent-relative over-drop.""" + return _bind_owner(_top_level_of_type(root, TYPE_OBJECT), ref, referrer_pkg) def resolve_object_ref( root: "MetaData", ref: str, referrer_pkg: str ) -> Optional["MetaData"]: - """Resolve a metadata OBJECT reference under the ADR-0042 package-local contract - (see :func:`ref_matches_object` for the matcher). *referrer_pkg* is the effective - package of the node carrying the ref. Returns the resolved object, or ``None`` - when nothing matches — there is NO ambiguous outcome (bare = package-local, so - ambiguity is unreachable). The SINGLE resolver every object-ref site shares so - the contract is uniform. Mirrors the TS ``resolveObjectRef``. - """ - objects = [c for c in root.children() if c.type == TYPE_OBJECT] - if PACKAGE_SEP in ref: - return next((c for c in objects if c.resolution_key() == ref), None) - # Bare: PREFER the referrer's own package, THEN a root-level (empty-package) - # object — mirroring the loader symbol table so both resolvers agree when a - # root-level object shares the bare name. - local_key = f"{referrer_pkg}{PACKAGE_SEP}{ref}" if referrer_pkg else ref - own = next((c for c in objects if c.resolution_key() == local_key), None) - if own is not None: - return own - if local_key != ref: - return next((c for c in objects if c.resolution_key() == ref), None) - return None + """The single object resolver every object-ref site shares (ADR-0059 R2): + :func:`resolve_object_ref_checked` without the over-drop message. An over-drop + resolves to ``None``; the validation walk reports it.""" + return resolve_object_ref_checked(root, ref, referrer_pkg)[0] + + +def ref_matches_object(node: "MetaData", ref: str, referrer_pkg: str = "") -> bool: + """Does root-level object *node* satisfy object reference *ref* (no child tail) + declared by a node whose effective package is *referrer_pkg*? Agrees with + :func:`resolve_object_ref`; never matches on a short name.""" + root = node.parent or node + resolved = resolve_object_ref(root, ref, referrer_pkg) + return resolved is node + + +def resolve_ref( + root: "MetaData", + ref: str, + referrer_pkg: str, + owner_type: Optional[str] = None, + last_type: Optional[str] = None, +) -> tuple[Optional["MetaData"], Optional["MetaData"], Optional[str]]: + """Resolve a reference that may carry a dotted child tail (ADR-0059 R2): bind the + owner among the root's top-level nodes (of *owner_type* when given), then walk the + tail by child name. Intermediate segments must be unique among the current node's + effective children; the last is scoped to *last_type* when given. Returns + ``(node, owner, error)``.""" + owner_ref, tail = split_child_tail(ref) + bound, err = _bind_owner(_top_level_of_type(root, owner_type), owner_ref, referrer_pkg) + if bound is None: + return None, None, err + if tail == "": + return bound, bound, None + path = tail[len(CHILD_REF_SEP):].split(CHILD_REF_SEP) + if any(p == "" for p in path): + return None, None, None + current = bound + for seg in path[:-1]: + matches = [c for c in current.children() if c.name == seg] + if len(matches) != 1: + return None, bound, None + current = matches[0] + last = path[-1] + target = next( + (c for c in current.children() if c.name == last and (last_type is None or c.type == last_type)), + None, + ) + return target, bound, None + + +def lookup_canonical( + root: "MetaData", + value: str, + owner_type: Optional[str] = None, + last_type: Optional[str] = None, +) -> Optional["MetaData"]: + """ADR-0059 R7 -- resolve a CANONICAL reference with no referrer: strip one leading + ``::``, match a top-level node's resolution key exactly, then walk any dotted tail. + For consumers that hold a resolved model and no referrer context. Never matches on a + short name.""" + owner, tail = split_child_tail(value) + exact = owner if owner.startswith(PACKAGE_SEP) else f"{PACKAGE_SEP}{owner}" + return resolve_ref(root, exact + tail, "", owner_type, last_type)[0] + + +def spell_ref(target: "MetaData", referrer_pkg: str, tail: str = "") -> str: + """ADR-0059 R6 -- the canonical spelling of a resolved owner, as written from a + referrer in package *referrer_pkg*: ``pkg::Name`` for a packaged target, ``::Name`` + for a root-level target from a packaged referrer, ``Name`` from a root-level + referrer. Every spelling resolves to the same node with or without a referrer.""" + key = target.resolution_key() + root_level = PACKAGE_SEP not in key + return (f"{PACKAGE_SEP}{key}" if root_level and referrer_pkg else key) + tail + + +def effective_package_of(node: "MetaData") -> str: + """Effective package of a node: its own ``package``, else its file's (ADR-0059 R10).""" + return node.effective_package() + + +def top_level_node_of(node: "MetaData") -> "MetaData": + """The top-level node (a direct child of the root) that physically contains *node*.""" + current = node + while current.parent is not None and current.parent.parent is not None: + current = current.parent + return current + + +def referrer_package_of(node: "MetaData") -> str: + """ADR-0059 R3 -- the ONE referrer-package idiom: the effective package of the + top-level node that physically contains *node* (the node itself when it is top-level).""" + return effective_package_of(top_level_node_of(node)) + + +def referrer_package_for(node: "MetaData", attr_name: str) -> str: + """ADR-0059 R3 default -- the package a reference attribute resolves in: the effective + package of the top-level node that PHYSICALLY declares the attribute. For an + attribute *node* inherits through ``extends``, that is the ancestor that wrote it, so + an inherited reference means what it meant where it was written.""" + declarer: Optional["MetaData"] = node + seen: set[int] = set() + # ADR-0039 sanctioned own: locating the layer that physically declares the attr. + while ( + declarer is not None + and declarer.own_meta_attr(attr_name) is None + and id(declarer) not in seen + ): + seen.add(id(declarer)) + declarer = declarer.super_data + return referrer_package_of(declarer if declarer is not None else node) def did_you_mean_hint(root: "MetaData", ref: str) -> str: - """ADR-0042 §5 — a did-you-mean suffix for an UNRESOLVED object reference: the + """ADR-0042 section 5 -- a did-you-mean suffix for an UNRESOLVED object reference: the FQNs of same-short-name objects that DO exist (typically in other packages), so the author can qualify a bare ref they meant to point across a package boundary. - Returns ``""`` when no same-short-name object exists. Appended to the per-attr - unresolved-ref error message. Mirrors the TS ``didYouMeanHint``. + Returns ``""`` when no same-short-name object exists. Mirrors the TS ``didYouMeanHint``. """ - owner, _tail = _split_child_tail(ref) + owner, _tail = split_child_tail(ref) sep = owner.rfind(PACKAGE_SEP) short_name = owner if sep == -1 else owner[sep + len(PACKAGE_SEP):] candidates = [ @@ -244,3 +338,30 @@ def did_you_mean_hint(root: "MetaData", ref: str) -> str: f' An object named "{short_name}" exists in: {", ".join(candidates)}. ' "Qualify it with its package (FQN)." ) + + +def ref_target(node: "MetaData", attr_name: str) -> Optional["MetaData"]: + """ADR-0059 R7 -- the object a reference attribute on *node* names: the owner the + loader bound once (``MetaAttr.resolved_target``), read RESOLVING so an inherited + attribute works (ADR-0039). For a dotted ref it is the owner object, not the tail. A + tree built without the loader has no recorded target, so the value is resolved here + from the package of the node that declared it. ``None`` when the attribute is absent, + is a list, or names nothing. Consumers use this instead of comparing a reference + value to a short name.""" + seen: set[int] = set() + n: Optional["MetaData"] = node + while n is not None and id(n) not in seen: + seen.add(id(n)) + # ADR-0039 sanctioned own: locating the layer that declares the attr, walking the super chain. + attr = n.own_meta_attr(attr_name) + if attr is not None: + recorded = getattr(attr, "resolved_target", None) + if recorded is not None: + return None if isinstance(recorded, list) else recorded + value = getattr(attr, "value", None) + if not isinstance(value, str) or value == "": + return None + owner, _tail = split_child_tail(value) + return resolve_object_ref(node.root(), owner, referrer_package_of(n)) + n = n.super_data + return None diff --git a/server/python/src/metaobjects/parser.py b/server/python/src/metaobjects/parser.py index 5c74fdb26..e6a9110fa 100644 --- a/server/python/src/metaobjects/parser.py +++ b/server/python/src/metaobjects/parser.py @@ -8,7 +8,6 @@ from .meta.core.attr.attr_constants import ATTR_SUBTYPE_PROPERTIES, ATTR_SUBTYPE_STRINGARRAY from .meta.meta_data import MetaData from .meta.meta_root import MetaRoot -from .naming_refs import REF_BEARING_ATTR_NAMES, is_relative_ref from .registry import TypeRegistry from .shared.base_types import ( SUBTYPE_BASE, @@ -54,38 +53,6 @@ }) -# FR-032 (ADR-0032) — canonical-JSON ref guard. Canonical JSON is the -# self-contained interchange form: every ref-bearing attr MUST be fully-qualified. -# A relative authoring form (leading ``::`` or ``..::``) surviving into canonical -# JSON is ``ERR_RELATIVE_REF_IN_CANONICAL`` — relative forms are YAML-authoring -# sugar the desugar expands before canonical JSON. This parser only handles -# canonical JSON (the desugar runs upstream for YAML), so no format check is -# needed. Like ERR_RESERVED_ATTR, this is a hard error; we append it and halt the -# node build so exactly one error is produced. Mirrors the TS/Java/C# guard. -def _guard_relative_ref_in_canonical( - ref_label: str, - raw_value: object, - wrapper: str, - source: str, - result: "ParseResult", - envelope: ErrorSource, -) -> bool: - """Append ``ERR_RELATIVE_REF_IN_CANONICAL`` when *raw_value* is a relative - reference string. Returns True when the guard fired (caller halts). - """ - if not isinstance(raw_value, str) or not is_relative_ref(raw_value): - return False - result.errors.append(MetaError( - f"node '{wrapper}' has relative reference '{raw_value}' on {ref_label}; " - f"canonical JSON must be fully-qualified — relative forms (leading '::' " - f"or '..::') are YAML-authoring sugar the desugar expands", - ErrorCode.ERR_RELATIVE_REF_IN_CANONICAL, - source, - envelope=envelope, - )) - return True - - @dataclass class ParseResult: root: MetaData @@ -417,7 +384,9 @@ def _build( # root packages are no longer reachable via the parent chain). The node's # own ``package`` if declared, else the inherited context package (the # file's root package). Mirrors TS ``MetaData.fileDefaultPackage``. - node.file_default_package = (str(pkg) if pkg else None) or (ctx_pkg or None) + # Recorded even when empty: "" says the file declares no package (ADR-0059 R10), + # which is not the same as a node built in code with no file at all. + node.file_default_package = (str(pkg) if pkg else None) or ctx_pkg if pkg: node.package = str(pkg) @@ -430,12 +399,6 @@ def _build( node.package = None if body_dict.get(KEY_EXTENDS): - # FR-032 — a relative ``extends`` in canonical JSON is illegal. - if _guard_relative_ref_in_canonical( - KEY_EXTENDS, body_dict[KEY_EXTENDS], wrapper, source, result, - _current_envelope(source, builder, yaml_position), - ): - return None node.super_ref = str(body_dict[KEY_EXTENDS]) node.is_abstract = bool(body_dict.get(KEY_ABSTRACT, False)) node.is_overlay = bool(body_dict.get(KEY_OVERLAY, False)) @@ -461,12 +424,6 @@ def _build( ) ) continue - # FR-032 — a relative inline ref-attr value in canonical JSON is illegal. - if attr_name in REF_BEARING_ATTR_NAMES and _guard_relative_ref_in_canonical( - key, value, wrapper, source, result, - _current_envelope(source, builder, yaml_position), - ): - return None schema = registry.attr_schema(type_, sub_type, attr_name) # Array-valued attrs (the `string` + is_array model that replaced the # `stringarray` subtype) coerce through the array string-attr class @@ -504,7 +461,10 @@ def _build( builder.pop() # The context package for children: use this node's own package if set, else inherit. - child_ctx_pkg = node.package or ctx_pkg + # An `overlay: true` node amends an existing node (possibly in another package), so + # its children keep the FILE's context package, not the amended node's own: a nested + # node resolves in its own file's default package (ADR-0059 R3/R10). + child_ctx_pkg = ctx_pkg if (node.is_overlay and not is_root) else (node.package or ctx_pkg) # Descend into children: push `children` key, then `[i]` index for each entry, # then the child wrapper key, so JsonPath segments stack correctly. diff --git a/server/python/src/metaobjects/runtime/n2m_resolver.py b/server/python/src/metaobjects/runtime/n2m_resolver.py index 8cf6dcbdb..5549101f8 100644 --- a/server/python/src/metaobjects/runtime/n2m_resolver.py +++ b/server/python/src/metaobjects/runtime/n2m_resolver.py @@ -22,7 +22,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import Any from ..meta.core.object.meta_object import MetaObject from ..meta.core.relationship.derive_m2m_fields import ( @@ -30,10 +30,13 @@ derive_m2m_fields, ) from ..meta.core.relationship.meta_relationship import MetaRelationship -from ..meta.core.relationship.relationship_constants import CARDINALITY_MANY +from ..meta.core.relationship.relationship_constants import ( + CARDINALITY_MANY, + RELATIONSHIP_ATTR_OBJECT_REF, + RELATIONSHIP_ATTR_THROUGH, +) +from ..naming_refs import ref_target -if TYPE_CHECKING: # pragma: no cover - from ..meta.meta_root import MetaRoot @dataclass(frozen=True) @@ -70,7 +73,7 @@ def resolve_n2m_descriptor( the caller should fall through to its 1:1 / 1:N relation path. Raises :class:`N2mResolutionError` when the relationship IS M:N but malformed. - *object_index* is a bare-name → object map of the loaded model's top-level + *object_index* is a resolution-key → object map of the loaded model's top-level objects (mirrors the TS ``root.findObject`` resolution surface). """ # ADR-0039: effective children — a M:N relationship may be inherited via @@ -103,10 +106,18 @@ def resolve_n2m_descriptor( f"M:N relationship '{relation_name}' on '{source_entity.name}': {e}" ) from e + # ADR-0059 R7: names come from the BOUND targets, never from stripping the ref. + target_node = ref_target(child, RELATIONSHIP_ATTR_OBJECT_REF) + junction_node = ref_target(child, RELATIONSHIP_ATTR_THROUGH) + if target_node is None or junction_node is None: + raise N2mResolutionError( + f"M:N relationship '{relation_name}' on '{source_entity.name}': " + f"@objectRef '{target_name}' / @through '{junction_name}' must resolve to loaded entities" + ) return N2mDescriptor( source_entity_name=source_entity.name, - target_entity_name=_strip_package(target_name), - junction_entity_name=_strip_package(junction_name), + target_entity_name=target_node.name, + junction_entity_name=junction_node.name, source_field=fields.source_field, target_field=fields.target_field, symmetric=child.symmetric(), @@ -154,6 +165,3 @@ def collect_column_ids(join_rows: list[dict[str, Any]], col: str) -> list[Any]: return list(seen.values()) -def _strip_package(name: str) -> str: - idx = name.rfind("::") - return name[idx + 2:] if idx >= 0 else name diff --git a/server/python/src/metaobjects/runtime/validator_runner.py b/server/python/src/metaobjects/runtime/validator_runner.py index ca047f0ab..306c64dec 100644 --- a/server/python/src/metaobjects/runtime/validator_runner.py +++ b/server/python/src/metaobjects/runtime/validator_runner.py @@ -30,7 +30,7 @@ from ..meta.core.validator import validator_constants as vc from ..meta.meta_data import MetaData from ..meta.persistence.db import db_constants as dbc -from ..naming_refs import resolve_object_ref +from ..naming_refs import ref_target from ..shared.base_types import TYPE_FIELD, TYPE_VALIDATOR @@ -205,22 +205,9 @@ def _value_object_errors( def _resolve_vo_ref(field: MetaData) -> MetaObject | None: """The ``object.value`` a ``field.object``'s ``@objectRef`` names, found by walking to - the tree root. ``None`` when unresolvable or when the target is not a value object — + its bound target (ADR-0059). ``None`` when unresolvable or when the target is not a value object — then there is no recursion, on every port.""" - # ADR-0039: resolving — @objectRef may be inherited via extends. - ref = field.get_meta_attr(fc.FIELD_ATTR_OBJECT_REF) - if not isinstance(ref, str) or not ref: - return None - root: MetaData = field - while root.parent is not None: - root = root.parent - # ADR-0042 — the SINGLE object-ref resolver every ref site shares: an FQN matches - # its resolution key exactly; a bare ref resolves in the DECLARING owner's package - # (an inherited field resolves in the package that declared it), then a root-level - # object — never a same-named object in some other package. - owner = field.parent if field.parent is not None else root - referrer_pkg = owner.package or owner.file_default_package or "" - target = resolve_object_ref(root, ref, referrer_pkg) + target = ref_target(field, fc.FIELD_ATTR_OBJECT_REF) return target if isinstance(target, MetaObject) and target.sub_type == OBJECT_SUBTYPE_VALUE else None diff --git a/server/python/src/metaobjects/serializer_json.py b/server/python/src/metaobjects/serializer_json.py index 93301fb0b..efc0c77c1 100644 --- a/server/python/src/metaobjects/serializer_json.py +++ b/server/python/src/metaobjects/serializer_json.py @@ -26,8 +26,17 @@ _SOURCE_RDB_FUSED_KEY = f"{TYPE_SOURCE}{FUSED_KEY_SEP}{SOURCE_SUBTYPE_RDB}" -def canonical_serialize(node: MetaData) -> str: - return _serialize(node, effective=False) +#: How references are written (ADR-0059 R8). ``"canonical"`` (default): each reference +#: as the loader resolved it -- ``pkg::Name``, ``::Name`` for a root-level target from a +#: packaged referrer -- and the document package rule. ``"authored"``: each reference, +#: ``extends`` included, exactly as written, and every ``package`` where it was written. +#: Only ``meta fmt`` uses it, so formatting never rewrites what an author wrote. +REFS_CANONICAL = "canonical" +REFS_AUTHORED = "authored" + + +def canonical_serialize(node: MetaData, refs: str = REFS_CANONICAL) -> str: + return _serialize(node, effective=False, refs=refs) def canonical_serialize_effective(node: MetaData) -> str: @@ -40,7 +49,7 @@ def canonical_serialize_effective(node: MetaData) -> str: still emitted on every node (the ref stays in the body), but inherited members are inlined rather than referenced. """ - return _serialize(node, effective=True) + return _serialize(node, effective=True, refs=REFS_CANONICAL) def serialize_shared_document(nodes: list[MetaData]) -> str: @@ -76,8 +85,8 @@ def serialize_shared_document(nodes: list[MetaData]) -> str: return json.dumps(doc, indent=2, ensure_ascii=False) + "\n" -def _serialize(node: MetaData, effective: bool) -> str: - parsed = _to_canonical(node, effective) +def _serialize(node: MetaData, effective: bool, refs: str = REFS_CANONICAL) -> str: + parsed = _to_canonical(node, effective, refs) # FR-016 / ADR-0018 — rewrite legacy @table → kind-matching alias on # source.rdb wrappers; run before serialization so the rewritten key sorts # naturally with the rest of the body (alphabetical at our depth). @@ -86,18 +95,72 @@ def _serialize(node: MetaData, effective: bool) -> str: return text + "\n" -def _to_canonical(node: MetaData, effective: bool = False) -> dict[str, object]: - return {f"{node.type}{FUSED_KEY_SEP}{node.sub_type}": _body(node, effective)} +def _to_canonical( + node: MetaData, + effective: bool = False, + refs: str = REFS_CANONICAL, + pkg_slot: str | None = None, +) -> dict[str, object]: + return {f"{node.type}{FUSED_KEY_SEP}{node.sub_type}": _body(node, effective, refs, pkg_slot)} + + +def _top_level_package(node: MetaData, root: MetaData) -> str: + """Effective package of a top-level node (ADR-0059 R10): own, else its file's. A node + built in code has no file, so it takes the root's package, as re-loading the output + would give it.""" + if node.package is not None: + return node.package + if node.file_default_package is not None: + return node.file_default_package + return root.package or "" + +def _document_packages(root: MetaData) -> tuple[str | None, bool]: + """ADR-0059 R8 document package rule, for a ``metadata.root`` in canonical mode. + Returns ``(root_pkg, per_node)``. -def _body(node: MetaData, effective: bool = False) -> dict[str, object]: + Every named top-level node in one non-empty package P: the root carries P (a node + keeps a package it carries itself, as before), unless the root names none and every + node already names P itself. Otherwise the root carries none and each named top-level + node carries its effective package (omitted when empty). A root with no named + top-level node keeps its own package.""" + # ADR-0039 sanctioned own: the serializer writes the declared layer; the root has no super. + named = [c for c in root.own_children() if c.name != ""] + if not named: + return root.package, False + pkgs = {_top_level_package(c, root) for c in named} + if len(pkgs) == 1: + only = next(iter(pkgs)) + if only != "": + self_named = root.package is None and all(c.package == only for c in named) + return (None if self_named else only), False + return None, True + + +def _body( + node: MetaData, + effective: bool = False, + refs: str = REFS_CANONICAL, + pkg_slot: str | None = None, +) -> dict[str, object]: body: dict[str, object] = {} if node.name: body[KEY_NAME] = node.name - if node.package: - body[KEY_PACKAGE] = node.package - if node.super_ref: - body[KEY_EXTENDS] = node.super_ref + is_doc_root = node.type == TYPE_METADATA and node.sub_type == SUBTYPE_ROOT + doc = _document_packages(node) if is_doc_root and refs == REFS_CANONICAL else None + if doc is not None: + pkg = doc[0] + elif pkg_slot is not None: + pkg = pkg_slot + else: + pkg = node.package + if pkg: + body[KEY_PACKAGE] = pkg + # After the loader's resolve_references pass ``super_ref`` is canonical (ADR-0059 + # R5); the authored spelling is kept beside it for ``meta fmt``. + super_ref = (node.authored_super_ref or node.super_ref) if refs == REFS_AUTHORED else node.super_ref + if super_ref: + body[KEY_EXTENDS] = super_ref if node.is_abstract: body[KEY_ABSTRACT] = True # ADR-0039 — isArray is a native property; in EFFECTIVE mode it must resolve @@ -116,11 +179,22 @@ def _body(node: MetaData, effective: bool = False) -> dict[str, object]: body[f"{ATTR_PREFIX}{name}"] = _normalize(node.attrs()[name]) else: for attr in sorted(node.own_meta_attrs(), key=lambda a: a.name): - body[f"{ATTR_PREFIX}{attr.name}"] = _normalize(getattr(attr, "value", None)) + authored = getattr(attr, "authored_value", None) if refs == REFS_AUTHORED else None + value = authored if authored is not None else getattr(attr, "value", None) + body[f"{ATTR_PREFIX}{attr.name}"] = _normalize(value) children = node.children() if effective else node.own_children() if children: - body[KEY_CHILDREN] = [_to_canonical(c, effective) for c in children] + # A top-level node's package follows the document rule; deeper nodes keep their + # own. In a one-package document a node keeps any package it carries itself. + per_node = doc is not None and doc[1] + body[KEY_CHILDREN] = [ + _to_canonical( + c, effective, refs, + (_top_level_package(c, node) if c.name else "") if per_node else None, + ) + for c in children + ] return body diff --git a/server/python/src/metaobjects/super_resolve.py b/server/python/src/metaobjects/super_resolve.py index 1f6e66e5f..d1552ae91 100644 --- a/server/python/src/metaobjects/super_resolve.py +++ b/server/python/src/metaobjects/super_resolve.py @@ -1,9 +1,9 @@ """Deferred super/extends resolution over the merged tree (2nd pass, pre-freeze). Mirrors TS ``resolveDeferredSupers`` in super-resolve.ts: -- Build the FQN index keyed by node.fqn() (own), over the whole merged tree. -- Collect every node carrying an unresolved super_ref, tracking each node's - inherited context package. +- Index the root's top-level nodes by exact resolution key (ADR-0059 R2). +- Collect every node carrying an unresolved super_ref; its referrer package is its + own file's effective package. - Resolve supers ON DEMAND with memoization + cycle detection (#188), NOT in a single physical-declaration-order pass — so resolution is order-independent. """ @@ -12,6 +12,7 @@ from .errors import ErrorCode, MetaError from .meta.core.identity.identity_constants import IDENTITY_UNIQUE_KEY_SUBTYPES from .meta.meta_data import MetaData +from .naming_refs import RefOverDropError, owner_candidate_keys from .shared.base_types import TYPE_IDENTITY from .shared.separators import PACKAGE_SEP from .source import resolved_source @@ -71,13 +72,9 @@ def resolve_supers(root: MetaData, errors: list[MetaError]) -> None: """ index = _build_index(root) - # Every node carrying a super_ref, over the PHYSICAL declaration tree, with - # the inherited context package captured at that node's walk position (the - # effective_pkg the original pre-order walk computed — kept byte-identical so - # every existing fixture resolves the same way). + # Every node carrying a super_ref, over the PHYSICAL declaration tree. pending: list[MetaData] = [] - pkg_of: dict[MetaData, str | None] = {} - _collect(root, "", pending, pkg_of) + _collect(root, pending) # Nodes already attempted this pass. On-demand resolution can reach a node # via the ``pending`` loop AND via owner/target recursion — ``attempted`` @@ -96,15 +93,10 @@ def resolve_node(node: MetaData) -> None: if node in attempted: return # already resolved-or-failed this pass — never re-report attempted.add(node) - # Referrer context package: own ``package`` if declared, else the - # file-default package captured at parse time, else the inherited walk - # context. Captured during _collect (mirrors the original ``_walk`` - # effective_pkg). Using file_default_package (not just the threaded - # ctx_pkg) is what lets a bare / same-package / cross-package ``extends`` - # resolve over the MERGED tree. - effective_pkg = pkg_of[node] if node in pkg_of else ( - node.package or node.file_default_package or None - ) + # ADR-0059 R3: the referrer package of an ``extends`` is the declaring node's + # own file package: its ``package``, else the file-default package captured at + # parse time ("" for a file that declares none). + effective_pkg = node.effective_package() # For a dotted child-targeting ref, ``_resolve`` reads the OWNER's # effective ``children()`` — which only includes inherited members once @@ -113,16 +105,25 @@ def resolve_node(node: MetaData) -> None: if _is_child_targeting_ref(node.super_ref): owner_ref = _owner_ref(node.super_ref) if owner_ref is not None: - owner = _resolve(owner_ref, effective_pkg, index) - if owner is not None: + owner = _try_resolve(owner_ref, effective_pkg, index) + if owner is not None and owner != _OVER_DROP: resolve_node(owner) # FR-024: thread the referrer's type so dotted ``Entity.child`` refs # resolve type-scoped (a field ref selects fields; an identity ref # identities). - target = _resolve(node.super_ref, effective_pkg, index, referrer_type=node.type) + target = _try_resolve(node.super_ref, effective_pkg, index, referrer_type=node.type) - if target is None: + if target == _OVER_DROP: + # ADR-0059 R2 -- ``..::`` dropped more segments than the package has. + errors.append(MetaError( + f"the extends ref '{node.super_ref}' on {node.fqn()} climbs above the " + f"root package \u2014 each '..::' drops one segment of the declaring package", + ErrorCode.ERR_BAD_ATTR_VALUE, + path=node.fqn(), + envelope=resolved_source(node.source, node.fqn(), node.super_ref), + )) + elif target is None: # FR5d / ADR-0009: emit a ResolvedSource envelope carrying the # referrer's files / json_path plus the referrer FQN + unresolved # target. Mirrors TS resolveDeferredSupers in meta-data-loader.ts. @@ -160,63 +161,33 @@ def resolve_node(node: MetaData) -> None: resolve_node(node) -def _collect( - node: MetaData, - ctx_pkg: str, - pending: list[MetaData], - pkg_of: dict[MetaData, str | None], -) -> None: - """Pre-order walk: record every node carrying a super_ref plus the effective - context package at its walk position, then recurse over own_children(). - - Mirrors the referrer-context computation of the original ``_walk`` so the - on-demand resolver uses exactly the same effective_pkg the pre-order walk - did — resolution stays byte-identical for every existing fixture; only the - ORDER in which supers are wired changes (now order-independent, #188). - """ +def _collect(node: MetaData, pending: list[MetaData]) -> None: + """Pre-order walk over the PHYSICAL declaration tree: record every node carrying a + super_ref.""" if node.super_ref: - # own ``package`` if declared, else the file-default package captured at - # parse time, else the inherited walk context. - pkg_of[node] = node.package or node.file_default_package or ctx_pkg or None pending.append(node) - - # The context package for children is this node's own package, if set, else inherit. - next_ctx = node.package or ctx_pkg - # ADR-0039 sanctioned own: this IS the super-resolution collection walk — it + # ADR-0039 sanctioned own: this IS the super-resolution collection walk -- it # runs BEFORE extends is resolved (resolution sets super_data), so it must # iterate own children. for child in node.own_children(): - _collect(child, next_ctx, pending, pkg_of) + _collect(child, pending) def _build_index(root: MetaData) -> dict[str, MetaData]: - """Build a lookup index over the whole merged tree (own_children walk). - - Each named node is registered under its own ``fqn()`` AND — when it has no - own ``package`` but a file-default package was captured at parse time — - under the package-folded key ``::``. The second - key is what makes a cross-package fully-qualified ``extends`` resolve over - the merged tree (object ``fqn()`` stays bare because the parser does not - fold the file-default package onto the object's own package). Mirrors the - TS ``findInTree`` matcher (own ``fqn()`` OR ``resolutionKey()``). - """ + """The root's top-level named nodes, keyed by exact resolution key + (``::``, or the bare name for a root-level + node). The key is captured at parse time, so a match does not depend on load order + or on post-merge tree shape. Exact only: a bare key never matches a packaged node + (ADR-0059 R2). First declaration wins.""" idx: dict[str, MetaData] = {} - _index_walk(root, idx) + # ADR-0039 sanctioned own: super-resolution reads the PHYSICAL declaration layer; + # the root has no super, so its own children are its children. + for c in root.own_children(): + if c.name: + idx.setdefault(c.resolution_key(), c) return idx -def _index_walk(node: MetaData, idx: dict[str, MetaData]) -> None: - if node.name: - idx.setdefault(node.fqn(), node) - if not node.package and node.file_default_package: - folded = f"{node.file_default_package}{PACKAGE_SEP}{node.name}" - idx.setdefault(folded, node) - # ADR-0039 sanctioned own: index-building walk over the declared tree (feeds - # super-resolution; runs before extends is resolved). - for child in node.own_children(): - _index_walk(child, idx) - - def _is_child_targeting_ref(ref: str) -> bool: """FR-024 (ADR-0029): True when the ref's final ``::``-segment contains a ``.`` — i.e. it targets a child nested inside an object (``Customer.id``, @@ -241,37 +212,41 @@ def _owner_ref(ref: str) -> str | None: return ref[:seg_start] + parts[0] +_OVER_DROP = object() + + +def _try_resolve( + ref: str, + context_pkg: str | None, + index: dict[str, MetaData], + referrer_type: str | None = None, +): + """:func:`_resolve`, with a ``..::`` over-drop returned as the ``_OVER_DROP`` marker + (ADR-0059 R2: a bad value, not a missing target).""" + try: + return _resolve(ref, context_pkg, index, referrer_type) + except RefOverDropError: + return _OVER_DROP + + def _resolve( ref: str, context_pkg: str | None, index: dict[str, MetaData], referrer_type: str | None = None, ) -> MetaData | None: - """Resolve a super_ref string against the FQN index. - - Resolution forms: - - absolute ``::pkg::Name`` → strip leading ``::``, look up ``pkg::Name``. - - relative ``..::rest`` → count leading ``..::`` levels; if levels exceed - context depth or remainder is empty → ``None`` - (→ ERR_UNRESOLVED_SUPER); else look up - ``reducedCtx::rest`` (mirrors TS exactly). - - bare/qualified ``Name`` → try ``context::ref`` first, then bare ``ref``. - - dotted ``Owner.child`` → FR-024 (ADR-0029): resolve the OWNER with the - strategies above, then select the child among - the owner's EFFECTIVE children (``children()``, - so inherited children resolve) by name + the - REFERRER's type (type-scoped: a field ref - resolves fields, an identity ref identities). - Dotted refs never fall through to the bare - lookup; multi-dot (``X.y.z``) TRAVERSES child - names to any depth (intermediate segments by - unique name); only degenerate empty segments → - ``None``; no ``referrer_type`` → ``None``. - Mirrors TS super-resolve.ts. - """ - abs_prefix = PACKAGE_SEP # "::" - rel_prefix = ".." + PACKAGE_SEP # "..:: + """Resolve a super_ref string against the top-level index (ADR-0059 R2). + + Plain forms go through ``owner_candidate_keys``: ``::pkg::Name`` root-absolute, + ``..::rest`` parent-relative (raises ``RefOverDropError`` on over-drop), + ``pkg::Name`` exact, bare ``Name`` in the context package then root-level. + A dotted ``Owner.child`` (FR-024, ADR-0029) resolves the OWNER the same way, then + selects the child among the owner's EFFECTIVE children (so inherited children + resolve) by name + the REFERRER's type. Multi-dot traverses child names to any + depth (intermediate segments by unique name); degenerate empty segments, or no + ``referrer_type``, resolve to ``None``. + """ if _is_child_targeting_ref(ref): # Addressing model (ADR-0029): the package qualifies the ROOT-level node # only; each subsequent segment traverses CHILD NAMES to any depth @@ -300,24 +275,10 @@ def _resolve( return child return None - if ref.startswith(abs_prefix): # absolute ::pkg::Name - return index.get(ref[len(abs_prefix):]) - - if ref.startswith(rel_prefix): # relative ..::rest - parts = ref.split(PACKAGE_SEP) - levels = 0 - while levels < len(parts) and parts[levels] == "..": - levels += 1 - pkg_parts = context_pkg.split(PACKAGE_SEP) if context_pkg else [] - remainder = parts[levels:] - if len(pkg_parts) < levels or len(remainder) == 0: - return None - all_parts = pkg_parts[: len(pkg_parts) - levels] + remainder - return index.get(PACKAGE_SEP.join(all_parts)) - - # bare or pkg-qualified (no leading :: / ..) - if context_pkg: - hit = index.get(f"{context_pkg}{PACKAGE_SEP}{ref}") + # ADR-0059 R2: a plain ref binds one top-level node by its form -- the same + # candidate keys every other reference uses. Raises RefOverDropError on over-drop. + for key in owner_candidate_keys(ref, context_pkg or ""): + hit = index.get(key) if hit is not None: return hit - return index.get(ref) + return None diff --git a/server/python/src/metaobjects/yaml_desugar.py b/server/python/src/metaobjects/yaml_desugar.py index dfb7ecc93..d1f85f706 100644 --- a/server/python/src/metaobjects/yaml_desugar.py +++ b/server/python/src/metaobjects/yaml_desugar.py @@ -35,7 +35,6 @@ from typing import Any from .errors import ErrorCode -from .naming_refs import REF_BEARING_ATTR_NAMES, expand_ref from .meta.core.attr.attr_constants import ( ATTR_SUBTYPE_BOOLEAN, ATTR_SUBTYPE_CLASS, @@ -46,7 +45,7 @@ ATTR_SUBTYPE_STRINGARRAY, ) from .registry import AttrSchema, TypeRegistry -from .shared.separators import ATTR_PREFIX, FUSED_KEY_SEP, PACKAGE_SEP +from .shared.separators import ATTR_PREFIX, FUSED_KEY_SEP from .shared.structural import ( KEY_ABSTRACT, KEY_CHILDREN, @@ -98,67 +97,15 @@ class DesugarResult: def desugar(input_obj: object, registry: TypeRegistry) -> DesugarResult: """Desugar a parsed-YAML authoring document into a canonical-shaped object.""" errors: list[CollectedError] = [] - # FR-032: root package context starts empty; each node's own `package` body - # key seeds the context threaded down to its children for ref expansion. - node = _desugar_node(input_obj, registry, errors, "", "") + node = _desugar_node(input_obj, registry, errors, "") return DesugarResult(canonical=node if node is not None else {}, errors=errors) -def _effective_package_for(raw_body: object, parent_pkg: str) -> str: - """FR-032 (ADR-0032) — compute a node's effective package from its raw - ``package`` body key (inheriting *parent_pkg* when absent). - - Mirrors TS ``effectivePackageFor`` / the parser's package resolution: a - leading ``::`` on a child package prepends the parent; otherwise it is used - as-is. The ``package`` attr itself is never ref-expanded — it is identity. - """ - if not isinstance(raw_body, dict): - return parent_pkg - raw_pkg = raw_body.get(KEY_PACKAGE) - if not isinstance(raw_pkg, str): - return parent_pkg - if parent_pkg != "" and raw_pkg.startswith(PACKAGE_SEP): - return parent_pkg + raw_pkg - return raw_pkg - - -def _expand_ref_value( - raw: object, - node_pkg: str, - errors: list[CollectedError], - path: str, - ref_label: str, -) -> object: - """FR-032 — expand a ref-bearing value to FQN. A ref value is a single string - (most refs) or a string-array (``@references`` can carry multiple). - ``expand_ref`` preserves any FR-024 dotted child suffix. A parent-relative - over-drop is collected as ERR_BAD_ATTR_VALUE and the raw value passes through. - """ - if isinstance(raw, str): - try: - return expand_ref(raw, node_pkg) - except ValueError as exc: - errors.append(CollectedError( - message=f"Cannot expand reference '{ref_label}' at {path}: {exc}", - code=ErrorCode.ERR_BAD_ATTR_VALUE, - )) - return raw - if isinstance(raw, list): - return [ - _expand_ref_value(el, node_pkg, errors, path, ref_label) - if isinstance(el, str) - else el - for el in raw - ] - return raw - - def _desugar_node( input_obj: object, registry: TypeRegistry, errors: list[CollectedError], path: str, - parent_pkg: str, ) -> dict[str, Any] | None: """Desugar one node - a single-key mapping { "type.subType": body }. @@ -205,11 +152,6 @@ def _desugar_node( # Rule 1: a bare `type` key -> the type's registry default subType. canonical_key = _resolve_key(key, registry, errors, path) - # FR-032: this node's effective package context (its own `package` body key, - # else inherited). Used to expand its ref-bearing attrs AND threaded to its - # children so their bare/relative refs resolve against the right package. - node_pkg = _effective_package_for(raw_body, parent_pkg) - # FR5b - position of the wrapper key (the `field.string:` line). Used to # back-fill `yaml_position` on synthesized bodies (Rule 2 scalar lift) and # on the canonical wrapper after Rule 1 / Rule 4 rewrites. @@ -219,7 +161,7 @@ def _desugar_node( # Rule 2: a scalar body -> { name: }. body = _desugar_body( - raw_body, registry, canonical_key, errors, path, wrapper_key_pos, node_pkg, + raw_body, registry, canonical_key, errors, path, wrapper_key_pos, ) # Rule 4 (cont.): stamp isArray onto the canonical body. @@ -232,7 +174,7 @@ def _desugar_node( children: list[Any] = [] for i, raw_child in enumerate(raw_children): child_path = f"{path}.{KEY_CHILDREN}[{i}]" - child = _desugar_node(raw_child, registry, errors, child_path, node_pkg) + child = _desugar_node(raw_child, registry, errors, child_path) # On a bad child keep an empty-object placeholder so sibling # indices stay stable; the error is already collected. children.append(child if child is not None else {}) @@ -277,7 +219,6 @@ def _desugar_body( errors: list[CollectedError], path: str, wrapper_key_pos: YamlPosition | None = None, - node_pkg: str = "", ) -> dict[str, Any]: """Rule 2 + 5 - normalize a node body into a canonical mapping. @@ -351,24 +292,16 @@ def _desugar_body( if key in RESERVED_KEYS or key.startswith(ATTR_PREFIX): out_key = key - # FR-032: expand a ref-bearing value to FQN. `extends` (reserved) and - # the @-prefixed ref attrs are expanded; everything else is verbatim. - # The `package` key is never expanded (it is identity). + # ADR-0059 R1: sources do not interpret references -- every value, + # reference or not, is copied through exactly as authored. attr_name = key[len(ATTR_PREFIX):] if key.startswith(ATTR_PREFIX) else "" - if key == KEY_EXTENDS or (attr_name and attr_name in REF_BEARING_ATTR_NAMES): - out[key] = _expand_ref_value(value, node_pkg, errors, path, key) - else: - out[key] = value + out[key] = value # D2 also applies to author-written @-keys (the awkward form). if attr_name and attr_name not in RESERVED_KEYS: _check_coercion(attr_name, value, schema_index, errors, path) else: out_key = f"{ATTR_PREFIX}{key}" - # FR-032: a bare (sigil-free) ref-bearing attr is expanded to FQN too. - if key in REF_BEARING_ATTR_NAMES: - out[out_key] = _expand_ref_value(value, node_pkg, errors, path, out_key) - else: - out[out_key] = value + out[out_key] = value _check_coercion(key, value, schema_index, errors, path) if src_positions is not None: diff --git a/server/python/tests/codegen/test_cross_package_binding_conformance.py b/server/python/tests/codegen/test_cross_package_binding_conformance.py new file mode 100644 index 000000000..64182d7bf --- /dev/null +++ b/server/python/tests/codegen/test_cross_package_binding_conformance.py @@ -0,0 +1,109 @@ +"""Cross-port conformance for Python codegen: codegen binds the target a reference names. + +Loads the shared fixture (``fixtures/codegen-conformance/cross-package-binding/input``) and +checks ``expected-bindings.json`` (ADR-0059 Decision 7): every ``identity.reference`` binds the +object its canonical value names, every relationship binds the same object in the M:N and +to-one resolution the generated router and model code use, and nothing binds by short name or +by the package of the entity that inherits an attribute. +""" +import json +from pathlib import Path + +import pytest + +import metaobjects.core_types # noqa: F401 — side-effect: registers attr classes + +from metaobjects import MetaDataLoader +from metaobjects.codegen.generators.router_generator import RouterGenerator, reverse_fks_for +from metaobjects.codegen.runtime.m2m_codegen import build_object_index, resolve_m2m_descriptors +from metaobjects.meta.core.identity.identity_constants import IDENTITY_REFERENCE_ATTR_REFERENCES +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.meta.core.relationship.derive_m2m_fields import derive_m2m_fields +from metaobjects.meta.core.relationship.meta_relationship import MetaRelationship +from metaobjects.meta.core.relationship.relationship_constants import ( + RELATIONSHIP_ATTR_OBJECT_REF, + RELATIONSHIP_ATTR_THROUGH, +) +from metaobjects.meta.core.relationship.relationship_references import ( + reference_fields, + resolve_relationship_reference, +) +from metaobjects.naming_refs import ref_target +from metaobjects.source_resolution import resolve_table_name + +_FIXTURE = Path(__file__).resolve().parents[4] / "fixtures/codegen-conformance/cross-package-binding" +_EXPECTED = json.loads((_FIXTURE / "expected-bindings.json").read_text(encoding="utf-8")) + +_ROOT = MetaDataLoader.from_directory(_FIXTURE / "input").root +_ENTITIES = [o for o in _ROOT.children() if isinstance(o, MetaObject)] +_INDEX = build_object_index(_ENTITIES) + + +def _split(path: str) -> tuple[MetaObject, str]: + key, member = path.rsplit(".", 1) + return _INDEX[key], member + + +def _member(entity: MetaObject, member: str): + return next(c for c in entity.children() if c.name == member) + + +@pytest.mark.parametrize("path", sorted(_EXPECTED["references"])) +def test_identity_reference_binds_the_expected_object(path: str) -> None: + entity, name = _split(path) + bound = ref_target(_member(entity, name), IDENTITY_REFERENCE_ATTR_REFERENCES) + assert bound is not None + assert bound.resolution_key() == _EXPECTED["references"][path] + + +_RELATIONS = sorted(_EXPECTED["relations"]) + + +@pytest.mark.parametrize("path", _RELATIONS) +def test_relationship_resolution_used_by_codegen(path: str) -> None: + want = _EXPECTED["relations"][path] + entity, name = _split(path) + rel = _member(entity, name) + assert isinstance(rel, MetaRelationship) + target = ref_target(rel, RELATIONSHIP_ATTR_OBJECT_REF) + assert target is not None and target.resolution_key() == want["target"] + assert resolve_table_name(target) == want["targetTable"] + + if want["cardinality"] == "many": + junction = ref_target(rel, RELATIONSHIP_ATTR_THROUGH) + assert junction is not None and junction.resolution_key() == want["junction"] + assert resolve_table_name(junction) == want["junctionTable"] + fields = derive_m2m_fields(rel, entity, _INDEX) + assert fields.source_field == want["sourceJoinField"] + assert fields.target_field == want["targetJoinField"] + descriptor = next(d for d in resolve_m2m_descriptors(entity, _INDEX) if d.relation_name == name) + assert descriptor.target_entity == target.name + assert descriptor.target_table == want["targetTable"] + assert descriptor.junction_table == want["junctionTable"] + else: + reference = resolve_relationship_reference(entity, name, target) + assert reference is not None + assert reference_fields(reference)[0] == want["fkField"] + assert any( + rfk.fk_field == want["fkField"] and rfk.target_entity == target.name + for rfk in reverse_fks_for(entity) + ) + + +@pytest.mark.parametrize("key", ["a::Post", "acme::Order"]) +def test_generated_router_traverses_the_expected_relations(key: str) -> None: + entity = _INDEX[key] + source = RouterGenerator().render_router(entity, _INDEX) + assert source is not None + expected = [p for p in _RELATIONS if p.startswith(key + ".") and _EXPECTED["relations"][p]["cardinality"] == "many"] + assert expected + for path in expected: + relation = path.rsplit(".", 1)[1] + assert f"def find_related_{relation}(" in source + assert f"/{relation}" in source + expected_tables = {_EXPECTED["relations"][p]["targetTable"] for p in expected} + descriptors = resolve_m2m_descriptors(entity, _INDEX) + assert {d.target_table for d in descriptors} == expected_tables + for wrong in ("a_tags", "acme_labels", "acme_customers"): + assert wrong not in {d.target_table for d in descriptors} + assert wrong not in source diff --git a/server/python/tests/codegen/test_hand_edits.py b/server/python/tests/codegen/test_hand_edits.py new file mode 100644 index 000000000..7efea3d84 --- /dev/null +++ b/server/python/tests/codegen/test_hand_edits.py @@ -0,0 +1,167 @@ +"""``handEdits`` on a generator entry: ``keep`` (the default) or ``overwrite``. + +``keep`` refuses to replace a generated file edited by hand, and refuses to delete one no +generator emits any more. ``overwrite`` replaces the edit with fresh output and re-records the +file, and deletes the edited orphan, naming each file it discards. ``verify --codegen`` follows +the same setting, so its remedy matches what ``gen`` will do. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects.cli import main +from metaobjects.codegen.project_config import load_project_config + +MANIFEST = Path(".metaobjects") / ".gen-state" / ".hashes.json" +KEEP = "targets:\n app:\n outDir: gen\n generators: [entity]\n" +OVERWRITE = ( + "targets:\n app:\n outDir: gen\n generators:\n" + " - name: entity\n handEdits: overwrite\n" +) + + +def _value(name: str) -> dict: + return {"object.value": {"name": name, "children": [{"field.string": {"name": "reading"}}]}} + + +def _write_model(root: Path, values: list[str]) -> None: + meta = root / "metaobjects" + meta.mkdir(parents=True, exist_ok=True) + (meta / "meta.acme.json").write_text( + json.dumps({"metadata.root": {"package": "acme", "children": [_value(v) for v in values]}}), + encoding="utf-8", + ) + + +def _project(root: Path, config: str, values: list[str]) -> Path: + _write_model(root, values) + (root / "metaobjects.config.yaml").write_text(config, encoding="utf-8") + return root / "gen" + + +def _file(out: Path, stem: str) -> Path: + [found] = [p for p in out.rglob("*.py") if p.stem.lower() == stem.lower()] + return found + + +def _manifest(root: Path) -> dict[str, str]: + return json.loads((root / MANIFEST).read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("config", [KEEP, OVERWRITE], ids=["keep", "overwrite"]) +def test_an_edited_generated_file( + config: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + out = _project(tmp_path, config, ["Telemetry"]) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + target = _file(out, "telemetry") + fresh = target.read_text(encoding="utf-8") + target.write_text(fresh + "# mine\n", encoding="utf-8") + capsys.readouterr() + + verify = main(["verify", "--codegen"]) + err = capsys.readouterr().err + assert verify == 1 + + code = main(["gen"]) + captured = capsys.readouterr() + if config is KEEP: + assert "handEdits: overwrite" not in err + assert code == 1 + assert target.read_text(encoding="utf-8").endswith("# mine\n") + assert "refused" in captured.err + else: + assert "edited by hand; its generator sets handEdits: overwrite" in err + assert "regenerate (metaobjects gen)" in err + assert code == 0 + assert target.read_text(encoding="utf-8") == fresh + assert "overwrote hand-edited" in captured.out + # Re-baselined: the next run is quiet and verify is green. + assert main(["gen"]) == 0 + assert "overwrote" not in capsys.readouterr().out + assert main(["verify", "--codegen"]) == 0 + + +@pytest.mark.parametrize("config", [KEEP, OVERWRITE], ids=["keep", "overwrite"]) +def test_an_edited_orphan( + config: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + out = _project(tmp_path, config, ["Telemetry"]) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + old = _file(out, "telemetry") + old.write_text(old.read_text(encoding="utf-8") + "# mine\n", encoding="utf-8") + _write_model(tmp_path, ["TelemetryReading"]) + capsys.readouterr() + + assert main(["verify", "--codegen"]) == 1 + err = capsys.readouterr().err + + assert main(["gen"]) == 0 + captured = capsys.readouterr() + if config is KEEP: + assert "will NOT delete them" in err + assert old.exists() + assert "NOT deleted" in captured.out + assert [k for k in _manifest(tmp_path) if k.endswith(old.name)] + else: + assert "handEdits is overwrite, so gen deletes it" in err + assert "will NOT delete them" not in err + assert not old.exists() + assert "deleted hand-edited" in captured.out + assert not [k for k in _manifest(tmp_path) if k.endswith(old.name)] + assert main(["verify", "--codegen"]) == 0 + + +def test_a_file_nobody_generated_is_never_overwritten( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + out = _project(tmp_path, OVERWRITE, ["Telemetry"]) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + target = _file(out, "telemetry") + # Forget the record: the file is now someone else's as far as gen can prove. + (tmp_path / MANIFEST).write_text("{}", encoding="utf-8") + target.write_text("# not generated\n", encoding="utf-8") + assert main(["gen"]) == 1 + assert target.read_text(encoding="utf-8") == "# not generated\n" + + +def test_one_keep_target_in_a_shared_directory_protects_its_edited_orphans( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + config = OVERWRITE + " other:\n outDir: gen\n generators: [names]\n" + out = _project(tmp_path, config, ["Telemetry"]) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + old = _file(out, "telemetry") + old.write_text(old.read_text(encoding="utf-8") + "# mine\n", encoding="utf-8") + _write_model(tmp_path, ["TelemetryReading"]) + assert main(["gen"]) == 0 + assert old.exists() + + +def test_the_config_reads_both_entry_forms_and_rejects_a_bad_value(tmp_path: Path) -> None: + cfg = tmp_path / "metaobjects.config.yaml" + cfg.write_text( + "targets:\n app:\n outDir: gen\n generators:\n - entity\n" + " - name: names\n handEdits: overwrite\n - name: dto\n handEdits: keep\n", + encoding="utf-8", + ) + [target] = load_project_config(cfg).targets + assert target.generators == ["entity", "names", "dto"] + assert target.overwrite_hand_edits == frozenset({"names"}) + + cfg.write_text( + "targets:\n app:\n outDir: gen\n generators:\n" + " - name: names\n handEdits: always\n", + encoding="utf-8", + ) + with pytest.raises(Exception, match="handEdits"): + load_project_config(cfg) diff --git a/server/python/tests/codegen/test_orphan_sweep.py b/server/python/tests/codegen/test_orphan_sweep.py new file mode 100644 index 000000000..d677b4e2e --- /dev/null +++ b/server/python/tests/codegen/test_orphan_sweep.py @@ -0,0 +1,257 @@ +"""``metaobjects gen`` deletes a file it wrote earlier and no longer generates. + +The rc.5 re-check found the gap: renaming the value object ``Telemetry`` to +``TelemetryReading`` (or a release moving api-docs pages to another package) left the old +files on disk, ``verify --codegen`` reported them as ``extra:`` and said "regenerate", and +regenerating never removed anything, so an agent following the remedy looped. The rule is +the one every port shares (``codegen/orphan_sweep.py``): an untouched file the manifest +records and no generator emits is deleted and forgotten; an edited one is refused by name; +a file the manifest does not record is never touched; a narrowed run sweeps nothing. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects.cli import main + +MANIFEST = Path(".metaobjects") / ".gen-state" / ".hashes.json" + + +def _value(name: str) -> dict: + return {"object.value": {"name": name, "children": [{"field.string": {"name": "reading"}}]}} + + +def _entity(name: str) -> dict: + return {"object.entity": {"name": name, "children": [ + {"source.rdb": {"@table": name.lower()}}, + {"field.long": {"name": "id"}}, + {"identity.primary": {"@fields": "id"}}, + ]}} + + +def _write_model(root: Path, package: str, children: list[dict]) -> None: + meta = root / "metaobjects" + meta.mkdir(parents=True, exist_ok=True) + (meta / "meta.acme.json").write_text( + json.dumps({"metadata.root": {"package": package, "children": children}}), + encoding="utf-8", + ) + + +def _project(root: Path, children: list[dict], *, package: str = "acme", + config: str = "targets:\n app:\n outDir: gen\n generators: [entity]\n") -> Path: + _write_model(root, package, children) + (root / "metaobjects.config.yaml").write_text(config, encoding="utf-8") + return root / "gen" + + +def _manifest(root: Path) -> dict[str, str]: + return json.loads((root / MANIFEST).read_text(encoding="utf-8")) + + +def _find(out: Path, name: str) -> list[Path]: + return sorted(out.rglob(name)) + + +def test_a_renamed_value_object_is_deleted_and_verify_goes_green( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + out = _project(tmp_path, [_value("Telemetry")]) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + [old] = _find(out, "telemetry.py") or _find(out, "Telemetry.py") + + _write_model(tmp_path, "acme", [_value("TelemetryReading")]) + capsys.readouterr() + assert main(["verify", "--codegen"]) == 1 + err = capsys.readouterr().err + assert "gen deletes it" in err + assert "regenerate (metaobjects gen) and commit the result." in err + + assert main(["gen"]) == 0 + stdout = capsys.readouterr().out + assert not old.exists() + assert f"removed {old}" in stdout + assert not [k for k in _manifest(tmp_path) if k.endswith(old.name)] + + assert main(["verify", "--codegen"]) == 0 + + +def test_a_hand_edited_orphan_is_refused_and_verify_says_delete_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + out = _project(tmp_path, [_value("Telemetry")]) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + [old] = _find(out, "telemetry.py") or _find(out, "Telemetry.py") + old.write_text(old.read_text(encoding="utf-8") + "# mine\n", encoding="utf-8") + + _write_model(tmp_path, "acme", [_value("TelemetryReading")]) + capsys.readouterr() + assert main(["gen"]) == 0 + stdout = capsys.readouterr().out + assert old.exists() + assert "NOT deleted" in stdout + assert str(old.relative_to(tmp_path)) in stdout + # Still recorded, so the refusal repeats until someone resolves it. + assert str(old.relative_to(tmp_path)) in _manifest(tmp_path) + + assert main(["verify", "--codegen"]) == 1 + err = capsys.readouterr().err + assert "edited by hand" in err + assert "delete each one, or move your edits" in err + assert "regenerate (metaobjects gen)" not in err + + +def test_api_docs_pages_that_move_package_leave_no_stale_copy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + config = ( + "targets:\n" + " app:\n outDir: gen\n generators: [entity]\n" + " docs:\n outDir: docs\n generators: [api-docs]\n" + ) + _project(tmp_path, [_entity("Leg")], package="acme::iam", config=config) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + docs = tmp_path / "docs" + before = [p for p in docs.rglob("Leg.md") if "iam" in p.parts] + assert before, sorted(str(p) for p in docs.rglob("*")) + + _write_model(tmp_path, "acme::ops", [_entity("Leg")]) + capsys.readouterr() + assert main(["gen"]) == 0 + assert [p for p in docs.rglob("Leg.md") if "ops" in p.parts] + assert not [p for p in before if p.exists()] + assert main(["verify", "--codegen"]) == 0 + + +def test_a_file_nobody_generated_is_never_touched( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + out = _project(tmp_path, [_value("Telemetry")]) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + mine = out / "helpers.py" + mine.write_text("MINE = 1\n", encoding="utf-8") + _write_model(tmp_path, "acme", [_value("TelemetryReading")]) + assert main(["gen"]) == 0 + assert mine.exists() + + +def test_an_entities_narrowed_run_deletes_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + out = _project(tmp_path, [_value("Telemetry"), _value("Other")]) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + [old] = _find(out, "telemetry.py") or _find(out, "Telemetry.py") + _write_model(tmp_path, "acme", [_value("TelemetryReading"), _value("Other")]) + capsys.readouterr() + assert main(["gen", "--entities", "Other"]) == 0 + assert old.exists() + assert "Skipped orphan cleanup" in capsys.readouterr().out + + +def test_a_target_run_spares_a_shared_directory_another_target_writes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + config = ( + "targets:\n" + " app:\n outDir: gen\n generators: [entity]\n" + " docs:\n outDir: gen\n generators: [api-docs]\n" + ) + _project(tmp_path, [_entity("Leg")], config=config) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + pages = list((tmp_path / "gen").rglob("Leg.md")) + assert pages + assert main(["gen", "--target", "app"]) == 0 + assert all(p.exists() for p in pages) + + + +def test_a_target_run_spares_a_nested_directory_of_a_target_that_did_not_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + config = ( + "targets:\n" + " app:\n outDir: gen\n generators: [entity]\n" + " docs:\n outDir: gen/docs\n generators: [api-docs]\n" + ) + _project(tmp_path, [_entity("Leg")], config=config) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + pages = list((tmp_path / "gen" / "docs").rglob("Leg.md")) + assert pages + assert main(["gen", "--target", "app"]) == 0 + assert all(p.exists() for p in pages) + + +def test_verify_of_a_target_does_not_report_a_nested_target_s_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], +) -> None: + config = ( + "targets:\n" + " app:\n outDir: gen\n generators: [entity]\n" + " docs:\n outDir: gen/docs\n generators: [api-docs]\n" + ) + _project(tmp_path, [_entity("Leg")], config=config) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + assert list((tmp_path / "gen" / "docs").rglob("Leg.md")) + capsys.readouterr() + assert main(["verify", "--codegen", "--target", "app"]) == 0 + assert "extra" not in capsys.readouterr().err + + +def _nested_layout(root: Path) -> tuple[Path, Path, Path, Path]: + from metaobjects.codegen.overwrite_policy import _save_hashes, content_hash + + gen_state = root / ".metaobjects" / ".gen-state" + parent = root / "gen" + child = parent / "child" + child.mkdir(parents=True) + kept = parent / "kept.txt" + orphan = child / "orphan.txt" + kept.write_text("kept", encoding="utf-8") + orphan.write_text("original", encoding="utf-8") + _save_hashes(str(gen_state), { + "gen/kept.txt": content_hash("kept"), + "gen/child/orphan.txt": content_hash("original"), + }) + orphan.write_text("edited", encoding="utf-8") + return gen_state, parent, child, orphan + + +def test_a_nested_out_dir_is_judged_once_by_its_own_verdict(tmp_path: Path) -> None: + from metaobjects.codegen.orphan_sweep import sweep_orphans + + gen_state, parent, child, orphan = _nested_layout(tmp_path) + result = sweep_orphans( + str(gen_state), str(tmp_path), [str(parent), str(child)], {str(parent / "kept.txt")}, + overwrite_dirs=frozenset({str(child)}), + ) + assert result.removed == [str(orphan)] + assert result.discarded == ["gen/child/orphan.txt"] + assert result.refused == [] + assert not orphan.exists() + assert "gen/child/orphan.txt" not in _manifest(tmp_path) + + +def test_a_nested_out_dir_that_keeps_is_not_overwritten_by_its_parent(tmp_path: Path) -> None: + from metaobjects.codegen.orphan_sweep import sweep_orphans + + gen_state, parent, child, orphan = _nested_layout(tmp_path) + result = sweep_orphans( + str(gen_state), str(tmp_path), [str(parent), str(child)], {str(parent / "kept.txt")}, + overwrite_dirs=frozenset({str(parent)}), + ) + assert result.removed == [] + assert result.discarded == [] + assert result.refused == ["gen/child/orphan.txt"] + assert orphan.read_text(encoding="utf-8") == "edited" + assert "gen/child/orphan.txt" in _manifest(tmp_path) diff --git a/server/python/tests/codegen/test_overwrite_policy_hash.py b/server/python/tests/codegen/test_overwrite_policy_hash.py index d46cbc6ad..ec2d2c0de 100644 --- a/server/python/tests/codegen/test_overwrite_policy_hash.py +++ b/server/python/tests/codegen/test_overwrite_policy_hash.py @@ -131,3 +131,30 @@ def test_without_gen_state_the_legacy_marker_rule_stands(tmp_path: Path) -> None q = tmp_path / "B.py" q.write_text(generated_header("B", "B") + "old\n") assert decide_and_write(str(q), generated_header("B", "B") + "new\n", "overwrite") == "overwrite" + + +def test_jvm_object_entry_is_read_and_preserved_on_record(tmp_path: Path) -> None: + state = tmp_path / "state" + state.mkdir() + jvm = {"hash": content_hash("jvm body"), "generator": "java:dto", "outputDir": "target/gen"} + (state / ".hashes.json").write_text( + json.dumps({"Plain.txt": "abc", "Jvm.java": jvm}, indent=2) + "\n", encoding="utf-8" + ) + + assert read_generated_hash(str(state), "Jvm.java") == jvm["hash"] + + target = tmp_path / "Other.py" + assert _gen(target, "other\n", state) == "new" + + saved = json.loads((state / ".hashes.json").read_text(encoding="utf-8")) + assert saved["Jvm.java"] == jvm + assert saved["Plain.txt"] == "abc" + assert saved["Other.py"] == content_hash("other\n") + + jvm_file = tmp_path / "Jvm.java" + jvm_file.write_text("jvm body", encoding="utf-8") + assert _gen(jvm_file, "jvm body v2", state) == "overwrite" + saved = json.loads((state / ".hashes.json").read_text(encoding="utf-8")) + assert saved["Jvm.java"]["generator"] == "java:dto" + assert saved["Jvm.java"]["outputDir"] == "target/gen" + assert saved["Jvm.java"]["hash"] == content_hash("jvm body v2") diff --git a/server/python/tests/codegen/test_render_helper_generator.py b/server/python/tests/codegen/test_render_helper_generator.py index 70b71c524..aafdeaf25 100644 --- a/server/python/tests/codegen/test_render_helper_generator.py +++ b/server/python/tests/codegen/test_render_helper_generator.py @@ -419,16 +419,43 @@ def test_snake_case_pascal_to_snake() -> None: assert _snake_case("Alpha") == "alpha" -def test_resolve_payload_vo_matches_short_and_fully_qualified_ref() -> None: - """FR-026 expands @payloadRef to a fully-qualified ``a::b::Name`` while the - object.value child still carries the short ``name`` — both forms must resolve. - - #228 fix round 2: ``resolve_payload_vo`` now requires an explicit - ``referrer_pkg`` (package-local, ADR-0042) — a SHORT (bare) ref resolves - against the referrer's OWN package (here ``"acme::blog"``, the SAME package - ``WelcomePayload`` is declared in — the common same-package case this test - exercises); an FQN ref resolves exactly regardless of the referrer package - (passed ``""`` below to prove that).""" +def test_resolve_payload_vo_resolves_a_canonical_value_exactly() -> None: + """ADR-0059 R7: a canonical value is absolute. ``pkg::Name`` binds that package's + object, ``::Name`` and a bare ``Name`` bind the root-level one, and the referrer + package is never applied. Three value objects share the short name ``Report``, so a + short-name scan or a package-local resolve would bind a packaged copy instead.""" + import json + + from metaobjects import InMemoryStringSource, MetaDataFormat, MetaDataLoader + + def doc(pkg: str | None, field: str) -> InMemoryStringSource: + body: dict = {"children": [ + {"object.value": {"name": "Report", "children": [{"field.string": {"name": field}}]}}, + ]} + if pkg is not None: + body["package"] = pkg + return InMemoryStringSource( + json.dumps({"metadata.root": body}), id=f"{field}.json", format=MetaDataFormat.JSON + ) + + result = MetaDataLoader().load([doc("acme::alpha", "alphaVal"), doc("acme::beta", "betaVal"), doc(None, "rootVal")]) + assert result.errors == [] + root = result.root + + def field_of(ref: str, referrer_pkg: str) -> str | None: + vo = resolve_payload_vo(root, ref, referrer_pkg) + return None if vo is None else vo.children()[0].name + + assert field_of("acme::alpha::Report", "") == "alphaVal" + assert field_of("acme::beta::Report", "acme::alpha") == "betaVal" + assert field_of("::Report", "acme::alpha") == "rootVal" + assert field_of("Report", "acme::alpha") == "rootVal" + assert field_of("acme::gamma::Report", "") is None + + +def test_resolve_payload_vo_bare_value_does_not_bind_a_packaged_object() -> None: + """A bare canonical value names the root-level object; with none there it names + nothing, even when exactly one packaged object of that short name exists.""" import json from metaobjects import InMemoryStringSource, MetaDataFormat, MetaDataLoader @@ -442,6 +469,5 @@ def test_resolve_payload_vo_matches_short_and_fully_qualified_ref() -> None: id="m.json", format=MetaDataFormat.JSON, ) ]).root - assert resolve_payload_vo(root, "WelcomePayload", "acme::blog") is not None # short ref, same-package referrer - assert resolve_payload_vo(root, "acme::blog::WelcomePayload", "") is not None # FQN ref - assert resolve_payload_vo(root, "acme::blog::Missing", "") is None + assert resolve_payload_vo(root, "acme::blog::WelcomePayload", "") is not None + assert resolve_payload_vo(root, "WelcomePayload", "acme::blog") is None diff --git a/server/python/tests/codegen/test_requirement_tests_generator.py b/server/python/tests/codegen/test_requirement_tests_generator.py index 1541e83e8..59aa6f5cc 100644 --- a/server/python/tests/codegen/test_requirement_tests_generator.py +++ b/server/python/tests/codegen/test_requirement_tests_generator.py @@ -507,30 +507,30 @@ def _two_package_documents(second_has_requirement: bool) -> tuple[str, str]: return first, second -def test_stale_file_gen_leaves_the_file_of_a_package_that_lost_its_requirements_and_verify_codegen_names_it_extra( +def test_stale_file_gen_deletes_the_file_of_a_package_that_lost_its_requirements( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """The port's existing behaviour for ANY generator: `gen` never deletes a file it no longer - emits, and `verify --codegen` reports it as `extra:` (stale) when the manifest records that - this tool wrote it. Nothing about the requirement tests is special-cased.""" + """The rule for ANY generator (codegen/orphan_sweep.py): `verify --codegen` reports a file + no generator emits any more as `extra:` with "gen deletes it", and a full `gen` deletes it + because it is untouched. Nothing about the requirement tests is special-cased.""" config = _project(tmp_path, "requirementTests:\n warnUncovered: false\n", *_two_package_documents(True)) monkeypatch.chdir(tmp_path) assert main(["gen", "--config", str(config)]) == 0 stale = tmp_path / "gen" / "requirements" / "test_acme_two_requirements.py" assert stale.exists() - before = stale.read_text(encoding="utf-8") - assert "Gone" in before + assert "Gone" in stale.read_text(encoding="utf-8") assert main(["verify", "--codegen", "--config", str(config)]) == 0 first, second = _two_package_documents(False) (tmp_path / "metaobjects" / "meta.0.yaml").write_text(first, encoding="utf-8") (tmp_path / "metaobjects" / "meta.1.yaml").write_text(second, encoding="utf-8") capsys.readouterr() - assert main(["gen", "--config", str(config)]) == 0 - assert stale.read_text(encoding="utf-8") == before, "gen does not remove a file it no longer emits" - assert main(["verify", "--codegen", "--config", str(config)]) == 1 - assert " extra: requirements/test_acme_two_requirements.py" in capsys.readouterr().err + assert " extra: requirements/test_acme_two_requirements.py (no generator emits it any more; gen deletes it)" in capsys.readouterr().err + + assert main(["gen", "--config", str(config)]) == 0 + assert not stale.exists(), "gen deletes an untouched file it no longer emits" + assert main(["verify", "--codegen", "--config", str(config)]) == 0 def test_tests_are_emitted_sorted_by_id_whatever_order_the_model_declares_them_in(tmp_path: Path) -> None: diff --git a/server/python/tests/codegen/test_template_data.py b/server/python/tests/codegen/test_template_data.py index cb28bb550..e10ba7489 100644 --- a/server/python/tests/codegen/test_template_data.py +++ b/server/python/tests/codegen/test_template_data.py @@ -39,7 +39,7 @@ def test_entity_dict_neutral_fields() -> None: def test_order_relationship() -> None: d = build_entity_template_data(_obj("Order")) assert d["relationships"] == [ - {"name": "product", "cardinality": "one", "targetRef": "Product"} + {"name": "product", "cardinality": "one", "targetRef": "shop::Product"} ] diff --git a/server/python/tests/conformance/test_conformance_twin_roundtrip.py b/server/python/tests/conformance/test_conformance_twin_roundtrip.py new file mode 100644 index 000000000..321707e5b --- /dev/null +++ b/server/python/tests/conformance/test_conformance_twin_roundtrip.py @@ -0,0 +1,122 @@ +"""Two source-independence gates over the shared conformance corpus (ADR-0059). + +Mirrors the TS reference at +server/typescript/packages/metadata/test/conformance-twin-roundtrip.test.ts. + +Gate 1 -- YAML twin. Every fixture's input loads once as canonical JSON and once with +the SAME texts read as YAML sources, in the same order. JSON is valid YAML, so the +second load goes through the YAML desugar. Both loads must report the same ordered +error codes and, when clean, serialize identically. A reference means the same thing +whatever source it was written in, so any difference is a defect. + +Gate 2 -- round trip. Every fixture's ``expected.json`` loads as the only input and +must serialize back to itself (trailing whitespace aside, as the corpus runner +compares). Canonical JSON is an output format, so what the serializer writes has to +load back to the same model. + +Exclusions live in shared files beside the corpus, one reason per entry, so every port +skips the same fixtures for the same reasons. There are no port-local exclusions +beyond fixtures whose providers this port cannot compose. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects import MetaDataLoader +from metaobjects.errors import ParseError +from metaobjects.loader.sources import DirectorySource, InMemoryStringSource, MetaDataFormat +from metaobjects.serializer_json import canonical_serialize, canonical_serialize_effective + +from .conformance_adapter import _resolve_providers +from .corpus import corpus_root +from .fixture_discovery import Fixture, discover_fixtures + +_CORPUS = corpus_root() + + +def _read_exclusions(name: str) -> dict[str, str]: + return json.loads((_CORPUS / name).read_text(encoding="utf-8"))["exclusions"] + + +_TWIN_EXCLUSIONS = _read_exclusions("yaml-twin-exclusions.json") +_ROUND_TRIP_EXCLUSIONS = _read_exclusions("round-trip-exclusions.json") + + +def _has_expectation(fix: Fixture) -> bool: + # Docs-only and contract-only fixtures carry no metadata expectation: they are + # asserted by their own runners, not by a tree comparison. + return ( + fix.has_expected + or fix.has_expected_effective + or fix.has_expected_errors + or fix.has_expected_warnings + or fix.has_script + ) + + +_FIXTURES = [f for f in discover_fixtures(_CORPUS) if _has_expectation(f)] +_NAMES = {f.name for f in discover_fixtures(_CORPUS)} + + +def _load(sources: list[InMemoryStringSource], fix: Fixture) -> tuple[list[str], str | None, str | None] | None: + """(ordered error codes, canonical, effective), or None when the fixture's + providers do not compose (a provider-composition fixture: no tree to compare).""" + try: + loader = MetaDataLoader(providers=_resolve_providers(fix.providers), strict=True) + except (ParseError, ValueError): + return None + result = loader.load(sources) + codes = [e.code.name for e in result.errors] + if codes: + return codes, None, None + return codes, canonical_serialize(result.root), canonical_serialize_effective(result.root) + + +def test_every_excluded_fixture_exists() -> None: + for name in [*_TWIN_EXCLUSIONS, *_ROUND_TRIP_EXCLUSIONS]: + assert name in _NAMES, f"exclusion names a fixture that does not exist: {name}" + + +_TWIN = [f for f in _FIXTURES if f.name not in _TWIN_EXCLUSIONS] + + +@pytest.mark.parametrize("fix", _TWIN, ids=[f.name for f in _TWIN]) +def test_yaml_twin(fix: Fixture) -> None: + files = list(DirectorySource(fix.input_dir).expand()) + texts = [f.read() for f in files] + as_json = [InMemoryStringSource(t, id=f.id, format=f.format) for f, t in zip(files, texts)] + # Only canonical-JSON inputs get a twin; a file already authored as YAML stays YAML. + as_yaml = [ + InMemoryStringSource( + t, id=f.id, + format=MetaDataFormat.YAML if Path(f.id).suffix.lower() == ".json" else f.format, + ) + for f, t in zip(files, texts) + ] + json_load = _load(as_json, fix) + if json_load is None: + return # provider-composition fixture: no tree to twin + yaml_load = _load(as_yaml, fix) + assert yaml_load is not None + assert yaml_load[0] == json_load[0] + if json_load[1] is not None: + assert yaml_load[1] == json_load[1] + if fix.has_expected_effective: + assert yaml_load[2] == json_load[2] + + +_ROUND_TRIP = [f for f in _FIXTURES if f.has_expected and f.name not in _ROUND_TRIP_EXCLUSIONS] + + +@pytest.mark.parametrize("fix", _ROUND_TRIP, ids=[f.name for f in _ROUND_TRIP]) +def test_round_trip(fix: Fixture) -> None: + text = (fix.dir / "expected.json").read_text(encoding="utf-8") + loaded = _load([InMemoryStringSource(text, id="expected.json")], fix) + if loaded is None: + return + assert loaded[0] == [] + # Trimmed, as the corpus runner compares goldens: a golden's trailing blank line is not content. + assert (loaded[1] or "").strip() == text.strip() diff --git a/server/python/tests/conformance/test_yaml_conformance.py b/server/python/tests/conformance/test_yaml_conformance.py index f0d7239de..94396ebc6 100644 --- a/server/python/tests/conformance/test_yaml_conformance.py +++ b/server/python/tests/conformance/test_yaml_conformance.py @@ -33,6 +33,7 @@ YamlSource, ) from metaobjects.super_resolve import resolve_supers +from metaobjects.loader.resolve_references import resolve_references from metaobjects.loader.validation_passes import run_validations # Full default provider set: core types + DB-domain (@column / @dbColumnType) + @@ -141,6 +142,7 @@ def _load_yaml_to_canonical(yaml_text: str) -> tuple[str, list[str], list[MetaEr resolve_supers(root, errors) run_validations(root, registry, errors, warnings) + resolve_references(root) root.freeze() canonical = canonical_serialize(root) diff --git a/server/python/tests/unit/test_effective_package.py b/server/python/tests/unit/test_effective_package.py index f22ab1679..98451bff9 100644 --- a/server/python/tests/unit/test_effective_package.py +++ b/server/python/tests/unit/test_effective_package.py @@ -85,7 +85,9 @@ def test_super_resolve_context_package_multi_level() -> None: container = _N("object", "entity", "Container") container.package = "acme::sub" leaf = _N("object", "entity", "Leaf") - leaf.super_ref = "Base" # "Base" exists as root-level bare key + # ADR-0059 R2: a bare ref binds the referrer's own package then root-level, never + # another package; "Base" lives in "acme", so reach it with a parent-relative ref. + leaf.super_ref = "..::Base" container.add_child(leaf) root.add_child(container) diff --git a/server/python/tests/unit/test_inherited_reference_binding.py b/server/python/tests/unit/test_inherited_reference_binding.py new file mode 100644 index 000000000..236d64e46 --- /dev/null +++ b/server/python/tests/unit/test_inherited_reference_binding.py @@ -0,0 +1,37 @@ +"""A root-declared reference inherited into package ``acme`` keeps meaning the root object +even though ``acme`` declares an object of the same short name (ADR-0059 Decision 3, 7).""" +from pathlib import Path + +import metaobjects.core_types # noqa: F401 — side-effect: registers attr classes + +from metaobjects import MetaDataLoader +from metaobjects.meta.core.identity.identity_constants import IDENTITY_REFERENCE_ATTR_REFERENCES +from metaobjects.meta.core.relationship.derive_m2m_fields import derive_m2m_fields +from metaobjects.meta.core.relationship.relationship_constants import ( + RELATIONSHIP_ATTR_OBJECT_REF, + RELATIONSHIP_ATTR_THROUGH, +) +from metaobjects.naming_refs import ref_target + +_INPUT = Path(__file__).resolve().parents[4] / "fixtures/codegen-conformance/cross-package-binding/input" + + +def _by_key(root, key): + return next(c for c in root.children() if c.resolution_key() == key) + + +def test_inherited_references_bind_the_root_objects() -> None: + root = MetaDataLoader.from_directory(_INPUT).root + order = _by_key(root, "acme::Order") + customer = next(c for c in order.children() if c.name == "customer") + labels = next(c for c in order.children() if c.name == "labels") + customer_ref = next(c for c in order.children() if c.name == "customerRef") + + assert ref_target(customer, RELATIONSHIP_ATTR_OBJECT_REF) is _by_key(root, "Customer") + assert ref_target(customer_ref, IDENTITY_REFERENCE_ATTR_REFERENCES) is _by_key(root, "Customer") + assert ref_target(labels, RELATIONSHIP_ATTR_OBJECT_REF) is _by_key(root, "Label") + assert ref_target(labels, RELATIONSHIP_ATTR_THROUGH) is _by_key(root, "OrderLabel") + assert _by_key(root, "acme::Customer") is not ref_target(customer, RELATIONSHIP_ATTR_OBJECT_REF) + + fields = derive_m2m_fields(labels, order) + assert (fields.source_field, fields.target_field) == ("orderId", "labelId") diff --git a/server/python/tests/unit/test_relationship_references.py b/server/python/tests/unit/test_relationship_references.py index 826ad11fe..db04923d9 100644 --- a/server/python/tests/unit/test_relationship_references.py +++ b/server/python/tests/unit/test_relationship_references.py @@ -42,6 +42,10 @@ def _find_object(root: MetaData, name: str) -> MetaData: return next(c for c in root.children() if c.type == TYPE_OBJECT and c.name == name) +def _team(holder: MetaData) -> MetaData: + return _find_object(holder.root(), "Team") + + def _load_object(doc: dict, name: str) -> MetaData: result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))]) assert result.errors == [], [e.message for e in result.errors] @@ -53,21 +57,26 @@ def _load_match() -> MetaData: def test_enumerates_every_candidate_reference_for_the_target() -> None: - candidates = reference_candidates_for(_load_match(), "Team") + match = _load_match() + candidates = reference_candidates_for(match, _team(match)) assert [c.name for c in candidates] == ["homeTeamRef", "awayTeamRef"] +def test_an_unbound_target_matches_no_candidate() -> None: + assert reference_candidates_for(_load_match(), None) == [] + + def test_name_pairing_resolves_each_association_to_its_own_reference() -> None: match = _load_match() - home = resolve_relationship_reference(match, "homeTeam", "Team") - away = resolve_relationship_reference(match, "awayTeam", "Team") + home = resolve_relationship_reference(match, "homeTeam", _team(match)) + away = resolve_relationship_reference(match, "awayTeam", _team(match)) assert home is not None and home.name == "homeTeamRef" assert away is not None and away.name == "awayTeamRef" def test_source_ref_field_wins_over_name_pairing() -> None: match = _load_match() - resolved = resolve_relationship_reference(match, "homeTeam", "Team", "awayTeamId") + resolved = resolve_relationship_reference(match, "homeTeam", _team(match), "awayTeamId") assert resolved is not None and resolved.name == "awayTeamRef" @@ -86,7 +95,7 @@ def test_a_single_candidate_resolves_regardless_of_name() -> None: ]}}, ]}} match = _load_object(doc, "Match") - resolved = resolve_relationship_reference(match, "champion", "Team") + resolved = resolve_relationship_reference(match, "champion", _team(match)) assert resolved is not None and resolved.name == "anythingAtAll" @@ -113,7 +122,7 @@ def test_unpairable_names_return_none_rather_than_guessing() -> None: result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))]) assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"] match = _find_object(result.root, "Match") - assert resolve_relationship_reference(match, "winner", "Team") is None + assert resolve_relationship_reference(match, "winner", _team(match)) is None def test_suffix_stripping_never_applies_to_the_relationship_name() -> None: @@ -136,7 +145,7 @@ def test_suffix_stripping_never_applies_to_the_relationship_name() -> None: result = MetaDataLoader().load([InMemoryStringSource(json.dumps(doc))]) assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"] match = _find_object(result.root, "Match") - assert resolve_relationship_reference(match, "valid", "Team") is None + assert resolve_relationship_reference(match, "valid", _team(match)) is None # --------------------------------------------------------------------------- @@ -175,8 +184,7 @@ def test_dotted_references_are_enumerated_as_candidates() -> None: # Both the bare-dotted "Team.id" and the FQN-dotted "repro::Team.id" forms # resolve to the entity "Team" — the package separator is never mistaken # for the field separator. - assert [c.name for c in reference_candidates_for(match, "Team")] == ["alphaRef", "betaRef"] - assert [c.name for c in reference_candidates_for(match, "repro::Team")] == ["alphaRef", "betaRef"] + assert [c.name for c in reference_candidates_for(match, _team(match))] == ["alphaRef", "betaRef"] def test_dotted_references_resolve_through_source_ref_field() -> None: @@ -186,7 +194,7 @@ def test_dotted_references_resolve_through_source_ref_field() -> None: # this raised ERR_INVALID_RELATIONSHIP ("names no identity.reference # targeting Team. Candidates: .") on a model TypeScript accepted. match = _load_object(doc, "Match") - resolved = resolve_relationship_reference(match, "winner", "Team", "betaFk") + resolved = resolve_relationship_reference(match, "winner", _team(match), "betaFk") assert resolved is not None and resolved.name == "betaRef" @@ -200,4 +208,4 @@ def test_dotted_references_are_still_ambiguous_without_disambiguation() -> None: assert [e.code.value for e in result.errors] == ["ERR_INVALID_RELATIONSHIP"] assert "alphaRef(alphaFk), betaRef(betaFk)" in result.errors[0].message match = _find_object(result.root, "Match") - assert resolve_relationship_reference(match, "winner", "Team") is None + assert resolve_relationship_reference(match, "winner", _team(match)) is None diff --git a/server/python/tests/unit/test_reporting_accessors.py b/server/python/tests/unit/test_reporting_accessors.py index 13aa3915b..16aadf822 100644 --- a/server/python/tests/unit/test_reporting_accessors.py +++ b/server/python/tests/unit/test_reporting_accessors.py @@ -102,13 +102,13 @@ def test_dimension_accessors(vocab: MetaData) -> None: program = _child(purchase, TYPE_DIMENSION, "program") assert isinstance(program, MetaDimension) assert not program.is_time() - assert program.of() == "Purchase.programId" + assert program.of() == "acme::shop::Purchase.programId" assert program.via() is None assert program.grains() == [] title = _child(purchase, TYPE_DIMENSION, "programTitle") assert isinstance(title, MetaDimension) - assert title.via() == "Purchase.program" + assert title.via() == "acme::shop::Purchase.program" purchased_at = _child(purchase, TYPE_DIMENSION, "purchasedAt") assert isinstance(purchased_at, MetaDimension) @@ -130,8 +130,8 @@ def test_measure_accessors(vocab: MetaData) -> None: assert revenue.agg() == "sum" assert not revenue.distinct() # @of is isArray: the authored bare string coerces to a one-element list. - assert revenue.get_meta_attr("of") == ["Purchase.amountCents"] - assert revenue.of_columns() == ["Purchase.amountCents"] + assert revenue.get_meta_attr("of") == ["acme::shop::Purchase.amountCents"] + assert revenue.of_columns() == ["acme::shop::Purchase.amountCents"] assert revenue.segment_name() == "active" assert revenue.filter() is None @@ -148,10 +148,10 @@ def test_measure_accessors(vocab: MetaData) -> None: days = _child(event, TYPE_MEASURE, "daysEngaged") assert isinstance(days, MetaMeasure) assert days.of_columns() == [ - "WorkoutEvent.programId", - "WorkoutEvent.customerEmail", - "WorkoutEvent.weekNumber", - "WorkoutEvent.dayNumber", + "acme::shop::WorkoutEvent.programId", + "acme::shop::WorkoutEvent.customerEmail", + "acme::shop::WorkoutEvent.weekNumber", + "acme::shop::WorkoutEvent.dayNumber", ] ratio = _child(event, TYPE_MEASURE, "avgDaysPerStarter") assert isinstance(ratio, MetaMeasure) @@ -177,7 +177,7 @@ def test_inherited_members_resolve_through_extends() -> None: assert occurred.grains() == ["day", "week"] events = _child(event, TYPE_MEASURE, "events") assert isinstance(events, MetaMeasure) - assert events.of_columns() == ["BaseEvent.id"] + assert events.of_columns() == ["acme::shop::BaseEvent.id"] # --------------------------------------------------------------------------- @@ -187,7 +187,7 @@ def test_inherited_members_resolve_through_extends() -> None: def test_report_accessors(vocab: MetaData) -> None: daily = _object(vocab, "DailyRevenue") - assert report_from(daily) == "Purchase" + assert report_from(daily) == "acme::shop::Purchase" assert report_dimension_items(daily) == [ReportDimensionItem("purchasedAt", "day")] assert report_measure_names(daily) == ["purchases", "revenue"] # A relative-date operand survives desugaring unchanged under an explicit op. @@ -246,7 +246,7 @@ def test_report_spine_is_the_declared_path_else_none(vocab: MetaData) -> None: ) result = _load(doc) assert result.errors == [], [e.message for e in result.errors] - assert report_spine(_object(result.root, "ProgramPurchases")) == "Purchase.program" + assert report_spine(_object(result.root, "ProgramPurchases")) == "acme::shop::Purchase.program" assert report_spine(_object(vocab, "DailyRevenue")) is None diff --git a/server/python/tests/unit/test_super_resolve.py b/server/python/tests/unit/test_super_resolve.py index 2d7580c17..947c3b1ff 100644 --- a/server/python/tests/unit/test_super_resolve.py +++ b/server/python/tests/unit/test_super_resolve.py @@ -172,7 +172,7 @@ def test_resolve_relative_ref_resolves_against_reduced_context() -> None: # --------------------------------------------------------------------------- def test_resolve_over_deep_relative_ref_emits_error() -> None: - """More ``..::`` levels than context segments → unresolved → ERR_UNRESOLVED_SUPER.""" + """More ``..::`` levels than context segments → ERR_BAD_ATTR_VALUE (ADR-0059 R2).""" root = _root("acme") base = _node("object", "entity", "Base") root.add_child(base) @@ -186,7 +186,7 @@ def test_resolve_over_deep_relative_ref_emits_error() -> None: resolve_supers(root, errors) assert len(errors) == 1 - assert errors[0].code == ErrorCode.ERR_UNRESOLVED_SUPER + assert errors[0].code == ErrorCode.ERR_BAD_ATTR_VALUE assert sub.super_data is None diff --git a/server/python/tests/unit/test_template_toolcall.py b/server/python/tests/unit/test_template_toolcall.py index 17783017e..0c167cb24 100644 --- a/server/python/tests/unit/test_template_toolcall.py +++ b/server/python/tests/unit/test_template_toolcall.py @@ -114,7 +114,7 @@ def test_template_toolcall_loads_with_required_attrs() -> None: toolcall = toolcalls[0] assert toolcall.name == "lookupWeather" assert toolcall.attr(tc.TEMPLATE_ATTR_TOOL_NAME) == "lookup_weather" - assert toolcall.attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) == "WeatherToolOutput" + assert toolcall.attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) == "acme::tools::WeatherToolOutput" def test_template_toolcall_missing_tool_name_emits_err_missing_required_attr() -> None: diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index 45af16392..827190a81 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -101,6 +101,7 @@ import { TEMPLATE_SUBTYPE_PROMPT, TEMPLATE_SUBTYPE_OUTPUT, TEMPLATE_ATTR_PAYLOAD_REF, + refTarget, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_REQUIRED_SLOTS, TEMPLATE_ATTR_REQUIRED_TAGS, @@ -1134,7 +1135,7 @@ export async function verifyCommand( // @payloadRef must resolve to a loaded object.value (drives the field tree // every render-engine drift check runs against). - const fieldTree = derivePayloadFieldTree(root, payloadRef, tmpl.package ?? tmpl.fileDefaultPackage ?? ""); + const fieldTree = derivePayloadFieldTree(refTarget(tmpl, TEMPLATE_ATTR_PAYLOAD_REF)); if (fieldTree.length === 0) { log.error( `[${tmpl.name}] (${tmpl.subType}) ${ERR_PARTIAL_UNRESOLVED}: ` + @@ -1674,9 +1675,18 @@ export async function verifyCommand( // it MERGES the edit and exits 0 — so prescribing it there was the unbreakable loop // the hand-edit exemption exists to end, reintroduced for the one file the exemption // deliberately excludes. Both lines print when the drift set holds both kinds. - if (result.driftedFiles.length > result.handEditedNames.length) { + if (result.driftedFiles.length > result.handEditedNames.length + result.editedOrphans.length) { log.error("Run 'meta gen' to regenerate, then commit the result."); } + // A hand-edited orphan is the other kind `meta gen` cannot fix: it deletes an untouched + // file no generator emits any more, and refuses an edited one. + if (result.editedOrphans.length > 0) { + log.error( + "For the file(s) above that no generator emits any more and that were edited by hand, " + + "'meta gen' will NOT delete them. Delete each one, or move your edits into a file of " + + "your own first.", + ); + } if (result.handEditedNames.length > 0) { log.error( "For the names artifact(s) above, 'meta gen' will NOT help — it preserves a hand " + diff --git a/server/typescript/packages/cli/src/lib/codegen-drift.ts b/server/typescript/packages/cli/src/lib/codegen-drift.ts index ba965b49f..334e02f51 100644 --- a/server/typescript/packages/cli/src/lib/codegen-drift.ts +++ b/server/typescript/packages/cli/src/lib/codegen-drift.ts @@ -45,6 +45,7 @@ import { DOCS_TARGET_NAME, readGeneratedHash, listGeneratedPaths, + orphanVerdict, NAMES_FILE_SUFFIX, generatorRegistry, normalizeConfig, @@ -75,6 +76,12 @@ export interface CodegenDriftResult { * to end. Two doors asking one question must not answer it twice. */ handEditedNames: string[]; + /** + * The subset of `driftedFiles` that no generator emits any more AND that was edited + * since `meta gen` wrote it, sorted. `meta gen` deletes an untouched orphan but refuses + * one of these, so prescribing it would loop: the remedy is to delete the file. + */ + editedOrphans: string[]; /** * Project-relative paths of generated files that carry a PRESERVED hand edit, sorted: * the generated contribution is current (a fresh regen hashes to what we recorded @@ -196,6 +203,7 @@ export async function computeCodegenDrift( driftedFiles: [], lines: [], handEditedNames: [], + editedOrphans: [], handEdited: [], docsIgnored: 0, docsIgnoredDirs: [], @@ -254,7 +262,7 @@ export async function computeCodegenDrift( // Generate fresh into the temp tree. "overwrite" + "fresh" + a temp // gen-state dir guarantees every file is written (no merge/skip), so the // temp tree is the canonical "what gen would produce right now". - await runGen({ + const regen = await runGen({ config: tempConfig, metadata, projectRoot: tempRoot, @@ -272,11 +280,15 @@ export async function computeCodegenDrift( ...(run?.agentSchema !== undefined ? { agentSchema: run.agentSchema } : {}), }); + // Where gen discards hand edits instead of keeping them (handEdits: "overwrite"). + const overwrite = regen.handEditsOverwrite; + // Diff each committed outDir against its temp mirror. const driftedFiles = new Set(); const lines: string[] = []; const handEditedNames = new Set(); const handEdited = new Set(); + const editedOrphans = new Set(); // Whether we have any record of what we wrote here. With records, the orphan // branch below can scope itself to our own output; with none, it cannot tell @@ -333,8 +345,33 @@ export async function computeCodegenDrift( if (haveWriteRecords && readGeneratedHash(projectGenStateDir, relKey) === undefined) { continue; } + const verdict = haveWriteRecords + ? orphanVerdict(regen.orphanSweepJobs ?? [], tempRoot, relative(tempRoot, join(fresh, rel))) + : undefined; + if (verdict?.kept === true) continue; driftedFiles.add(relKey); - lines.push(`- ${relKey} (committed but regen would not emit it)`); + // `meta gen` deletes an orphan it can prove untouched and REFUSES one edited + // since it was written, so the remedy differs by which this is. Asked of the + // same evidence the sweep asks (the recorded hash), so the two cannot disagree. + // With no write records at all, neither verdict can be proven, so the line + // stays the plain one. + if (!haveWriteRecords) { + lines.push(`- ${relKey} (committed but regen would not emit it)`); + } else if (readGeneratedHash(projectGenStateDir, relKey) === contentHash(readFileSync(join(committed, rel), "utf8"))) { + lines.push(`- ${relKey} (committed but regen would not emit it; 'meta gen' deletes it)`); + } else if (verdict?.force === true) { + // The sweep force-deletes an edited orphan here, so gen deletes it. + lines.push( + `- ${relKey} (no generator emits it any more, and it was edited by hand; ` + + `a force or handEdits: "overwrite" orphan policy owns it, so 'meta gen' deletes it)`, + ); + } else { + editedOrphans.add(relKey); + lines.push( + `- ${relKey} (no generator emits it any more, and it was edited by hand: ` + + `delete it, or move your edits into a file of your own and then delete it)`, + ); + } } else if (!inCommitted && inFresh) { driftedFiles.add(relKey); lines.push(`+ ${relKey} (regen would emit it; not committed — run 'meta gen')`); @@ -384,6 +421,14 @@ export async function computeCodegenDrift( // nothing is proven, so the old byte verdict stands. driftedFiles.add(relKey); lines.push(`~ ${relKey} (committed content differs from a fresh regen)`); + } else if (overwrite?.paths.has(join(fresh, rel)) === true) { + // Its generator says handEdits: "overwrite": `meta gen` replaces the edit + // with fresh output, so it is drift that gen fixes, not a kept edit. + driftedFiles.add(relKey); + lines.push( + `~ ${relKey} (edited by hand; its generator sets handEdits: "overwrite", so ` + + `'meta gen' replaces it with fresh output)`, + ); } else { // The generated contribution is current and the bytes still differ: a // hand edit `meta gen` preserved. Not drift, but never silent either. @@ -400,6 +445,7 @@ export async function computeCodegenDrift( driftedFiles: sorted, lines, handEditedNames: [...handEditedNames].sort(), + editedOrphans: [...editedOrphans].sort(), handEdited: [...handEdited].sort(), docsIgnored, docsIgnoredDirs, diff --git a/server/typescript/packages/cli/src/lib/deprecation-lint.ts b/server/typescript/packages/cli/src/lib/deprecation-lint.ts index 6872bf0c5..054796944 100644 --- a/server/typescript/packages/cli/src/lib/deprecation-lint.ts +++ b/server/typescript/packages/cli/src/lib/deprecation-lint.ts @@ -50,6 +50,7 @@ import { ORIGIN_AGGREGATE_ATTR_OF, isMetaObject, resolveObjectRef, + refTarget, type MetaData, type MetaObject, } from "@metaobjectsdev/metadata"; @@ -166,12 +167,11 @@ function findHop(entity: MetaObject, hopName: string): MetaData | undefined { ); } -/** The entity a resolved hop (relationship or identity.reference) points at, raw. */ -function hopTargetRef(hop: MetaData): string | undefined { - const v = hop.type === TYPE_RELATIONSHIP - ? hop.attr(RELATIONSHIP_ATTR_OBJECT_REF) - : hop.attr(IDENTITY_REFERENCE_ATTR_REFERENCES); - return typeof v === "string" && v !== "" ? headOf(v) : undefined; +/** The entity a resolved hop (relationship or identity.reference) points at, as the + * loader bound it (ADR-0059 R7: resolved where the hop's reference was declared). */ +function hopTarget(hop: MetaData): MetaObject | undefined { + const target = refTarget(hop, hop.type === TYPE_RELATIONSHIP ? RELATIONSHIP_ATTR_OBJECT_REF : IDENTITY_REFERENCE_ATTR_REFERENCES); + return target !== undefined && isMetaObject(target) ? target : undefined; } /** @@ -187,18 +187,15 @@ function hopTargetRef(hop: MetaData): string | undefined { * directory takes. */ function checkEntityFieldPath( - root: MetaData, referrer: MetaData, referrerPath: string, verb: string, raw: string, - referrerPkg: string, + entity: MetaObject | undefined, out: Diagnostic[], ): void { - const head = headOf(raw); - const entity = resolveEntityRef(root, head, referrerPkg); if (entity === undefined) return; - const fieldName = raw.slice(head.length + 1); + const fieldName = raw.slice(headOf(raw).length + 1); const field = fieldName !== "" ? entity.fields().find((f) => f.name === fieldName) : undefined; if (field !== undefined && considerTarget(referrer, referrerPath, verb, field, out)) { return; // the field itself is the deprecated thing — don't ALSO warn about its (fine) entity. @@ -232,9 +229,7 @@ function checkViaPath( const hop = findHop(current, hopName); if (hop === undefined) return; considerTarget(origin, referrerPath, verb, hop, out); - const targetRef = hopTargetRef(hop); - if (targetRef === undefined) return; - const next = resolveEntityRef(root, targetRef, packageOf(current)); + const next = hopTarget(hop); if (next === undefined) return; considerTarget(origin, referrerPath, verb, next, out); current = next; @@ -252,11 +247,11 @@ function checkOrigin(root: MetaData, origin: MetaData, referrerPath: string, out // same stance every origin accessor in meta-origin.ts takes). const from = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM); if (typeof from === "string" && from !== "") { - checkEntityFieldPath(root, origin, referrerPath, `${subtypeLabel} @from references`, from, referrerPkg, out); + checkEntityFieldPath(origin, referrerPath, `${subtypeLabel} @from references`, from, resolveEntityRef(root, headOf(from), referrerPkg), out); } const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF); if (typeof of_ === "string" && of_ !== "") { - checkEntityFieldPath(root, origin, referrerPath, `${subtypeLabel} @of references`, of_, referrerPkg, out); + checkEntityFieldPath(origin, referrerPath, `${subtypeLabel} @of references`, of_, resolveEntityRef(root, headOf(of_), referrerPkg), out); } const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA); if (typeof via === "string" && via !== "") { @@ -273,12 +268,9 @@ function checkNode(root: MetaData, node: MetaData, path: string, out: Diagnostic // @objectRef — relationship.* (its target object) and field.object/value (a // value-typed field's own objectRef). Same attr name on both (naming-refs.ts). if (node.type === TYPE_RELATIONSHIP || node.type === TYPE_FIELD) { - const ref = node.attr(node.type === TYPE_RELATIONSHIP ? RELATIONSHIP_ATTR_OBJECT_REF : FIELD_ATTR_OBJECT_REF); - if (typeof ref === "string" && ref !== "") { - const owner = owningObject(node); - const target = resolveEntityRef(root, ref, owner !== undefined ? packageOf(owner) : ""); - if (target !== undefined) considerTarget(node, path, "references (@objectRef)", target, out); - } + // ADR-0059 R7: the target the loader bound, resolved where @objectRef was declared. + const target = refTarget(node, node.type === TYPE_RELATIONSHIP ? RELATIONSHIP_ATTR_OBJECT_REF : FIELD_ATTR_OBJECT_REF); + if (target !== undefined && isMetaObject(target)) considerTarget(node, path, "references (@objectRef)", target, out); } // @references — identity.reference's FK target. May be bare ("Entity", @@ -290,8 +282,8 @@ function checkNode(root: MetaData, node: MetaData, path: string, out: Diagnostic if (node.type === TYPE_IDENTITY && node.subType === IDENTITY_SUBTYPE_REFERENCE) { const raw = node.attr(IDENTITY_REFERENCE_ATTR_REFERENCES); if (typeof raw === "string" && raw !== "") { - const owner = owningObject(node); - checkEntityFieldPath(root, node, path, "references (@references)", raw, owner !== undefined ? packageOf(owner) : "", out); + const target = refTarget(node, IDENTITY_REFERENCE_ATTR_REFERENCES); + checkEntityFieldPath(node, path, "references (@references)", raw, target !== undefined && isMetaObject(target) ? target : undefined, out); } } diff --git a/server/typescript/packages/cli/src/lib/payload-field-tree.ts b/server/typescript/packages/cli/src/lib/payload-field-tree.ts index 44495372a..2c6700557 100644 --- a/server/typescript/packages/cli/src/lib/payload-field-tree.ts +++ b/server/typescript/packages/cli/src/lib/payload-field-tree.ts @@ -10,53 +10,32 @@ import { FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, isMetaField, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import type { PayloadField } from "@metaobjectsdev/render"; -function findObject(root: MetaData, name: string, referrerPkg: string): MetaData | undefined { - // ADR-0039: effective children — resolve rather than rely on root being unextended. - // ADR-0042: package-local — an FQN @objectRef binds the exact package; a bare ref - // binds the referrer's package FIRST (else root-level), never a same-named object.value - // in another package. The SAME resolver render-helper.ts's findObject uses (must not drift). - return resolveObjectRef(root, name, referrerPkg).node; -} - /** * Walk an `object.value` view-object into a render `PayloadField[]`. Object-ref - * fields recurse into their referenced view-object; a `seen` set guards against - * a (pathological) reference cycle. `referrerPkg` (ADR-0042) is the package of - * the node that declares `voName` (the template for the root call; the parent VO - * for a nested recursion), so a bare ref resolves package-locally. + * fields recurse into the view-object the loader bound for their `@objectRef` + * (ADR-0059 R7); a `seen` set guards against a (pathological) reference cycle. + * Returns `[]` when `vo` is undefined (the ref did not resolve). */ export function derivePayloadFieldTree( - root: MetaData, - voName: string, - referrerPkg = "", - seen: ReadonlySet = new Set(), + vo: MetaData | undefined, + seen: ReadonlySet = new Set(), ): PayloadField[] { - if (seen.has(voName)) return []; - const vo = findObject(root, voName, referrerPkg); - if (!vo) return []; - // A nested @objectRef resolves in the FIELD's own declaring package (fallback - // below), which differs from this VO's when the field is inherited via extends - // from an abstract VO in another package (the bare ref was authored there). - const voPkg = vo.package ?? vo.fileDefaultPackage ?? ""; - const nextSeen = new Set(seen).add(voName); + if (vo === undefined || seen.has(vo)) return []; + const nextSeen = new Set(seen).add(vo); const fields: PayloadField[] = []; for (const f of vo.children().filter(isMetaField)) { // ADR-0039: effective array-ness — `isArray` may be inherited via extends. Carried only // when true, so verify can tell a list of objects from a single object and a list of // scalars from a scalar. const arrayness = f.resolvedIsArray() ? { isArray: true } : {}; - if (f.subType === FIELD_SUBTYPE_OBJECT) { + if (f.subType === FIELD_SUBTYPE_OBJECT && typeof f.attr(FIELD_ATTR_OBJECT_REF) === "string") { // ADR-0039: effective attr — @objectRef may be inherited via extends. - const ref = f.attr(FIELD_ATTR_OBJECT_REF); - if (typeof ref === "string") { - const fieldPkg = f.parent?.package ?? f.parent?.fileDefaultPackage ?? voPkg; - fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen), ...arrayness }); - continue; - } + fields.push({ name: f.name, fields: derivePayloadFieldTree(refTarget(f, FIELD_ATTR_OBJECT_REF), nextSeen), ...arrayness }); + continue; } fields.push({ name: f.name, ...arrayness }); } diff --git a/server/typescript/packages/cli/test/codegen-drift-orphan-policy.test.ts b/server/typescript/packages/cli/test/codegen-drift-orphan-policy.test.ts new file mode 100644 index 000000000..f77a41564 --- /dev/null +++ b/server/typescript/packages/cli/test/codegen-drift-orphan-policy.test.ts @@ -0,0 +1,121 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { runGen, type Generator, type MetaobjectsGenConfig, type OrphanPolicy } from "@metaobjectsdev/codegen-ts"; +import { computeCodegenDrift } from "../src/lib/codegen-drift.js"; + +const OUT = "gen"; + +async function model(...names: string[]): Promise { + const doc = JSON.stringify({ + "metadata.root": { + package: "acme", + children: names.map((name) => ({ + "object.value": { name, children: [{ "field.string": { name: "x" } }] }, + })), + }, + }); + const r = await new MetaDataLoader().load([new InMemoryStringSource(doc)]); + if (r.errors.length > 0) throw new Error(r.errors.map((e) => e.message).join("\n")); + return r.root; +} + +const stubs = (orphanPolicy: OrphanPolicy, names: readonly string[]): Generator => ({ + name: "stubs", + generate: () => names.map((name) => ({ path: `stubs/${name}.txt`, content: `${name}\n` })), + orphanPolicy, +}); + +const owns = (rel: string): boolean => rel.startsWith("stubs/"); + +let projectRoot: string; +beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "drift-orphan-policy-")); +}); +afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); +}); + +const config = (policy: OrphanPolicy, names: readonly string[]): MetaobjectsGenConfig => ({ + outDir: OUT, + extStyle: "js", + generators: [stubs(policy, names)], +}); + +async function gen(policy: OrphanPolicy, ...names: string[]) { + return runGen({ config: config(policy, names), metadata: await model(...names), projectRoot }); +} + +describe("verify --codegen classifies orphans by the sweep's own policy", () => { + test("a kept orphan is not drift, and gen keeps it", async () => { + const policy: OrphanPolicy = { owns, keep: true }; + await gen(policy, "A", "B"); + const orphan = join(projectRoot, OUT, "stubs", "B.txt"); + + const drift = await computeCodegenDrift(config(policy, ["A"]), await model("A"), projectRoot); + expect(drift.driftedFiles).toEqual([]); + expect(drift.lines).toEqual([]); + expect(drift.clean).toBe(true); + + await gen(policy, "A"); + expect(existsSync(orphan)).toBe(true); + }); + + test("an edited orphan under a force policy is reported as deleted by gen, and gen deletes it", async () => { + const policy: OrphanPolicy = { owns, force: true }; + await gen(policy, "A", "B"); + const orphan = join(projectRoot, OUT, "stubs", "B.txt"); + writeFileSync(orphan, `${readFileSync(orphan, "utf8")}// mine\n`); + + const drift = await computeCodegenDrift(config(policy, ["A"]), await model("A"), projectRoot); + expect(drift.editedOrphans).toEqual([]); + expect(drift.driftedFiles).toEqual([join(OUT, "stubs", "B.txt")]); + expect(drift.lines.join("\n")).toContain("'meta gen' deletes it"); + + await gen(policy, "A"); + expect(existsSync(orphan)).toBe(false); + }); + + test("an edited orphan without force stays an edited orphan, and gen refuses it", async () => { + const policy: OrphanPolicy = { owns }; + await gen(policy, "A", "B"); + const orphan = join(projectRoot, OUT, "stubs", "B.txt"); + writeFileSync(orphan, `${readFileSync(orphan, "utf8")}// mine\n`); + + const drift = await computeCodegenDrift(config(policy, ["A"]), await model("A"), projectRoot); + expect(drift.editedOrphans).toEqual([join(OUT, "stubs", "B.txt")]); + + await gen(policy, "A"); + expect(existsSync(orphan)).toBe(true); + }); +}); + +describe("a declared outDir no selected generator writes", () => { + const plain = (target?: string): Generator => ({ + name: "plain", + generate: () => [{ path: "A.txt", content: "A\n" }], + ...(target !== undefined && { target }), + }); + const moved: MetaobjectsGenConfig = { + outDir: OUT, + extStyle: "js", + targets: { web: { outDir: "web" } }, + generators: [plain("web")], + }; + + test("verify reports its recorded file as deleted by gen, gen deletes it, and verify is then clean", async () => { + await runGen({ config: { outDir: OUT, extStyle: "js", generators: [plain()] }, metadata: await model("A"), projectRoot }); + const left = join(projectRoot, OUT, "A.txt"); + expect(existsSync(left)).toBe(true); + + const drift = await computeCodegenDrift(moved, await model("A"), projectRoot); + expect(drift.lines.join("\n")).toContain(`${join(OUT, "A.txt")} (committed but regen would not emit it; 'meta gen' deletes it)`); + + await runGen({ config: moved, metadata: await model("A"), projectRoot }); + expect(existsSync(left)).toBe(false); + expect(existsSync(join(projectRoot, "web", "A.txt"))).toBe(true); + expect((await computeCodegenDrift(moved, await model("A"), projectRoot)).clean).toBe(true); + }); +}); diff --git a/server/typescript/packages/cli/test/integration/verify-codegen-foreign-files.test.ts b/server/typescript/packages/cli/test/integration/verify-codegen-foreign-files.test.ts index f82a3475e..fbfbfe8c3 100644 --- a/server/typescript/packages/cli/test/integration/verify-codegen-foreign-files.test.ts +++ b/server/typescript/packages/cli/test/integration/verify-codegen-foreign-files.test.ts @@ -151,6 +151,102 @@ describe("meta verify --codegen — files MetaObjects never wrote", () => { } }); + test("the remedy works: meta gen deletes the untouched orphan and the gate goes green", async () => { + // The loop this closes: the gate convicted `Tag.ts` and said "run 'meta gen'", and + // gen never deleted anything, so the gate failed identically after every run. + const root = setupRepo(); + try { + expect(await run(["gen", "--cwd", root])).toBe(0); + deleteTagEntity(root); + + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(1); + expect(all()).toContain("'meta gen' deletes it"); + + out = []; err = []; + expect(await run(["gen", "--cwd", root])).toBe(0); + expect(existsSync(join(root, OUT, "Tag.ts"))).toBe(false); + expect(readFileSync(join(root, MANIFEST), "utf8")).not.toContain("Tag.ts"); + + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a hand-edited orphan is refused by gen, and the gate says to delete it", async () => { + const root = setupRepo(); + try { + expect(await run(["gen", "--cwd", root])).toBe(0); + writeFileSync(join(root, OUT, "Tag.ts"), `${readFileSync(join(root, OUT, "Tag.ts"), "utf8")}// mine\n`); + deleteTagEntity(root); + + out = []; err = []; + await run(["gen", "--cwd", root]); + expect(existsSync(join(root, OUT, "Tag.ts"))).toBe(true); + expect(all()).toContain("NOT deleted"); + + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(1); + expect(all()).toContain("edited by hand: delete it"); + expect(all()).toContain("'meta gen' will NOT delete them"); + expect(all()).not.toContain("Run 'meta gen' to regenerate"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("handEdits: overwrite — the gate calls an edit drift that gen fixes, and gen fixes it", async () => { + const root = setupRepo(); + try { + writeFileSync( + join(root, "metaobjects.config.ts"), + ` +import { defineConfig } from "@metaobjectsdev/codegen-ts"; +import { entityFile } from "@metaobjectsdev/codegen-ts/generators"; +export default defineConfig({ + outDir: ${JSON.stringify(join(root, OUT))}, + dialect: "sqlite", + dbImport: "~/db", + extStyle: "none", + generators: [{ ...entityFile(), handEdits: "overwrite" }], +}); +`, + ); + expect(await run(["gen", "--cwd", root])).toBe(0); + const tag = join(root, OUT, "Tag.ts"); + writeFileSync(tag, `${readFileSync(tag, "utf8")}// mine\n`); + + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(1); + expect(all()).toContain(`handEdits: "overwrite"`); + expect(all()).toContain("Run 'meta gen' to regenerate"); + + out = []; err = []; + expect(await run(["gen", "--cwd", root])).toBe(0); + expect(all()).toContain("overwrote hand-edited"); + expect(readFileSync(tag, "utf8")).not.toContain("// mine"); + + // An edited orphan of an overwriting generator is deleted, not refused. + writeFileSync(tag, `${readFileSync(tag, "utf8")}// mine\n`); + deleteTagEntity(root); + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(1); + expect(all()).toContain("so 'meta gen' deletes it"); + expect(all()).not.toContain("will NOT delete"); + out = []; err = []; + expect(await run(["gen", "--cwd", root])).toBe(0); + expect(existsSync(tag)).toBe(false); + expect(all()).toContain("deleted hand-edited"); + + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("with no manifest at all, the old conservative verdict stands", async () => { const root = setupRepo(); try { diff --git a/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts b/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts index 071da6f84..67c97d0b5 100644 --- a/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts +++ b/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts @@ -47,7 +47,7 @@ const model = [ describe("derivePayloadFieldTree — mirrors the payload-codegen VO walk", () => { test("scalar fields become leaf nodes; an object-ref field becomes a nested tree", async () => { const root = await loadRoot(model); - const tree = derivePayloadFieldTree(root, "AuthorBrief", "acme::ai"); + const tree = derivePayloadFieldTree(root.findObject("acme::ai::AuthorBrief")); expect(tree).toEqual([ { name: "displayName" }, { name: "postCount" }, @@ -75,7 +75,7 @@ describe("derivePayloadFieldTree — mirrors the payload-codegen VO walk", () => }, }, ]); - expect(derivePayloadFieldTree(root, "Card", "acme::ai")).toEqual([ + expect(derivePayloadFieldTree(root.findObject("acme::ai::Card"))).toEqual([ { name: "title" }, { name: "aliases", isArray: true }, { name: "labels", isArray: true }, @@ -86,7 +86,7 @@ describe("derivePayloadFieldTree — mirrors the payload-codegen VO walk", () => test("an unknown view-object name yields an empty tree", async () => { const root = await loadRoot(model); - expect(derivePayloadFieldTree(root, "Nope")).toEqual([]); + expect(derivePayloadFieldTree(root.findObject("Nope"))).toEqual([]); }); // ADR-0041: two packages each declare an object.value `Note` (alpha: alphaText, beta: @@ -104,7 +104,7 @@ describe("derivePayloadFieldTree — mirrors the payload-codegen VO walk", () => { "field.object": { name: "fromAlpha", "@objectRef": "acme::alpha::Note" } }, { "field.object": { name: "fromBeta", "@objectRef": "acme::beta::Note" } } ] } } ] } }, ); - expect(derivePayloadFieldTree(root, "Digest", "acme::app")).toEqual([ + expect(derivePayloadFieldTree(root.findObject("acme::app::Digest"))).toEqual([ { name: "fromAlpha", fields: [{ name: "alphaText" }] }, { name: "fromBeta", fields: [{ name: "betaText" }] }, ]); diff --git a/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts b/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts index 914f2d6c9..8f06de418 100644 --- a/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts +++ b/server/typescript/packages/codegen-ts-tanstack/src/templates/hooks-file.ts @@ -125,7 +125,7 @@ function renderM2mHooks( const mod = entityModuleSpecifier( ctx.selfTarget, ctx.entityModuleTarget, - ctx.packageOf.get(e.targetEntity), + effectivePackage(e.target), e.targetEntity, ctx.extStyle, ); diff --git a/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts b/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts index 75d7bd355..4c03a1a05 100644 --- a/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts +++ b/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts @@ -36,11 +36,13 @@ import { reportShape, reportSpine, reportSpineHops, - resolveObjectRef, + refTarget, resolveTableName, resolveTableSchema, stripPackage, type MetaData, + REPORTING_ATTR_VIA, + OBJECT_REPORT_ATTR_SPINE, type MetaDimension, type MetaField, type MetaMeasure, @@ -65,7 +67,7 @@ import { type Path, type PathStep, } from "../projection/extract-view-spec.js"; -import { dimensionOfField, ratioOperand, resolveDimensionViaPath } from "../projection/report-resolve.js"; +import { authoredText, dimensionOfField, ratioOperand, resolveDimensionViaPath } from "../projection/report-resolve.js"; import { andOf, cond, reportScope } from "../projection/report-sql.js"; import type { ViewFilterClause } from "../projection/view-spec.js"; import { @@ -403,7 +405,8 @@ class CubeModelBuilder { */ private viaPath(dim: MetaDimension, from: MetaObject): Path { const where = `cube '${from.name}': dimension '${memberKey(dim)}'`; - const via = dim.via() ?? ""; + // Refusals quote @via as the author wrote it (ADR-0059). + const via = authoredText(dim, REPORTING_ATTR_VIA) ?? dim.via() ?? ""; const path = resolveDimensionViaPath(dim, from, this.root, this.mc.extract, where); const onPath = new Set([from.resolutionKey()]); for (const step of path) { @@ -535,8 +538,7 @@ class CubeModelBuilder { relationship = "many_to_one"; } else if (child.type === TYPE_RELATIONSHIP && child.attr(RELATIONSHIP_ATTR_CARDINALITY) === CARDINALITY_ONE) { // Resolved first, so a relationship onto an entity that is no cube is never walked. - const targetRef = child.attr(RELATIONSHIP_ATTR_OBJECT_REF); - const target = typeof targetRef === "string" ? resolveObjectRef(this.root, targetRef, packageOf(entity)).node : undefined; + const target = refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF); if (!isMetaObject(target) || !this.drafts.has(target.resolutionKey())) continue; relationship = "one_to_one"; } else { @@ -636,7 +638,7 @@ class CubeModelBuilder { if (cubes.includes(hop.joined)) { throw new CubeModelError( ERR_CUBE_UNMAPPABLE_DIMENSION, - `${where} @via '${dim.via() ?? ""}' passes through one cube twice ` + + `${where} @via '${authoredText(dim, REPORTING_ATTR_VIA) ?? dim.via() ?? ""}' passes through one cube twice ` + `(${[...cubes, hop.joined].map((c) => c.name).join(" -> ")}), so the member it reads would belong ` + `to the earlier hop's row. Remove the dimension, or reach the value through distinct cubes.`, ); @@ -669,7 +671,7 @@ class CubeModelBuilder { const two = paths.length === 2; throw new CubeModelError( ERR_CUBE_AMBIGUOUS_PATH, - `${where} reads '${joined.name}' through @via '${dim.via() ?? ""}', and the cube graph reaches ` + + `${where} reads '${joined.name}' through @via '${authoredText(dim, REPORTING_ATTR_VIA) ?? dim.via() ?? ""}', and the cube graph reaches ` + `'${joined.name}' from '${draft.name}' by more than one path ` + `(${listed.join("; ")}${more}), so Cube could join it by ${two ? "either" : "any of them"}. Remove ` + `the identity.reference that makes the other ${two ? "path" : "paths"}, or read the value through a ` + @@ -775,7 +777,8 @@ class CubeModelBuilder { const from = this.reportCube(report, shape, "facts"); if (from === undefined) return undefined; const where = `report '${report.resolutionKey()}'`; - const spine = reportSpine(report) ?? ""; + // Messages quote @spine as the author wrote it (ADR-0059). + const spine = authoredText(report, OBJECT_REPORT_ATTR_SPINE) ?? reportSpine(report) ?? ""; // The spine cube, and the spine's own steps, from the listed dimensions' resolved paths. let spineCube: CubeDraft | undefined; diff --git a/server/typescript/packages/codegen-ts/src/generator.ts b/server/typescript/packages/codegen-ts/src/generator.ts index 947c4a317..64bcdec18 100644 --- a/server/typescript/packages/codegen-ts/src/generator.ts +++ b/server/typescript/packages/codegen-ts/src/generator.ts @@ -5,6 +5,7 @@ import type { RenderContext } from "./render-context.js"; import type { DocsRunFacts } from "./docs-run.js"; import type { ResolvedGenConfig } from "./metaobjects-config.js"; import type { OrphanPolicy } from "./reconcile-orphans.js"; +import type { HandEdits } from "./overwrite-policy.js"; import { effectivePackage } from "./docs-paths.js"; export interface EmittedFile { @@ -134,14 +135,24 @@ export interface Generator { /** Marks the generator that produces entity modules — the runner uses its * target as the entity-module target for cross-target import resolution. */ emitsEntityModule?: boolean; - /** FR-038 §8 — OPT IN to orphan reconciliation by declaring the output - * namespace this generator exclusively owns. When set, the runner removes - * files inside that namespace which a previous run generated and this run did - * not, REFUSING any that have been edited by hand. Absent (the default for - * every existing generator) means the runner never deletes anything, which is - * why this is additive: output stops being generated and the stale file simply - * stays, exactly as before. */ + /** FR-038 §8 — declare the output namespace this generator exclusively owns. + * A full `meta gen` run already removes every file it recorded writing in a + * target directory and no generator emits any more, refusing any edited by hand + * (the run-wide sweep, every generator, no opt-in). A policy adds two things on + * top: its namespace is still reconciled on a `--generators` run that names this + * generator, and `force` can delete a hand-edited file in it instead of refusing. */ orphanPolicy?: OrphanPolicy; + /** + * What a hand edit to one of this generator's files gets. `"keep"`, the default, + * protects it: the edit is merged into fresh output when this machine has a snapshot + * body, and the file is refused by name otherwise; an edited file no generator emits + * any more is refused, not deleted. `"overwrite"` discards it: the file is replaced + * with fresh output and its record re-baselined, and an edited file of this generator's + * that no generator emits any more is deleted. Each discarded edit is named in the + * run's warnings. A file `gen` has no record of writing is never touched either way. + * The same setting, by the same name, exists in every port. + */ + handEdits?: HandEdits; /** Marks the OPT-IN Hono routes generator (routesFileHono). The runner * aggregates this across the active suite into `ctx.config.includeHonoRoutes`, * so a generator that documents the API surface (api-docs) can AUTO-DETECT diff --git a/server/typescript/packages/codegen-ts/src/generators/agent-docs-file.ts b/server/typescript/packages/codegen-ts/src/generators/agent-docs-file.ts index e4a6a42d9..63ea1610f 100644 --- a/server/typescript/packages/codegen-ts/src/generators/agent-docs-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/agent-docs-file.ts @@ -95,8 +95,9 @@ export interface AgentDocsFileOpts { function lineageOf(field: MetaField): string | undefined { const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN); if (origin === undefined) return undefined; + // Human-facing text: references print as the author wrote them (ADR-0059). const str = (name: string): string | undefined => { - const v = origin.attr(name); + const v = origin.authoredAttr(name); return typeof v === "string" && v !== "" ? v : undefined; }; switch (origin.subType) { @@ -154,8 +155,9 @@ function relationshipLines(objects: readonly MetaObject[]): string[] { for (const obj of objects) { for (const rel of obj.relationships()) { const cardinality = rel.attr(RELATIONSHIP_ATTR_CARDINALITY); - const target = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF); - const through = rel.attr(RELATIONSHIP_ATTR_THROUGH); + // References print as the author wrote them (ADR-0059); the loaded value is canonical. + const target = rel.authoredAttr(RELATIONSHIP_ATTR_OBJECT_REF); + const through = rel.authoredAttr(RELATIONSHIP_ATTR_THROUGH); const onDelete = rel.attr(RELATIONSHIP_ATTR_ON_DELETE); // The FQN, not the short name: this line is an ADDRESS a reader searches for. const parts = [`\`${obj.resolutionKey()}.${rel.name}\``, `\`${rel.subType}\``]; diff --git a/server/typescript/packages/codegen-ts/src/generators/api-field-shape.ts b/server/typescript/packages/codegen-ts/src/generators/api-field-shape.ts index dca8932bb..d56592706 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-field-shape.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-field-shape.ts @@ -17,7 +17,7 @@ // interface — the type `extract` returns — via the SAME // `fieldTsTypeString` mapping the VO interface emitter uses. -import type { MetaObject, MetaField, MetaRoot } from "@metaobjectsdev/metadata"; +import { type MetaData, type MetaObject, type MetaField, isMetaObject, refTarget } from "@metaobjectsdev/metadata"; import { fieldTsTypeString } from "../templates/inferred-types.js"; import { insertSchemaFields, @@ -93,15 +93,15 @@ export function updateFieldShapes(obj: MetaObject): FieldShape[] { } /** - * The extractor PAYLOAD field shape — the field interface of the value object - * `extract` returns (the `@payloadRef` target). Same `fieldTsTypeString` - * mapping + `isFieldRequired` optionality the VO interface emitter uses, so it - * matches the emitted payload type. Returns undefined when the ref does not - * resolve to a loaded object. + * The field shape of the value object a template reference attribute names + * (`@payloadRef` / `@responseRef`), read as the loader bound it (ADR-0059 R7). Same + * `fieldTsTypeString` mapping + `isFieldRequired` optionality the VO interface emitter + * uses, so it matches the emitted type. Returns undefined when the ref does not resolve + * to a loaded object. */ -export function payloadFieldShapes(root: MetaRoot, payloadRef: string): FieldShape[] | undefined { - const vo = root.findObject(payloadRef); - if (vo === undefined) return undefined; +export function payloadFieldShapes(tmpl: MetaData, attrName: string): FieldShape[] | undefined { + const vo = refTarget(tmpl, attrName); + if (vo === undefined || !isMetaObject(vo)) return undefined; return modelFieldShapes(vo); } diff --git a/server/typescript/packages/codegen-ts/src/generators/api-model.ts b/server/typescript/packages/codegen-ts/src/generators/api-model.ts index 2e33aaf02..7b17c7b0c 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-model.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-model.ts @@ -112,13 +112,14 @@ import { TEMPLATE_SUBTYPE_OUTPUT, TEMPLATE_SUBTYPE_PROMPT, TEMPLATE_ATTR_PAYLOAD_REF, + TEMPLATE_ATTR_RESPONSE_REF, TEMPLATE_ATTR_FORMAT, TEMPLATE_ATTR_KIND, TEMPLATE_KIND_EMAIL, TEMPLATE_KIND_DEFAULT, TYPE_SOURCE, SOURCE_ATTR_PARAMETER_REF, - resolveObjectRef, + refTarget, stripPackage, } from "@metaobjectsdev/metadata"; import { @@ -896,9 +897,8 @@ function callableArgsRef(obj: MetaObject, root: MetaRoot): string | undefined { if (typeof ref === "string" && ref !== "") { // Only count it when it resolves to a value object (the template's guard). // ADR-0039: resolving — root has no super (children()==ownChildren()). - // ADR-0042: a bare @parameterRef resolves in the callable entity's package. - const referrerPkg = obj.package ?? obj.fileDefaultPackage ?? ""; - const vo = resolveObjectRef(root, ref, referrerPkg).node; + // ADR-0059 R7: the bound target, resolved where @parameterRef was declared. + const vo = refTarget(child, SOURCE_ATTR_PARAMETER_REF); if (vo?.subType === OBJECT_SUBTYPE_VALUE) return ref; } } @@ -977,7 +977,7 @@ function buildTemplateUnit(tmpl: MetaData, root: MetaRoot, _layout: OutputLayout // ADR-0039: resolving — a template may inherit @payloadRef/@kind via extends. const payloadRef = tmpl.attr(TEMPLATE_ATTR_PAYLOAD_REF); const payload = typeof payloadRef === "string" ? payloadRef : undefined; - const payloadType = payload !== undefined ? valueObjectTypeName(root, tmpl, payload) : undefined; + const payloadType = payload !== undefined ? valueObjectTypeName(tmpl, payload) : undefined; const kind = ((tmpl.attr(TEMPLATE_ATTR_KIND) as string | undefined) ?? TEMPLATE_KIND_DEFAULT).toLowerCase(); // ADR-0052: a template.output documents its RENDER and nothing else. The @@ -1009,7 +1009,7 @@ function buildTemplateUnit(tmpl: MetaData, root: MetaRoot, _layout: OutputLayout // ADR-0052 moved that away, which left the render example with no field shape // to build from — it degraded to `render({}, provider)`. Attach it here, // where it always belonged: this is the payload the render handle takes. - const renderShape = payloadFieldShapes(root, payload); + const renderShape = payloadFieldShapes(tmpl, TEMPLATE_ATTR_PAYLOAD_REF); if (renderShape !== undefined) renderSym.fields = renderShape; symbols.push(renderSym); } @@ -1041,12 +1041,12 @@ function templatePrompts(root: MetaRoot): MetaData[] { } /** - * The TS type name a template's payload/response is documented under — the resolved value - * object's own name (ADR-0056: it IS the type), falling back to the ref's short name when the - * ref does not resolve. ADR-0042: a bare ref resolves in the template's package first. + * The TS type name a template's payload is documented under — the bound value object's own + * name (ADR-0056: it IS the type, ADR-0059 R7), falling back to the ref's short name when the + * ref does not resolve. */ -function valueObjectTypeName(root: MetaRoot, tmpl: MetaData, ref: string): string { - const vo = resolveObjectRef(root, ref, tmpl.package ?? tmpl.fileDefaultPackage ?? "").node; +function valueObjectTypeName(tmpl: MetaData, ref: string): string { + const vo = refTarget(tmpl, TEMPLATE_ATTR_PAYLOAD_REF); return vo !== undefined ? vo.name : stripPackage(ref); } @@ -1071,7 +1071,7 @@ function buildPromptUnit(tmpl: MetaData, root: MetaRoot): ApiUnitDoc { if (payload) { const render = `render${symbolBase}`; - const payloadType = valueObjectTypeName(root, tmpl, payload); + const payloadType = valueObjectTypeName(tmpl, payload); const sym: ApiSymbol = { name: render, kind: "prompt", @@ -1081,7 +1081,7 @@ function buildPromptUnit(tmpl: MetaData, root: MetaRoot): ApiUnitDoc { returns: "string", usage: `Render the ${name} prompt text from a typed ${payloadType} payload (ready to send to an LLM).`, }; - const payloadShape = payloadFieldShapes(root, payload); + const payloadShape = payloadFieldShapes(tmpl, TEMPLATE_ATTR_PAYLOAD_REF); if (payloadShape !== undefined) sym.fields = payloadShape; symbols.push(sym); } @@ -1092,7 +1092,7 @@ function buildPromptUnit(tmpl: MetaData, root: MetaRoot): ApiUnitDoc { // a format value, and never on @payloadRef, which types the REQUEST. --- const shape = responseShape(root, tmpl); if (shape) { - const { vo, ref: responseRef, format } = shape; + const { vo, format } = shape; // The extractor generator emits a FLAT `.extractor.ts`. const extractorMod = templateModulePath(`${name}.extractor`); const extract = `extract${symbolBase}`; @@ -1101,7 +1101,7 @@ function buildPromptUnit(tmpl: MetaData, root: MetaRoot): ApiUnitDoc { // document its field shape so an agent sees what `extract` yields, not just a // type name. `vo` is the resolved node; `responseRef` is the authored ref. const responseType = vo.name; - const responseFieldShape = payloadFieldShapes(root, responseRef); + const responseFieldShape = payloadFieldShapes(tmpl, TEMPLATE_ATTR_RESPONSE_REF); const extractSym: ApiSymbol = { name: extract, kind: "extractor", diff --git a/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts b/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts index d5629a252..76046acfb 100644 --- a/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts +++ b/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts @@ -12,6 +12,8 @@ import { type MetaRoot, TYPE_TEMPLATE, TEMPLATE_ATTR_PAYLOAD_REF, + IDENTITY_REFERENCE_ATTR_REFERENCES, + refTarget, REQUIREMENT_ATTR_STATEMENT, OBJECT_SUBTYPE_VALUE, OBJECT_SUBTYPE_ENTITY, @@ -522,7 +524,9 @@ function describeIdentity(id: MetaIdentity): string { if (id.subType === IDENTITY_SUBTYPE_REFERENCE) { // The subType discriminator guarantees the instance is a MetaReferenceIdentity; // narrow to it so we can use its typed `referencesRaw` getter directly. - const raw = (id as MetaReferenceIdentity).referencesRaw; + // Human-facing text prints the reference as the author wrote it (ADR-0059); the + // loaded value is canonical. + const raw = id.authoredAttr(IDENTITY_REFERENCE_ATTR_REFERENCES); if (typeof raw === "string" && raw.length > 0) { return `**Reference:** ${fieldList} → \`${raw}\``; } @@ -677,9 +681,8 @@ export function buildEntityDocData( // template may inherit @payloadRef via extends; resolve rather than work-by-accident. for (const child of root.children()) { if (child.type !== TYPE_TEMPLATE) continue; - const ref = child.attr(TEMPLATE_ATTR_PAYLOAD_REF); - if (typeof ref !== "string") continue; - if (stripPackage(ref) !== entity.name) continue; + // ADR-0059 R7: the payload the loader bound, never a short-name match. + if (refTarget(child, TEMPLATE_ATTR_PAYLOAD_REF) !== entity) continue; // Link to the template's own doc page. The href is derived from the SAME // page-placement function used to write the template page, so it resolves // in BOTH layouts (flat → `./.md`; package → a correct relative path diff --git a/server/typescript/packages/codegen-ts/src/generators/prompt-render-file.ts b/server/typescript/packages/codegen-ts/src/generators/prompt-render-file.ts index 041f192eb..e9b1fe3c5 100644 --- a/server/typescript/packages/codegen-ts/src/generators/prompt-render-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/prompt-render-file.ts @@ -17,7 +17,7 @@ import { TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_FORMAT, - resolveObjectRef, + refTarget, type MetaData, } from "@metaobjectsdev/metadata"; import { findTemplates } from "../templates/find-templates.js"; @@ -65,9 +65,9 @@ export const promptRender = function promptRender(opts?: PromptRenderOpts): Gene // ADR-0039: resolving — a template may inherit @payloadRef via extends. const ref = t.attr(TEMPLATE_ATTR_PAYLOAD_REF); if (typeof ref !== "string" || ref === "") continue; - // ADR-0042: a bare ref resolves in the template's package first. The loader has + // ADR-0059 R7: the value object the loader bound for @payloadRef. The loader has // already enforced the legal target set (object.value / sourceless projection, #210). - const vo = resolveObjectRef(ctx.loadedRoot, ref, t.package ?? t.fileDefaultPackage ?? "").node; + const vo = refTarget(t, TEMPLATE_ATTR_PAYLOAD_REF); if (vo === undefined) continue; const imp = valueObjectImport(ctx.renderContext, vo, outFile, ctx.config.extStyle ?? "js"); imports.push(imp); diff --git a/server/typescript/packages/codegen-ts/src/generators/render-helper-file.ts b/server/typescript/packages/codegen-ts/src/generators/render-helper-file.ts index d53227afc..76ac4885a 100644 --- a/server/typescript/packages/codegen-ts/src/generators/render-helper-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/render-helper-file.ts @@ -22,7 +22,7 @@ import { TYPE_TEMPLATE, TEMPLATE_SUBTYPE_OUTPUT, TEMPLATE_ATTR_PAYLOAD_REF, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import { type EmittedFile, @@ -60,8 +60,8 @@ export const renderHelper = function renderHelper(opts?: RenderHelperOpts): Gene // ADR-0039: resolving — a template may inherit its @* refs/format/kind via extends. const payloadRef = t.attr(TEMPLATE_ATTR_PAYLOAD_REF); if (typeof payloadRef !== "string") continue; - // ADR-0042: a bare @payloadRef resolves in the template's package. - const vo = resolveObjectRef(root, payloadRef, t.package ?? t.fileDefaultPackage ?? "").node; + // ADR-0059 R7: the value object the loader bound for @payloadRef. + const vo = refTarget(t, TEMPLATE_ATTR_PAYLOAD_REF); if (!vo) continue; const path = `${dirPrefix}${t.name}.render.ts`; files.push({ diff --git a/server/typescript/packages/codegen-ts/src/generators/report-doc.ts b/server/typescript/packages/codegen-ts/src/generators/report-doc.ts index 4d80c8de5..e4948b46d 100644 --- a/server/typescript/packages/codegen-ts/src/generators/report-doc.ts +++ b/server/typescript/packages/codegen-ts/src/generators/report-doc.ts @@ -25,14 +25,14 @@ import { describeSegment, isMetaObject, reportFieldTypeName, - reportFrom, + OBJECT_REPORT_ATTR_FROM, reportNotServedReason, reportReadSource, reportShape, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import type { OutputLayout } from "../import-path.js"; -import { docPageHref, docPageNode, effectivePackage } from "../docs-paths.js"; +import { docPageHref, docPageNode } from "../docs-paths.js"; import { isReport } from "../source-detect.js"; /** Inline code for a name this module prints itself (the describers return theirs ready). */ @@ -80,10 +80,8 @@ export function buildReportBlock(report: MetaObject, root: MetaRoot, layout: Out function reportsFrom(entity: MetaObject, root: MetaRoot): MetaObject[] { return root.objects().filter((o) => { if (!isReport(o)) return false; - const from = reportFrom(o); - if (from === undefined) return false; - // A bare @from resolves in the REPORT's package, as `reportShape` resolves it. - const target = resolveObjectRef(root, from, effectivePackage(o) ?? "").node; + // ADR-0059 R7 — the entity the loader bound for @from. + const target = refTarget(o, OBJECT_REPORT_ATTR_FROM); return isMetaObject(target) && target === entity; }); } diff --git a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts index a441b841b..716b0aec7 100644 --- a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts +++ b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts @@ -78,9 +78,10 @@ export interface RequirementTestsOpts { * because it is a good default. */ forceOrphanDelete?: boolean; - /** Turn orphan reconciliation off entirely. Default true — a stub whose - * requirement is gone is drift, and leaving it is how a deleted claim keeps a - * green test. */ + /** `false` keeps every stub in this generator's namespace (`owns`), even an + * untouched one whose requirement is gone: neither this policy nor the run-wide + * sweep deletes it. Default true — a stub whose requirement is gone is drift, and + * leaving it is how a deleted claim keeps a green test. */ reconcileOrphans?: boolean; } @@ -135,9 +136,10 @@ export function requirementTests(opts: RequirementTestsOpts = {}): Generator { assertRequirementTestGrain(grain); const toPath = opts.path ?? (grain === "member" ? defaultMemberPath : defaultPath); // A custom `path` with no custom `owns` leaves the default namespace pointing - // somewhere the generator no longer writes, so reconciliation matches nothing. - // That degrades safely — it can only ever delete less — but silently, and a - // policy nobody can tell isn't running is the failure mode §5 is about. + // somewhere the generator no longer writes, so the policy claims nothing. The + // run-wide sweep still deletes an untouched orphan there; what is lost is `force`, + // reconciliation on a `--generators` run, and — the one worth a warning — + // `reconcileOrphans: false`, which cannot keep files it cannot name. const namespaceUnknown = opts.path !== undefined && opts.owns === undefined; const generator: Generator = { @@ -146,11 +148,12 @@ export function requirementTests(opts: RequirementTestsOpts = {}): Generator { const files: EmittedFile[] = []; const uncovered: string[] = []; - if (namespaceUnknown && opts.reconcileOrphans !== false) { + if (namespaceUnknown && opts.reconcileOrphans === false) { ctx.warn( - `a custom 'path' was supplied without a matching 'owns', so a stub left ` + - `behind by a deleted requirement will NOT be cleaned up. Supply 'owns' ` + - `to describe where 'path' writes, or set reconcileOrphans: false.`, + "a custom 'path' was supplied without a matching 'owns', so reconcileOrphans: " + + "false cannot tell which files are its stubs: a stub left behind by a deleted " + + "requirement is deleted like any other generated file no generator emits any " + + "more (an edited one is refused). Supply 'owns' to describe where 'path' writes.", ); } @@ -215,16 +218,16 @@ export function requirementTests(opts: RequirementTestsOpts = {}): Generator { }; if (opts.target !== undefined) generator.target = opts.target; - if (opts.reconcileOrphans !== false) { - generator.orphanPolicy = { - // With a custom `path` and no `owns`, claim NOTHING rather than guess: the - // default namespace would be a claim over a directory this generator does - // not write to, and a wrong claim deletes another generator's files. - owns: opts.owns ?? (namespaceUnknown - ? () => false - : (relPath) => relPath.startsWith(DEFAULT_STUB_DIR)), - ...(opts.forceOrphanDelete === true && { force: true }), - }; - } + generator.orphanPolicy = { + // With a custom `path` and no `owns`, claim NOTHING rather than guess: the + // default namespace would be a claim over a directory this generator does + // not write to, and a wrong claim deletes another generator's files. + owns: opts.owns ?? (namespaceUnknown + ? () => false + : (relPath) => relPath.startsWith(DEFAULT_STUB_DIR)), + ...(opts.reconcileOrphans === false + ? { keep: true } + : opts.forceOrphanDelete === true && { force: true }), + }; return generator; } diff --git a/server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts b/server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts index 6201a5f0a..143019556 100644 --- a/server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts +++ b/server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts @@ -8,7 +8,12 @@ import { type MetaData, + type MetaObject, type MetaRoot, + TYPE_OBJECT, + isMetaObject, + lookupCanonical, + refTarget, TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_FORMAT, @@ -90,13 +95,11 @@ function templateDescription(t: MetaData): string | undefined { return typeof v === "string" && v.length > 0 ? v : undefined; } -/** Page-placement node for a metadata object resolved by short name, with the - * SAME package-less fallback the Payload cross-link and field hrefs share: when - * the object can't be resolved off the root, fall back to a root-level node so a - * link is still emitted. Keeps every inbound href routing through the one - * `docPageHref(layout, …)` placement. */ -function pageNodeByName(root: MetaRoot | undefined, name: string): DocPageNode { - const obj = root?.findObject(name); +/** Page-placement node for a metadata object, with the SAME package-less fallback + * the Payload cross-link and field hrefs share: when the object did not resolve, + * fall back to a root-level node named `name` so a link is still emitted. Keeps + * every inbound href routing through the one `docPageHref(layout, …)` placement. */ +function pageNodeOf(obj: MetaData | undefined, name: string): DocPageNode { return obj !== undefined ? docPageNode(obj) : { name }; } @@ -114,12 +117,11 @@ export function buildTemplateDocData( const kind: "document" | "email" = isEmail ? TEMPLATE_KIND_EMAIL : TEMPLATE_KIND_DOCUMENT; const payloadRefRaw = template.attr(TEMPLATE_ATTR_PAYLOAD_REF); - // ADR-0042: keep the FULL ref for RESOLUTION (an FQN @payloadRef to another - // package must resolve exactly — the old bare-tail fallback that used to find - // it is gone); strip to the bare name only for DISPLAY labels. - const payloadRefFull = - typeof payloadRefRaw === "string" && payloadRefRaw.length > 0 ? payloadRefRaw : "unknown"; - const payloadName = payloadRefFull !== "unknown" ? stripPackage(payloadRefFull) : "unknown"; + // ADR-0059 R7: the payload VO is the target the loader bound for @payloadRef; the + // ref is stripped to its bare name only for DISPLAY labels. + const payloadVo = refTarget(template, TEMPLATE_ATTR_PAYLOAD_REF); + const payloadName = + typeof payloadRefRaw === "string" && payloadRefRaw.length > 0 ? stripPackage(payloadRefRaw) : "unknown"; const requiredTags = attrStringList(template.attr(TEMPLATE_ATTR_REQUIRED_TAGS)); const maxChars = readMaxChars(template.attr(TEMPLATE_ATTR_MAX_CHARS)); @@ -157,7 +159,7 @@ export function buildTemplateDocData( const payloadLink = docPageHref( layout, docPageNode(template), - pageNodeByName(root, payloadName), + pageNodeOf(payloadVo, payloadName), ); const data: TemplateDocData = { @@ -190,7 +192,7 @@ export function buildTemplateDocData( layout, template, payloadName, - payloadRefFull, + payloadVo: payloadVo !== undefined && isMetaObject(payloadVo) ? payloadVo : undefined, // Document: the single @textRef (unlabeled). Email: one labeled part each. refs: parts !== undefined @@ -226,9 +228,9 @@ interface BuildSectionArgs { template: MetaData; /** Bare payload name — DISPLAY only (labels, owner names). */ payloadName: string; - /** Full @payloadRef (FQN for a cross-package payload, bare for same-package) — - * used for RESOLUTION so a cross-package payload tree still resolves (ADR-0042). */ - payloadRefFull: string; + /** The payload VO the loader bound for @payloadRef (ADR-0059 R7), or undefined + * when it did not resolve. */ + payloadVo: MetaObject | undefined; refs: SourceRefSpec[]; } @@ -243,22 +245,17 @@ interface BuildSectionArgs { * resolved, so the caller omits the section entirely. */ function buildTemplateSourceSection(args: BuildSectionArgs): string | undefined { - const { provider, root, layout, template, payloadName, payloadRefFull, refs } = args; - // ADR-0042: resolve the payload tree from the FULL ref — an FQN cross-package - // @payloadRef resolves exactly; a bare same-package ref resolves in the - // template's package. - const tree: AnnotatePayloadField[] = buildEnrichedPayloadTree( - root, - payloadRefFull, - template.package ?? template.fileDefaultPackage ?? "", - ); + const { provider, root, layout, template, payloadName, payloadVo, refs } = args; + const tree: AnnotatePayloadField[] = buildEnrichedPayloadTree(payloadVo); const fromNode = docPageNode(template); // Layout-aware field href: route through the SAME docPageHref the Payload // cross-link uses, so the link lands on the owner VO's REAL page in BOTH // layouts (flat → `./Owner.md`, package → `..//Owner.md`). - const fieldHref = (owner: string, name: string): string => - `${docPageHref(layout, fromNode, pageNodeByName(root, owner))}#${fieldAnchorSlug(name)}`; + const fieldHref = (owner: string, name: string, ownerKey?: string): string => { + const ownerObj = ownerKey !== undefined ? lookupCanonical(root, ownerKey, { ownerType: TYPE_OBJECT }) : undefined; + return `${docPageHref(layout, fromNode, pageNodeOf(ownerObj, owner))}#${fieldAnchorSlug(name)}`; + }; const resolvePartialHref = makePartialHrefResolver(root, layout, fromNode); const isMultipart = refs.length > 1 || refs.some((r) => r.label !== undefined); diff --git a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts index 519f1ad4c..dcb05a65a 100644 --- a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts +++ b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts @@ -20,68 +20,49 @@ import { type MetaObject, - type MetaRoot, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, + isMetaObject, + refTarget, stripPackage, - resolveObjectRef, } from "@metaobjectsdev/metadata"; import { isFieldRequired, neutralTypeStr } from "./docs-data-builder.js"; import type { AnnotatePayloadField } from "./template-source-annotate.js"; -// ADR-0041/0042: FQN-exact object resolution — a "::"-qualified @objectRef binds the exact -// package (never a bare-tail fallback), so a same-named object.value in another package -// can't be wrongly bound (which would attach the wrong owner/type/link to a doc node); a -// bare ref binds the referrer's package FIRST, else a root-level object. -// Mirrors render-helper.ts's findObject + the CLI payload-field-tree resolver. -function findObject(root: MetaRoot, ref: string, referrerPkg = ""): MetaObject | undefined { - return resolveObjectRef(root, ref, referrerPkg).node as MetaObject | undefined; -} - /** - * Walk the payload VO `voName` into an enriched `AnnotatePayloadField[]`. Each - * node carries `owner` (the short name of the VO that DECLARES the field — the - * link target), `type` (the neutral logical type), and `required`. Object-ref - * fields recurse into their referenced VO with `owner` switched to that nested - * VO; a `seen` set guards a (pathological) reference cycle (returns `[]` rather - * than recursing forever). + * Walk the payload VO `vo` into an enriched `AnnotatePayloadField[]`. Each + * node carries `owner` (the short name of the VO that DECLARES the field), + * `ownerKey` (its resolution key — the link target), `type` (the neutral logical type), and `required`. Object-ref + * fields recurse into the VO the loader bound for their `@objectRef` (ADR-0059 R7), + * with `owner` switched to that nested VO; a `seen` set guards a (pathological) + * reference cycle (returns `[]` rather than recursing forever). * - * Returns `[]` when the VO can't be resolved — the annotator then leaves the - * referencing variables unresolved (flagged "not on payload"), never throws. + * Returns `[]` when the VO is undefined (the ref did not resolve) — the annotator + * then leaves the referencing variables unresolved (flagged "not on payload"), + * never throws. */ export function buildEnrichedPayloadTree( - root: MetaRoot, - voName: string, - referrerPkg = "", - seen: ReadonlySet = new Set(), + vo: MetaObject | undefined, + seen: ReadonlySet = new Set(), ): AnnotatePayloadField[] { - if (seen.has(voName)) return []; - const vo: MetaObject | undefined = findObject(root, voName, referrerPkg); - if (vo === undefined) return []; + if (vo === undefined || seen.has(vo)) return []; const owner = stripPackage(vo.name); - // ADR-0042: a nested @objectRef resolves in the FIELD's own declaring package - // (fallback below), which differs from this VO's when the field is inherited via - // extends from an abstract VO in another package (the bare ref was authored there). - const voPkg = vo.package ?? vo.fileDefaultPackage ?? ""; - const nextSeen = new Set(seen).add(voName); + const ownerKey = vo.resolutionKey(); + const nextSeen = new Set(seen).add(vo); const out: AnnotatePayloadField[] = []; for (const f of vo.fields()) { const node: AnnotatePayloadField = { name: f.name, owner, + ownerKey, type: neutralTypeStr(f), required: isFieldRequired(f), ...(f.resolvedIsArray() ? { isArray: true } : {}), }; if (f.subType === FIELD_SUBTYPE_OBJECT) { - const ref = f.attr(FIELD_ATTR_OBJECT_REF); - if (typeof ref === "string" && ref.length > 0) { - // Owner switches to the nested VO for its fields (the recursion below - // re-derives `owner` from the resolved VO's own name). Pass the FULL ref - // (not stripPackage), resolved package-locally against the FIELD's declaring - // package (ADR-0042 — the bare ref was authored there, not on the concrete VO). - const fieldPkg = f.parent?.package ?? f.parent?.fileDefaultPackage ?? voPkg; - node.fields = buildEnrichedPayloadTree(root, ref, fieldPkg, nextSeen); + const target = refTarget(f, FIELD_ATTR_OBJECT_REF); + if (target !== undefined && isMetaObject(target)) { + node.fields = buildEnrichedPayloadTree(target, nextSeen); } } out.push(node); diff --git a/server/typescript/packages/codegen-ts/src/generators/template-source-annotate.ts b/server/typescript/packages/codegen-ts/src/generators/template-source-annotate.ts index 8977a6cf8..127f14a37 100644 --- a/server/typescript/packages/codegen-ts/src/generators/template-source-annotate.ts +++ b/server/typescript/packages/codegen-ts/src/generators/template-source-annotate.ts @@ -30,11 +30,13 @@ import { fieldAnchorSlug } from "./field-anchor.js"; * An enriched payload-field node. Structurally a verify `PayloadField` (so the * shared resolver walks it), plus the per-field doc metadata the annotator needs * to emit a `ResolvedField` + link: `owner` (the VO that declares the field), - * `type`, and `required`. Container fields (object / array-of-object) carry + * `ownerKey` (that VO's resolution key, which names its page exactly), `type`, and + * `required`. Container fields (object / array-of-object) carry * `fields` whose nodes are owned by the nested VO. */ export interface AnnotatePayloadField extends PayloadField { owner: string; + ownerKey?: string; type: string; required: boolean; fields?: AnnotatePayloadField[]; @@ -88,7 +90,7 @@ export interface AnnotateOptions { * (the SAME `docPageHref(layout, …)` the Payload cross-link uses) so the link * resolves under package layout too. */ - fieldHref?: (owner: string, name: string) => string; + fieldHref?: (owner: string, name: string, ownerKey?: string) => string; } // A Mustache parse token: [type, value, start, end, subTokens?, closeStart?, ...]. @@ -142,7 +144,7 @@ export function annotateTemplate( const hit = resolveTemplateVariable(stack, path); if (!hit) return undefined; const field = toResolvedField(hit); - return { field, href: buildFieldHref(field.owner, field.name) }; + return { field, href: buildFieldHref(field.owner, field.name, hit.ownerKey) }; } // Build a variable token ({{x}} or {{&x}}/{{{x}}}). The implicit iterator diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index ea16d453e..3b8be78ac 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -86,6 +86,7 @@ export type { MergeStrategy, BaselineMode, DecideAndWriteOpts, + HandEdits, } from "./overwrite-policy.js"; export { decideAndWrite, GitMissingError, WRITE_STATUSES } from "./overwrite-policy.js"; // The committed half of `.gen-state`. Exported for `verify --codegen`, which needs @@ -408,7 +409,7 @@ export type { ReconcileOrphansArgs, OrphanPolicy, } from "./reconcile-orphans.js"; -export { sweepOrphans } from "./orphan-sweep.js"; +export { sweepOrphans, orphanVerdict } from "./orphan-sweep.js"; export type { OrphanJob, SweepOrphansArgs, diff --git a/server/typescript/packages/codegen-ts/src/model-walk.ts b/server/typescript/packages/codegen-ts/src/model-walk.ts index 15298379f..eaf7a5727 100644 --- a/server/typescript/packages/codegen-ts/src/model-walk.ts +++ b/server/typescript/packages/codegen-ts/src/model-walk.ts @@ -13,14 +13,7 @@ // means knowing a rule the engine already owns. Each is exported from the package root. import type { MetaField, MetaObject } from "@metaobjectsdev/metadata"; -import { isMetaObject, resolveObjectRef } from "@metaobjectsdev/metadata"; -import { effectivePackage } from "./docs-paths.js"; - -/** The object that declares `field` — its parent node, when that is an object. */ -function owningObject(field: MetaField): MetaObject | undefined { - const parent = field.parent; - return parent !== undefined && isMetaObject(parent) ? parent : undefined; -} +import { FIELD_ATTR_OBJECT_REF, isMetaObject, refTarget } from "@metaobjectsdev/metadata"; /** * The object a `field.object`'s `@objectRef` points at, resolved the way the loader resolves @@ -35,10 +28,6 @@ function owningObject(field: MetaField): MetaObject | undefined { * an inherited field is the base, where the bare ref was written. */ export function objectRefTarget(field: MetaField): MetaObject | undefined { - const ref = field.objectRef; - if (ref === undefined || ref.length === 0) return undefined; - const owner = owningObject(field); - const referrerPkg = (owner !== undefined ? effectivePackage(owner) : undefined) ?? ""; - const node = resolveObjectRef(field.root(), ref, referrerPkg).node; + const node = refTarget(field, FIELD_ATTR_OBJECT_REF); return node !== undefined && isMetaObject(node) ? node : undefined; } diff --git a/server/typescript/packages/codegen-ts/src/orphan-sweep.ts b/server/typescript/packages/codegen-ts/src/orphan-sweep.ts index e03b5a6c3..02d8399fd 100644 --- a/server/typescript/packages/codegen-ts/src/orphan-sweep.ts +++ b/server/typescript/packages/codegen-ts/src/orphan-sweep.ts @@ -65,6 +65,8 @@ export interface SweepOrphansResult { /** Hand-edited orphans deleted because their policy set `force`. Separate from * `removed` so the caller can say the louder thing about them. */ readonly forced: string[]; + /** Which job deleted each forced file, so the caller can say why. */ + readonly forcedBy: ReadonlyMap; } /** gen-state keys carry the platform separator; an `owns` predicate is written @@ -74,14 +76,47 @@ function toPosix(p: string): string { return sep === "/" ? p : p.split(sep).join("/"); } +function ownedByJob(job: OrphanJob, projectRoot: string, relPath: string): boolean { + const inTarget = relative(job.writeOutDir, resolve(projectRoot, relPath)); + // "" is the directory itself; a `..` prefix or an absolute result means + // the path is outside this generator's output entirely. Neither is + // something the predicate should get a say in. + if (inTarget === "" || inTarget.startsWith("..") || isAbsolute(inTarget)) { + return false; + } + return job.policy.owns(toPosix(inTarget)); +} + +// A `keep` policy decides nothing itself; it takes its namespace out of every other +// job's reach, the run-wide jobs included. +function keptByAny(keepJobs: readonly OrphanJob[], projectRoot: string, relPath: string): boolean { + return keepJobs.some((job) => ownedByJob(job, projectRoot, relPath)); +} + +/** What the sweep does with a path it recorded writing and no generator emits any more. + * `kept`: a `keep` namespace owns it, so it is never touched. `force`: the first job + * that owns it deletes it even when hand-edited. Neither: an untouched copy is deleted + * and an edited one refused. Exported so `verify --codegen` classifies orphans with the + * sweep's own predicate. */ +export function orphanVerdict( + jobs: readonly OrphanJob[], + projectRoot: string, + relPath: string, +): { readonly kept: boolean; readonly force: boolean } { + const keepJobs = jobs.filter((job) => job.policy.keep === true); + if (keptByAny(keepJobs, projectRoot, relPath)) return { kept: true, force: false }; + const owner = jobs.find((job) => job.policy.keep !== true && ownedByJob(job, projectRoot, relPath)); + return { kept: false, force: owner?.policy.force === true }; +} + export function sweepOrphans(args: SweepOrphansArgs): SweepOrphansResult { if (args.jobs.length === 0) { - return { removed: [], refused: [], forced: [], refusedBy: new Map() }; + return { removed: [], refused: [], forced: [], refusedBy: new Map(), forcedBy: new Map() }; } const previouslyGenerated = listGeneratedPaths(args.genStateDir); if (previouslyGenerated.length === 0) { - return { removed: [], refused: [], forced: [], refusedBy: new Map() }; + return { removed: [], refused: [], forced: [], refusedBy: new Map(), forcedBy: new Map() }; } // NOT normalized: both sides of this comparison are produced by @@ -98,21 +133,18 @@ export function sweepOrphans(args: SweepOrphansArgs): SweepOrphansResult { const forced = new Set(); const forget = new Set(); const refusedBy = new Map(); + const forcedBy = new Map(); + + const jobOwns = (job: OrphanJob, relPath: string): boolean => ownedByJob(job, args.projectRoot, relPath); + const kept = (relPath: string): boolean => keptByAny(keepJobs, args.projectRoot, relPath); + const keepJobs = args.jobs.filter((job) => job.policy.keep === true); for (const job of args.jobs) { + if (job.policy.keep === true) continue; const decision = reconcileOrphans({ previouslyGenerated, emitted, - owns: (relPath) => { - const inTarget = relative(job.writeOutDir, resolve(args.projectRoot, relPath)); - // "" is the directory itself; a `..` prefix or an absolute result means - // the path is outside this generator's output entirely. Neither is - // something the predicate should get a say in. - if (inTarget === "" || inTarget.startsWith("..") || isAbsolute(inTarget)) { - return false; - } - return job.policy.owns(toPosix(inTarget)); - }, + owns: (relPath) => !kept(relPath) && jobOwns(job, relPath), exists: (relPath) => existsSync(resolve(args.projectRoot, relPath)), // The committed hash manifest, NOT the snapshot body — the bodies are // gitignored, so on a fresh clone a body-based check could never prove a @@ -147,6 +179,7 @@ export function sweepOrphans(args: SweepOrphansArgs): SweepOrphansResult { if (removed.has(relPath) || forced.has(relPath) || refused.has(relPath)) continue; if (job.policy.force === true) { forced.add(relPath); + forcedBy.set(relPath, job.generatorName); forget.add(relPath); } else { // Deliberately NOT forgotten: the record is what makes the refusal @@ -174,5 +207,6 @@ export function sweepOrphans(args: SweepOrphansArgs): SweepOrphansResult { refused: [...refused].sort(), forced: [...forced].sort(), refusedBy, + forcedBy, }; } diff --git a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts index ed3a29b58..a1b0d45b5 100644 --- a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts +++ b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts @@ -114,8 +114,23 @@ export interface DecideAndWriteOpts { outputRelPath?: string; /** First-time-existing-file behavior. */ baseline?: BaselineMode; + /** + * What a hand edit to a file `gen` recorded writing gets: `"keep"` (the default) merges + * it or refuses the file; `"overwrite"` replaces the file with fresh output and + * re-baselines its record, reporting `discardedEdit` so the caller can name it. A file + * with no record of being generated is never overwritten by this setting. + */ + handEdits?: HandEdits; } +/** + * The per-generator hand-edit setting (`Generator.handEdits`), the same name and values in + * every port. `"keep"`, the default, protects an edit to a generated file (merged, or the + * file refused by name); `"overwrite"` discards it: the file is regenerated and its record + * re-baselined, and an edited file no generator emits any more is deleted. + */ +export type HandEdits = "keep" | "overwrite"; + export interface WriteResult { path: string; status: WriteStatus; @@ -123,6 +138,8 @@ export interface WriteResult { * naming what the user must do. A refusal nobody can act on gets the file * deleted by hand, which is the outcome refusing exists to prevent. */ conflictHint?: string; + /** True when a hand edit was replaced with fresh output under `handEdits: "overwrite"`. */ + discardedEdit?: boolean; } const HASHES_FILE = ".hashes.json"; @@ -133,14 +150,13 @@ function sha256(content: string | Buffer): string { return createHash("sha256").update(content).digest("hex"); } -function loadHashes(genStateDir: string): HashesFile { +function readRawManifest(genStateDir: string): Record { const f = join(genStateDir, HASHES_FILE); if (!existsSync(f)) return {}; try { - const txt = readFileSync(f, "utf-8"); - const parsed: unknown = JSON.parse(txt); + const parsed: unknown = JSON.parse(readFileSync(f, "utf-8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as HashesFile; + return parsed as Record; } return {}; } catch { @@ -148,14 +164,37 @@ function loadHashes(genStateDir: string): HashesFile { } } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function entryHash(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (isRecord(value) && typeof value.hash === "string") return value.hash; + return undefined; +} + +function loadHashes(genStateDir: string): HashesFile { + const hashes: HashesFile = {}; + for (const [key, value] of Object.entries(readRawManifest(genStateDir))) { + const hash = entryHash(value); + if (hash !== undefined) hashes[key] = hash; + } + return hashes; +} + function saveHashes(genStateDir: string, hashes: HashesFile): void { mkdirSync(genStateDir, { recursive: true }); + const raw = readRawManifest(genStateDir); // Keys SORTED, because this file is committed. Insertion order would make the // diff — and any merge conflict between two people who both regenerated — // depend on which entity happened to generate first, which is noise nobody can // review. - const sorted: HashesFile = {}; - for (const key of Object.keys(hashes).sort()) sorted[key] = hashes[key]!; + const sorted: Record = {}; + for (const key of Object.keys(hashes).sort()) { + const existing = raw[key]; + sorted[key] = isRecord(existing) ? { ...existing, hash: hashes[key] } : hashes[key]; + } writeFileSync(join(genStateDir, HASHES_FILE), JSON.stringify(sorted, null, 2) + "\n"); } @@ -483,6 +522,7 @@ type WriteCase = | { kind: "skip" } | { kind: "no-snapshot-fresh-unchanged" } | { kind: "no-snapshot-fresh-overwrite" } + | { kind: "hand-edit-overwrite" } | { kind: "no-snapshot-unchanged" } | { kind: "no-snapshot-pristine-overwrite" } | { kind: "no-snapshot-refused"; hasRecord: boolean } @@ -518,6 +558,18 @@ function classifyWrite( ? undefined : storedSnapshot; + // `handEdits: "overwrite"`: an edit to a file we recorded writing is discarded, not + // merged or refused. "Recorded" is the committed hash, or a local snapshot body; a file + // with neither is not ours, and the default rules below still refuse it. + if (opts.handEdits === "overwrite" && current !== content) { + const recorded = readGeneratedHash(genStateDir, relPath); + const edited = + recorded !== undefined + ? recorded !== sha256(current) + : snapshot !== undefined && snapshot.text !== current; + if (edited) return { kind: "hand-edit-overwrite" }; + } + // First-time regen on a pre-existing file (no snapshot) — caveat 3. `baseline` // only ever applies here: BaselineMode's own doc comment says so ("When // .gen-state is absent but the file exists"), and a snapshot body — handled @@ -611,6 +663,7 @@ export function previewWriteStatus( return "unchanged"; case "no-snapshot-fresh-overwrite": case "no-snapshot-pristine-overwrite": + case "hand-edit-overwrite": return "overwrite"; case "no-snapshot-refused": return "refused"; @@ -659,6 +712,11 @@ export function decideAndWrite( advanceSnapshot(genStateDir, relPath, content); return { path, status: "overwrite" }; + case "hand-edit-overwrite": + writeFileSync(path, content); + advanceSnapshot(genStateDir, relPath, content); + return { path, status: "overwrite", discardedEdit: true }; + case "no-snapshot-refused": // Deliberately does NOT advance the snapshot or the hash: a refusal that // records the current content would make the file look pristine next run and diff --git a/server/typescript/packages/codegen-ts/src/pk-resolver.ts b/server/typescript/packages/codegen-ts/src/pk-resolver.ts index fbdec56ff..7129a0e82 100644 --- a/server/typescript/packages/codegen-ts/src/pk-resolver.ts +++ b/server/typescript/packages/codegen-ts/src/pk-resolver.ts @@ -30,27 +30,34 @@ export interface PkInfo { export function buildPkMap(root: MetaRoot): Map { const result = new Map(); for (const obj of root.objects()) { - // primaryIdentity() resolves the primary identity across the super-chain. - const primary = obj.primaryIdentity(); - if (!primary) continue; - // attr() (effective) not ownAttr() — @fields/@generation can be inherited when the - // identity node-level `extends` a base identity without restating them (#56). - const fields = primary.attr(IDENTITY_ATTR_FIELDS); - if (!Array.isArray(fields) && typeof fields !== "string") continue; - const fieldsList = Array.isArray(fields) ? fields : [fields]; - if (fieldsList.length === 0) continue; - const pkFieldName = String(fieldsList[0]); - // findField() resolves the field across the super-chain (handles extends:). - const pkField = obj.findField(pkFieldName); - const fieldSubType = pkField?.subType ?? FIELD_SUBTYPE_LONG; // sane default - const generation = primary.attr(IDENTITY_ATTR_GENERATION); - const info: PkInfo = { fieldName: pkFieldName, fieldSubType }; - if (typeof generation === "string") info.generation = generation; - result.set(obj.name, info); + const info = pkInfoOf(obj); + if (info !== undefined) result.set(obj.name, info); } return result; } +/** One object's PK info, read from the object itself. A reference consumer that holds the + * bound target (ADR-0059 R7) reads this rather than looking a name up in the map. */ +export function pkInfoOf(obj: MetaObject): PkInfo | undefined { + // primaryIdentity() resolves the primary identity across the super-chain. + const primary = obj.primaryIdentity(); + if (!primary) return undefined; + // attr() (effective) not ownAttr() — @fields/@generation can be inherited when the + // identity node-level `extends` a base identity without restating them (#56). + const fields = primary.attr(IDENTITY_ATTR_FIELDS); + if (!Array.isArray(fields) && typeof fields !== "string") return undefined; + const fieldsList = Array.isArray(fields) ? fields : [fields]; + if (fieldsList.length === 0) return undefined; + const pkFieldName = String(fieldsList[0]); + // findField() resolves the field across the super-chain (handles extends:). + const pkField = obj.findField(pkFieldName); + const fieldSubType = pkField?.subType ?? FIELD_SUBTYPE_LONG; // sane default + const generation = primary.attr(IDENTITY_ATTR_GENERATION); + const info: PkInfo = { fieldName: pkFieldName, fieldSubType }; + if (typeof generation === "string") info.generation = generation; + return info; +} + /** Map a field subType to the generated TS scalar type for keys/values. */ export function subTypeToTsType(subType: string): "number" | "boolean" | "string" { return subType === "long" || subType === "int" || subType === "short" || subType === "byte" diff --git a/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts b/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts index a05b07ff8..9588d0a1f 100644 --- a/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts +++ b/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts @@ -16,8 +16,8 @@ import { isMetaRoot, isReadOnlySource, isWritableSource, - reportFrom, - resolveObjectRef, + OBJECT_REPORT_ATTR_FROM, + refTarget, SOURCE_KIND_VIEW, TYPE_FIELD, TYPE_IDENTITY, @@ -27,7 +27,7 @@ import { physicalNamingOf, } from "@metaobjectsdev/metadata"; import { isProjection, isWriteThrough } from "./projection-detector.js"; -import { extractViewSpec, packageOf, projectionViewSource, refNamedOwner } from "./extract-view-spec.js"; +import { extractViewSpec, projectionViewSource, refNamedOwner } from "./extract-view-spec.js"; import { extractReportSpec } from "./extract-report-spec.js"; import { emitReportViewDdl } from "./report-ddl-emit.js"; import type { ReportViewSpec } from "./report-spec.js"; @@ -359,10 +359,9 @@ function collectSqlDependsOn( const t = joinTables[owner.resolutionKey()]; if (t !== undefined) tables.add(t); } - // A report's `@from` table (resolved package-locally, as reportShape does). + // A report's `@from` table (ADR-0059 R7: the entity the loader bound for @from). if (isReport(host)) { - const fromName = reportFrom(host); - const from = fromName === undefined ? undefined : resolveObjectRef(root, fromName, packageOf(host)).node; + const from = refTarget(host, OBJECT_REPORT_ATTR_FROM); const t = from === undefined ? undefined : joinTables[from.resolutionKey()]; if (t !== undefined) tables.add(t); } diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts index d9cb9aff7..17139e2d5 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts @@ -21,6 +21,8 @@ import { type MetaObject, type MetaRoot, type ReportField, + REPORTING_ATTR_VIA, + OBJECT_REPORT_ATTR_SPINE, } from "@metaobjectsdev/metadata"; import { columnNameFromField } from "../naming.js"; import { hasWritableRdbSource } from "../source-detect.js"; @@ -36,6 +38,7 @@ import { type Path, } from "./extract-view-spec.js"; import { + authoredText, dimensionOfField, ratioOperand, resolveAggregate, @@ -124,7 +127,8 @@ function spinePathOf( root: MetaRoot, ctx: ExtractContext, ): Path { - const spine = reportSpine(report) ?? ""; + // Refusals quote @spine as the author wrote it (ADR-0059). + const spine = authoredText(report, OBJECT_REPORT_ATTR_SPINE) ?? reportSpine(report) ?? ""; const where = `report '${report.name}':`; const path = walkViaPath([from.name, ...hops].join("."), root, packageOf(from), ctx); if (path.length !== hops.length) { @@ -200,7 +204,7 @@ export function extractReportSpec(report: MetaObject, root: MetaRoot, ctx: Extra // Rule R9, which the loader enforces: under @spine every listed dimension is reached through it. // A tree built past the loader is refused here rather than given a join the view cannot place. const notThroughSpine = (where: string, why: string): Error => - new Error(`${where} is not reached through @spine '${reportSpine(report) ?? ""}': ${why}.`); + new Error(`${where} is not reached through @spine '${authoredText(report, OBJECT_REPORT_ATTR_SPINE) ?? reportSpine(report) ?? ""}': ${why}.`); // One path per LISTED dimension that has @via (Table F); an unlisted dimension adds no join. const pathOf = new Map(); @@ -216,7 +220,7 @@ export function extractReportSpec(report: MetaObject, root: MetaRoot, ctx: Extra if (spineHops !== undefined && !spineHops.every((h, i) => hops[i] === h)) { throw notThroughSpine( where, - `its @via '${via}' does not begin with the spine's hops, so the view has no join to place it on`, + `its @via '${authoredText(dim, REPORTING_ATTR_VIA) ?? via}' does not begin with the spine's hops, so the view has no join to place it on`, ); } }; diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts index 8a81c3116..8fd32e963 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts @@ -5,6 +5,9 @@ import { TYPE_ORIGIN, TYPE_RELATIONSHIP, isMetaObject, + TYPE_OBJECT, + lookupCanonical, + splitChildTail, isReadOnlySource, SOURCE_ROLE_PRIMARY, SOURCE_ATTR_SCHEMA, @@ -53,6 +56,7 @@ import { findReferencesBetween, resolveRelationshipReference, resolveObjectRef, + refTarget, type AggregateFunction, type ReferenceLookup, } from "@metaobjectsdev/metadata"; @@ -353,19 +357,25 @@ function findReferenceHop(obj: MetaData, name: string): MetaData | undefined { function resolveHop( obj: MetaData, name: string, -): { hop: MetaData; targetName: string; cardinality: "one" | "many" } | undefined { +): { hop: MetaData; target: MetaObject | undefined; cardinality: "one" | "many" } | undefined { + // ADR-0059 R3/R7 — a hop's target is the object its reference attribute binds to, resolved + // in the package of the node that declares that attribute, never the walked entity's. const rel = findRelationship(obj, name); if (rel) { - const targetName = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined; - if (!targetName) return undefined; + if (!rel.attr(RELATIONSHIP_ATTR_OBJECT_REF)) return undefined; + const target = refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF); const cardAttr = rel.attr(RELATIONSHIP_ATTR_CARDINALITY) as string | undefined; - return { hop: rel, targetName, cardinality: cardAttr === CARDINALITY_ONE ? "one" : "many" }; + return { + hop: rel, + target: isMetaObject(target) ? target : undefined, + cardinality: cardAttr === CARDINALITY_ONE ? "one" : "many", + }; } const ref = findReferenceHop(obj, name); if (ref) { - const targetName = ref.attr(IDENTITY_REFERENCE_ATTR_REFERENCES) as string | undefined; - if (!targetName) return undefined; - return { hop: ref, targetName, cardinality: "one" }; + if (!ref.attr(IDENTITY_REFERENCE_ATTR_REFERENCES)) return undefined; + const target = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); + return { hop: ref, target: isMetaObject(target) ? target : undefined, cardinality: "one" }; } return undefined; } @@ -400,7 +410,7 @@ function resolveHopReference( } // ADR-0039: resolving — @sourceRefField may be inherited via extends. const sourceRefField = hop.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; - const matching = resolveRelationshipReference(holder, hopName, target.name, sourceRefField); + const matching = resolveRelationshipReference(holder, hopName, target, sourceRefField); if (matching) { return { holder, other: target, referenceIdentity: matching }; } @@ -416,7 +426,7 @@ function resolveHopReference( export function hopReferenceIdentity(holder: MetaObject, hopName: string, root: MetaRoot): MetaReferenceIdentity | undefined { const resolved = resolveHop(holder, hopName); if (!resolved) return undefined; - const target = resolveEntityRef(root, resolved.targetName, packageOf(holder)); + const target = resolved.target; if (!target) return undefined; const ref = resolveHopReference(holder, resolved.hop, hopName, target); if (Array.isArray(ref)) return ref.length === 1 ? ref[0]!.referenceIdentity : undefined; @@ -544,15 +554,12 @@ export function projectionViewRole(projection: MetaObject): string | undefined { export function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | undefined { const ref = (node as { superRef?: string }).superRef; if (ref === undefined) return undefined; - // The entity portion is everything before the FIRST "." (packages use "::", the - // member path uses "."). - const dot = ref.indexOf("."); - if (dot <= 0) return undefined; - const entityRef = ref.slice(0, dot); - // An FQN `extends` binds the EXACT package's entity (#244); a bare ref keeps the prior - // findObject behavior (a projection extends its base by bare name in the same package). - if (entityRef.includes("::")) return resolveEntityRef(root, entityRef, ""); - return root.findObject(entityRef) ?? undefined; + // The loader has rewritten `extends` to its canonical spelling (ADR-0059 R5), so the + // owner is looked up exactly, with no referrer and no short-name match (R7). + const { owner, tail } = splitChildTail(ref); + if (tail === "") return undefined; + const found = lookupCanonical(root, owner, { ownerType: TYPE_OBJECT }); + return isMetaObject(found) ? found : undefined; } /** Effective package of an object, taken from its resolution key ("::"). */ @@ -687,26 +694,24 @@ function primaryKeyColumn(entity: MetaObject, ctx: ExtractContext): string | und } /** - * Walk a `@via` dotted path to its terminal (related) entity name. any/all carry no + * Walk a `@via` dotted path to its terminal (related) entity. any/all carry no * `@of` to name the aggregated entity, so it is derived from the last `@via` hop. * Returns undefined if any hop fails to resolve (a prior loader error already fired). */ -function viaTerminalEntity(via: string, root: MetaRoot, referrerPkg: string): string | undefined { +function viaTerminalEntity(via: string, root: MetaRoot, referrerPkg: string): MetaObject | undefined { const segments = via.split("."); const rawEntity = segments[0]; if (!rawEntity) return undefined; let currentObj = resolveEntityRef(root, rawEntity, referrerPkg); if (!currentObj) return undefined; - let terminal = currentObj.resolutionKey(); for (const relName of segments.slice(1)) { const resolved = resolveHop(currentObj, relName); if (!resolved) return undefined; - const target = resolveEntityRef(root, resolved.targetName, packageOf(currentObj)); + const target = resolved.target; if (!target) return undefined; currentObj = target; - terminal = target.resolutionKey(); } - return terminal; + return currentObj; } /** @@ -855,11 +860,7 @@ export function walkViaPath(via: string, root: MetaRoot, referrerPkg: string, ct // a traversed relationship/reference may inherit its target via extends. const resolved = resolveHop(currentObj, relName); if (!resolved) break; - const { hop, targetName, cardinality } = resolved; - // @objectRef/@references may be package-qualified ("pkg::Entity"); resolve it - // package-aware relative to the hop's source entity (the loader qualifies a - // same-package ref even when authored bare), so the join binds the exact target. - const target = resolveEntityRef(root, targetName, packageOf(currentObj)); + const { hop, target, cardinality } = resolved; if (!target) break; // #368: two identity.reference declarations onto the same target are legal @@ -1153,11 +1154,10 @@ function buildSelectSpec( // hop, and @filter is the (required) quantified predicate. const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined; if (!via) continue; - const relatedName = viaTerminalEntity(via, root, projPkg); - if (!relatedName) continue; - const relatedEntity = resolveEntityRef(root, relatedName, projPkg); - const sourceAlias = findAliasInTree(joinTree, relatedName); - if (!relatedEntity || sourceAlias === undefined) continue; + const relatedEntity = viaTerminalEntity(via, root, projPkg); + if (!relatedEntity) continue; + const sourceAlias = findAliasInTree(joinTree, relatedEntity.resolutionKey()); + if (sourceAlias === undefined) continue; const joinedPk = primaryKeyColumn(relatedEntity, ctx); if (joinedPk === undefined) continue; const pred = filterAttr !== undefined @@ -1191,19 +1191,15 @@ function buildSelectSpec( // caller that reached codegen without loading, never a silent-drop path. const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA) as string | undefined; if (!via) continue; - // ADR-0039: resolving — @objectRef may be inherited via extends. - const objectRef = field.attr(FIELD_ATTR_OBJECT_REF) as string | undefined; - if (!objectRef) continue; - const relatedName = viaTerminalEntity(via, root, projPkg); - if (!relatedName) continue; - const relatedEntity = resolveEntityRef(root, relatedName, projPkg); + const relatedEntity = viaTerminalEntity(via, root, projPkg); if (!relatedEntity) continue; const sourceAlias = findAliasInTree(joinTree, relatedEntity.resolutionKey()); if (sourceAlias === undefined) continue; const joinedPk = primaryKeyColumn(relatedEntity, ctx); if (joinedPk === undefined) continue; - // ADR-0042 — a bare @objectRef resolves in the projection's package. - const vo = resolveObjectRef(root, objectRef, projPkg).node; + // ADR-0059 R7 — the VO the loader bound for @objectRef, resolved where it was declared. + // ADR-0039: resolving — @objectRef may be inherited via extends. + const vo = refTarget(field, FIELD_ATTR_OBJECT_REF); if (!vo) continue; // ADR-0039: resolving — a value object may inherit members via extends, and the // terminal entity may inherit fields. The VO's member list IS the exposure: a diff --git a/server/typescript/packages/codegen-ts/src/projection/report-resolve.ts b/server/typescript/packages/codegen-ts/src/projection/report-resolve.ts index 0c855a527..2d64e8724 100644 --- a/server/typescript/packages/codegen-ts/src/projection/report-resolve.ts +++ b/server/typescript/packages/codegen-ts/src/projection/report-resolve.ts @@ -14,10 +14,11 @@ import { reportingMemberOwner, reportingViaHops, resolveDimensionOf, - resolveObjectRef, + refTarget, resolveReportingFieldRef, type MeasureAgg, type MetaData, + REPORTING_ATTR_VIA, type MetaDimension, type MetaField, type MetaMeasure, @@ -28,6 +29,12 @@ import { packageOf, walkViaPath, type ExtractContext, type Path } from "./extrac import { andOf, declared, resolveReportFilter, segmentClause } from "./report-sql.js"; import type { ViewFilterClause } from "./view-spec.js"; +/** A string attribute as the author wrote it, for refusal text (ADR-0059); the loaded value is canonical. */ +export function authoredText(node: MetaData, attr: string): string | undefined { + const v = node.authoredAttr(attr); + return typeof v === "string" && v !== "" ? v : undefined; +} + /** * A dimension's `@of` field, by the loader's rule D1 ({@link resolveDimensionOf}). Without * `@via` the dimension is about `from`'s own rows, so the field is read from `from` (a field @@ -58,10 +65,9 @@ export function viaHopError(where: string, attr: string, via: string, hop: strin if (node === undefined) { return new Error(`${head}: it names no relationship or identity.reference of that entity.`); } - const targetRef = node.attr( - node.type === TYPE_IDENTITY ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF, - ); - const target = typeof targetRef === "string" ? resolveObjectRef(root, targetRef, packageOf(at)).node : undefined; + const refAttr = node.type === TYPE_IDENTITY ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF; + const targetRef = node.attr(refAttr); + const target = refTarget(node, refAttr); if (target === undefined) { return new Error(`${head}: its target '${String(targetRef ?? "")}' does not resolve to an object.`); } @@ -90,10 +96,12 @@ export function resolveDimensionViaPath( checkHops?: (hops: readonly string[]) => void, ): Path { const via = dim.via() ?? ""; + // Refusals quote the reference as the author wrote it (ADR-0059); the walk uses the canonical value. + const viaText = authoredText(dim, REPORTING_ATTR_VIA) ?? via; const hops = reportingViaHops(via, reportingMemberOwner(dim, from), from, root); if (hops === undefined) { throw new Error( - `${where} @via '${via}' must be Owner.hop[.hop...], starting at @from '${from.name}' or an entity it extends.`, + `${where} @via '${viaText}' must be Owner.hop[.hop...], starting at @from '${from.name}' or an entity it extends.`, ); } checkHops?.(hops); @@ -107,7 +115,7 @@ export function resolveDimensionViaPath( if (path.length !== hops.length) { const last = path[path.length - 1]; const at = last === undefined ? from : root.objects().find((o) => o.resolutionKey() === last.targetEntity); - throw viaHopError(where, "@via", via, hops[path.length]!, at ?? from, root); + throw viaHopError(where, "@via", viaText, hops[path.length]!, at ?? from, root); } return path; } diff --git a/server/typescript/packages/codegen-ts/src/reconcile-orphans.ts b/server/typescript/packages/codegen-ts/src/reconcile-orphans.ts index 4d274e207..0af5ff195 100644 --- a/server/typescript/packages/codegen-ts/src/reconcile-orphans.ts +++ b/server/typescript/packages/codegen-ts/src/reconcile-orphans.ts @@ -44,6 +44,13 @@ export interface OrphanPolicy { * automatic one. */ readonly force?: boolean; + /** + * Exempt this namespace from every sweep, the run-wide one included: a file in it + * is never deleted, untouched or not. Default false. `requirementTests({ + * reconcileOrphans: false })` sets it, so turning reconciliation off still means + * what it meant before the run-wide sweep existed. + */ + readonly keep?: boolean; } export interface OrphanDecision { @@ -127,10 +134,9 @@ export function refusedOrphanMessage( generatorName = "orphan cleanup", ): string { return ( - `${generatorName}: ${paths.length} generated file(s) are no longer produced by ` + - `this generator but have been edited by hand, so they were NOT deleted: ` + - `${paths.join(", ")}. Either what produced them was removed (delete these files ` + - `yourself if the edits are no longer wanted) or it was renamed (move the edits ` + - `into the newly generated file first — regeneration cannot follow a rename).` + `${generatorName}: ${paths.length} generated file(s) are no longer generated but ` + + `have been edited by hand, so they were NOT deleted: ${paths.join(", ")}. ` + + `Delete each one, or, if what produced it was renamed, move your edits into the ` + + `newly generated file first and then delete it (regeneration cannot follow a rename).` ); } diff --git a/server/typescript/packages/codegen-ts/src/reference/agent-docs.ts b/server/typescript/packages/codegen-ts/src/reference/agent-docs.ts index ec7d6c7d5..877ccbcd6 100644 --- a/server/typescript/packages/codegen-ts/src/reference/agent-docs.ts +++ b/server/typescript/packages/codegen-ts/src/reference/agent-docs.ts @@ -106,8 +106,9 @@ export interface AgentDocsFileOpts { function lineageOf(field: MetaField): string | undefined { const origin = field.ownChildren().find((c) => c.type === TYPE_ORIGIN); if (origin === undefined) return undefined; + // Human-facing text: references print as the author wrote them (ADR-0059). const str = (name: string): string | undefined => { - const v = origin.attr(name); + const v = origin.authoredAttr(name); return typeof v === "string" && v !== "" ? v : undefined; }; switch (origin.subType) { @@ -165,8 +166,9 @@ function relationshipLines(objects: readonly MetaObject[]): string[] { for (const obj of objects) { for (const rel of obj.relationships()) { const cardinality = rel.attr(RELATIONSHIP_ATTR_CARDINALITY); - const target = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF); - const through = rel.attr(RELATIONSHIP_ATTR_THROUGH); + // References print as the author wrote them (ADR-0059); the loaded value is canonical. + const target = rel.authoredAttr(RELATIONSHIP_ATTR_OBJECT_REF); + const through = rel.authoredAttr(RELATIONSHIP_ATTR_THROUGH); const onDelete = rel.attr(RELATIONSHIP_ATTR_ON_DELETE); // The FQN, not the short name: this line is an ADDRESS a reader searches for. const parts = [`\`${obj.resolutionKey()}.${rel.name}\``, `\`${rel.subType}\``]; diff --git a/server/typescript/packages/codegen-ts/src/reference/prompt-render.ts b/server/typescript/packages/codegen-ts/src/reference/prompt-render.ts index 57c1b8c0c..e2e0f1ce1 100644 --- a/server/typescript/packages/codegen-ts/src/reference/prompt-render.ts +++ b/server/typescript/packages/codegen-ts/src/reference/prompt-render.ts @@ -32,7 +32,7 @@ import { TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_FORMAT, - resolveObjectRef, + refTarget, type MetaData, } from "@metaobjectsdev/metadata"; import { @@ -83,8 +83,8 @@ export const promptRender = function promptRender(opts?: PromptRenderOpts): Gene for (const t of prompts) { const ref = t.attr(TEMPLATE_ATTR_PAYLOAD_REF); if (typeof ref !== "string" || ref === "") continue; - // A bare ref resolves in the template's own package first (ADR-0042). - const vo = resolveObjectRef(ctx.loadedRoot, ref, t.package ?? t.fileDefaultPackage ?? "").node; + // ADR-0059 R7: the value object the loader bound for @payloadRef. + const vo = refTarget(t, TEMPLATE_ATTR_PAYLOAD_REF); if (vo === undefined) continue; const imp = valueObjectImport(ctx.renderContext, vo, outFile, ctx.config.extStyle ?? "js"); imports.push(imp); diff --git a/server/typescript/packages/codegen-ts/src/reference/render-helper.ts b/server/typescript/packages/codegen-ts/src/reference/render-helper.ts index 8d3371e04..bb0d4d4c0 100644 --- a/server/typescript/packages/codegen-ts/src/reference/render-helper.ts +++ b/server/typescript/packages/codegen-ts/src/reference/render-helper.ts @@ -30,7 +30,7 @@ import { TYPE_TEMPLATE, TEMPLATE_SUBTYPE_OUTPUT, TEMPLATE_ATTR_PAYLOAD_REF, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import { oncePerRun, @@ -63,7 +63,7 @@ export const renderHelper = function renderHelper(opts?: RenderHelperOpts): Gene for (const t of outputs) { const payloadRef = t.attr(TEMPLATE_ATTR_PAYLOAD_REF); if (typeof payloadRef !== "string") continue; - const vo = resolveObjectRef(root, payloadRef, t.package ?? t.fileDefaultPackage ?? "").node; + const vo = refTarget(t, TEMPLATE_ATTR_PAYLOAD_REF); if (!vo) continue; const path = `${dirPrefix}${t.name}.render.ts`; files.push({ diff --git a/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts b/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts index 26479f92b..914fc49b2 100644 --- a/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts +++ b/server/typescript/packages/codegen-ts/src/reference/requirement-tests.ts @@ -229,8 +229,9 @@ export interface RequirementTestsOpts { owns?: (relPathInTarget: string) => boolean; /** Delete a hand-edited orphan rather than refusing it. Default false. */ forceOrphanDelete?: boolean; - /** Turn orphan reconciliation off entirely. Default true — a stub whose requirement - * is gone is drift, and leaving it is how a deleted claim keeps a green test. */ + /** `false` keeps every stub in this generator's namespace (`owns`), even an untouched + * one whose requirement is gone. Default true — a stub whose requirement is gone is + * drift, and leaving it is how a deleted claim keeps a green test. */ reconcileOrphans?: boolean; } @@ -280,8 +281,9 @@ export function requirementTests(opts: RequirementTestsOpts = {}): Generator { assertRequirementTestGrain(grain); const toPath = opts.path ?? (grain === "member" ? defaultMemberPath : defaultPath); // A custom `path` with no custom `owns` leaves the default namespace pointing - // somewhere the generator no longer writes, so reconciliation matches nothing. That - // degrades safely — it can only ever delete less — but silently, hence the warning. + // somewhere the generator no longer writes, so the policy claims nothing. The run-wide + // sweep still deletes an untouched orphan there; `reconcileOrphans: false` cannot keep + // files it cannot name, hence the warning. const namespaceUnknown = opts.path !== undefined && opts.owns === undefined; const generator: Generator = { @@ -290,11 +292,12 @@ export function requirementTests(opts: RequirementTestsOpts = {}): Generator { const files: EmittedFile[] = []; const uncovered: string[] = []; - if (namespaceUnknown && opts.reconcileOrphans !== false) { + if (namespaceUnknown && opts.reconcileOrphans === false) { ctx.warn( - "a custom 'path' was supplied without a matching 'owns', so a stub left " + - "behind by a deleted requirement will NOT be cleaned up. Supply 'owns' " + - "to describe where 'path' writes, or set reconcileOrphans: false.", + "a custom 'path' was supplied without a matching 'owns', so reconcileOrphans: " + + "false cannot tell which files are its stubs: a stub left behind by a deleted " + + "requirement is deleted like any other generated file no generator emits any " + + "more (an edited one is refused). Supply 'owns' to describe where 'path' writes.", ); } @@ -351,16 +354,16 @@ export function requirementTests(opts: RequirementTestsOpts = {}): Generator { }; if (opts.target !== undefined) generator.target = opts.target; - if (opts.reconcileOrphans !== false) { - generator.orphanPolicy = { - // With a custom `path` and no `owns`, claim NOTHING rather than guess: the default - // namespace would be a claim over a directory this generator does not write to, - // and a wrong claim deletes another generator's files. - owns: - opts.owns ?? - (namespaceUnknown ? () => false : (relPath) => relPath.startsWith(DEFAULT_STUB_DIR)), - ...(opts.forceOrphanDelete === true && { force: true }), - }; - } + generator.orphanPolicy = { + // With a custom `path` and no `owns`, claim NOTHING rather than guess: the default + // namespace would be a claim over a directory this generator does not write to, + // and a wrong claim deletes another generator's files. + owns: + opts.owns ?? + (namespaceUnknown ? () => false : (relPath) => relPath.startsWith(DEFAULT_STUB_DIR)), + ...(opts.reconcileOrphans === false + ? { keep: true } + : opts.forceOrphanDelete === true && { force: true }), + }; return generator; } diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index 1c0960d4f..c6f8a3d89 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -85,6 +85,7 @@ import { GENERATED_EDIT_NOTE, sidecarLine, DEFAULT_ID_FIELD, + pkInfoOf, isTphSubtype, tphDiscriminatorPin, tphStorageObject, @@ -338,9 +339,10 @@ ${m2mMountsFlat}} function m2mEntriesOf( ctx: RenderContext, name: string, -): Array { +): Array { return (ctx.relationMap.get(name) ?? []).filter( - (e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined, + (e): e is RelationEntry & { junctionEntity: string; junction: MetaObject } => + e.junctionEntity !== undefined && e.junction !== undefined, ); } @@ -353,7 +355,7 @@ function m2mEntriesOf( * has no M:N relationships — CRUD-only output stays byte-identical to before. */ function renderM2mMounts( - entries: ReadonlyArray, + entries: ReadonlyArray, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -397,7 +399,7 @@ interface TphM2mSource { * column). mountM2mRoute appends `/:id/` to the source $path. */ function renderM2mMount( - entry: RelationEntry & { junctionEntity: string }, + entry: RelationEntry & { junctionEntity: string; junction: MetaObject }, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -412,7 +414,7 @@ function renderM2mMount( `${ctx.collectionName(entry.junctionEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, )}`, @@ -421,26 +423,25 @@ function renderM2mMount( // subtype has no table const, and the junction FK can only point at the base table — // and filters the rows to the subtype, because a Broker id in that FK column is not a // Carrier. Every other target binds to itself, and emits no filter. - const declaredTarget = ctx.loadedRoot.findObject(entry.targetEntity); - const target = declaredTarget === undefined ? undefined : tphStorageObject(declaredTarget); - const targetTableEntity = target?.name ?? entry.targetEntity; - const pin = declaredTarget === undefined ? undefined : tphDiscriminatorPin(declaredTarget); + const target = tphStorageObject(entry.target); + const targetTableEntity = target.name; + const pin = tphDiscriminatorPin(entry.target); // A self-join's target table IS the source table, which this file already imports from // the entity module; a second import of the same binding is TS2300, and a SyntaxError // when Node loads the module. - const targetVarSym = targetTableEntity === source.name + const targetVarSym = target === source ? ctx.collectionName(source.name) : imp( `${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(targetTableEntity), + effectivePackage(target), targetTableEntity, ctx.extStyle, )}`, ); const mountM2mRouteSym = imp(`mountM2mRoute@${httpRuntimeSpecifier("drizzle-fastify", ctx, sourcePkg)}`); - const junction = ctx.loadedRoot.findObject(entry.junctionEntity); + const junction = entry.junction; // fromPackage = source.package: this routes file is SOURCE's own module, never the // junction's or the target's — see resolveJunctionColumn's doc comment (B1). // The relation-resolver pre-pass derives both join fields for every M:N entry. @@ -448,16 +449,10 @@ function renderM2mMount( if (sourceJoinField === undefined || targetJoinField === undefined) { throw new Error(`routes-file: M:N "${entry.name}" on ${source.name} has no derived join fields`); } - const sourceColumn: Code = junction - ? resolveJunctionColumn(junction, sourceJoinField, ctx, sourcePkg) - : code`${JSON.stringify(sourceJoinField)}`; - const targetColumn: Code = junction - ? resolveJunctionColumn(junction, targetJoinField, ctx, sourcePkg) - : code`${JSON.stringify(targetJoinField)}`; - const targetPkColumn: Code = target - ? resolveJunctionColumn(target, ctx.pkMap.get(targetTableEntity)?.fieldName ?? "id", ctx, sourcePkg) - : code`${JSON.stringify("id")}`; - const discriminatorLine: Code | string = pin !== undefined && target !== undefined + const sourceColumn: Code = resolveJunctionColumn(junction, sourceJoinField, ctx, sourcePkg); + const targetColumn: Code = resolveJunctionColumn(junction, targetJoinField, ctx, sourcePkg); + const targetPkColumn: Code = resolveJunctionColumn(target, pkInfoOf(target)?.fieldName ?? "id", ctx, sourcePkg); + const discriminatorLine: Code | string = pin !== undefined ? code` targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },` : ""; @@ -595,7 +590,7 @@ function renderTphRoutes( // compile gate stayed green while the endpoint 404'd. // // The physical columns stage 0 needs, resolved once against the base's own table. - const basePkField = ctx.pkMap.get(baseName)?.fieldName ?? "id"; + const basePkField = pkInfoOf(base)?.fieldName ?? "id"; const basePkColumn = resolveJunctionColumn(base, basePkField, ctx, basePkg); const baseDiscColumn = resolveJunctionColumn(base, discField, ctx, basePkg); const baseM2mMounts = renderM2mMounts(m2mEntriesOf(ctx, baseName), base, ctx, fastifyRef); diff --git a/server/typescript/packages/codegen-ts/src/relation-resolver.ts b/server/typescript/packages/codegen-ts/src/relation-resolver.ts index 215e4f8b3..a2a0ba3a0 100644 --- a/server/typescript/packages/codegen-ts/src/relation-resolver.ts +++ b/server/typescript/packages/codegen-ts/src/relation-resolver.ts @@ -9,11 +9,14 @@ import type { MetaRoot, MetaObject, MetaRelationship } from "@metaobjectsdev/met import { RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF, + refTarget, RELATIONSHIP_ATTR_THROUGH, RELATIONSHIP_ATTR_SOURCE_REF_FIELD, + IDENTITY_REFERENCE_ATTR_REFERENCES, CARDINALITY_ONE, CARDINALITY_MANY, deriveM2MFields, + isMetaObject, resolveRelationshipReference, stripPackage, } from "@metaobjectsdev/metadata"; @@ -27,8 +30,10 @@ export interface RelationEntry { name: string; /** Cardinality: 'one' | 'many' */ cardinality: "one" | "many"; - /** The other entity's name (e.g., "User") */ + /** The other entity's name (e.g., "User"). An identifier, never a lookup key: bind through `target`. */ targetEntity: string; + /** The entity the relationship's reference binds to (ADR-0059 R7). */ + target: MetaObject; /** For cardinality 'one': the field on THIS entity that holds the FK (e.g., "authorId") */ fkField?: string; /** For cardinality 'one': the target entity's PK field (e.g., "id") */ @@ -43,6 +48,8 @@ export interface RelationEntry { */ /** The junction/through entity name (e.g., "PostTag"). M:N entries only. */ junctionEntity?: string; + /** The junction/through entity `@through` binds to. M:N entries only. */ + junction?: MetaObject; /** Junction FK field holding the source-side key (logical field name, e.g. "postId"). M:N only. */ sourceJoinField?: string; /** Junction FK field holding the target-side key (logical field name, e.g. "tagId"). M:N only. */ @@ -107,7 +114,7 @@ export function buildRelationMap( if (clash !== undefined) { const same = clash.cardinality === entry.cardinality && - clash.targetEntity === entry.targetEntity && + clash.target === entry.target && clash.fkField === entry.fkField; if (!same) { onWarn?.( @@ -160,10 +167,10 @@ export function buildRelationMap( if (cardinality !== CARDINALITY_ONE) continue; - // ADR-0039: resolving — @objectRef may be inherited via extends. - const targetEntityRaw = child.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined; - if (!targetEntityRaw) continue; - const targetEntity = stripPackage(targetEntityRaw); + // ADR-0039: resolving — @objectRef may be inherited via extends. ADR-0059 R7: + // read the bound target, never a short-name match. + const target = refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF); + if (!isMetaObject(target)) continue; // #368: an entity may hold more than one identity.reference onto the same // target, so the target alone does not identify the FK. Resolve through the @@ -172,9 +179,7 @@ export function buildRelationMap( // means an unloadable model reached codegen — skip rather than guess. // ADR-0039: resolving — @sourceRefField may be inherited via extends. const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; - const matching = resolveRelationshipReference( - obj, child.name, targetEntity, declaredRefField, - ); + const matching = resolveRelationshipReference(obj, child.name, target, declaredRefField); if (!matching) continue; const fkField = matching.fields[0]; @@ -183,7 +188,8 @@ export function buildRelationMap( push(obj, { name: child.name, cardinality: "one", - targetEntity, + targetEntity: target.name, + target, fkField, targetPkField: "id", }); @@ -202,24 +208,22 @@ export function buildRelationMap( // one() sides so the through-table is navigable in the Drizzle relational // query API (db.query.posts.findMany({ with: { tags: { with: { tag: true }}}})). // A junction is any entity named by some M:N relationship's @through. - for (const junctionName of collectJunctionNames(root)) { - const junction = root.findObject(junctionName); - if (!junction) continue; + for (const junction of collectJunctions(root)) { for (const ref of junction.referenceIdentities()) { - const targetRaw = ref.targetEntity; + const target = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); const fkField = ref.fields[0]; - if (!targetRaw || !fkField) continue; - const targetEntity = stripPackage(targetRaw); + if (!isMetaObject(target) || !fkField) continue; // The relation member is named after the target entity (camel singular); // multiple references to the same entity (self-join junction) are // disambiguated by the FK field name. const refName = ref.name && ref.name.length > 0 ? ref.name - : variableNameFromEntity(targetEntity); + : variableNameFromEntity(target.name); push(junction, { name: refName, cardinality: "one", - targetEntity, + targetEntity: target.name, + target, fkField, targetPkField: "id", }); @@ -229,18 +233,18 @@ export function buildRelationMap( return result; } -/** Names of all entities that are the `@through` junction of some M:N relationship. */ -function collectJunctionNames(root: MetaRoot): Set { - const names = new Set(); +/** Every entity that is the `@through` junction of some M:N relationship, as bound. */ +function collectJunctions(root: MetaRoot): Set { + const junctions = new Set(); for (const obj of root.objects()) { for (const rel of obj.relationships()) { // ADR-0039: resolving — @cardinality/@through may be inherited via extends. if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_MANY) continue; - const through = rel.attr(RELATIONSHIP_ATTR_THROUGH) as string | undefined; - if (through) names.add(stripPackage(through)); + const junction = refTarget(rel, RELATIONSHIP_ATTR_THROUGH); + if (isMetaObject(junction)) junctions.add(junction); } } - return names; + return junctions; } /** @@ -259,9 +263,9 @@ function buildM2mEntry( onWarn?: (msg: string) => void, ): RelationEntry | null { // ADR-0039: resolving — @objectRef/@through may be inherited via extends. - const targetRaw = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined; + const target = refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF); const throughRaw = rel.attr(RELATIONSHIP_ATTR_THROUGH) as string | undefined; - if (!targetRaw || !throughRaw) return null; + if (!isMetaObject(target) || !throughRaw) return null; let fields; try { fields = deriveM2MFields(rel, source, root); @@ -274,11 +278,15 @@ function buildM2mEntry( ); return null; } + const junction = refTarget(rel, RELATIONSHIP_ATTR_THROUGH); + if (!isMetaObject(junction)) return null; return { name: rel.name, cardinality: "many", - targetEntity: stripPackage(targetRaw), - junctionEntity: stripPackage(throughRaw), + targetEntity: target.name, + target, + junctionEntity: junction.name, + junction, sourceJoinField: fields.sourceField, targetJoinField: fields.targetField, symmetric: rel.symmetric, diff --git a/server/typescript/packages/codegen-ts/src/render-context.ts b/server/typescript/packages/codegen-ts/src/render-context.ts index 66c33dd63..4dab959e0 100644 --- a/server/typescript/packages/codegen-ts/src/render-context.ts +++ b/server/typescript/packages/codegen-ts/src/render-context.ts @@ -1,7 +1,7 @@ // RenderContext — cross-cutting state passed to every template. -import type { MetaRoot, MetaData, MetaField } from "@metaobjectsdev/metadata"; -import { physicalNamingOf, resolveObjectRef, stripPackage } from "@metaobjectsdev/metadata"; +import type { MetaRoot, MetaData } from "@metaobjectsdev/metadata"; +import { FIELD_ATTR_OBJECT_REF, physicalNamingOf, refTarget, stripPackage } from "@metaobjectsdev/metadata"; import type { Dialect } from "./column-mapper.js"; import { normalizeTimestampMode } from "./dialect-module.js"; import type { PkInfo } from "./pk-resolver.js"; @@ -106,12 +106,12 @@ export interface RenderContext { * Zod schema, and module filename). Non-value objects (entities) are never in * the collision set, so this returns their bare `name`. */ valueObjectEmittedName: (obj: MetaData) => string; - /** The ADR-0044 emitted name for a REFERENCE to a value object (`@objectRef`, - * bare or FQN), resolved package-locally (ADR-0042) from `referrerPkg`. Falls - * back to the bare (package-stripped) ref when it resolves to no emitted value + /** The ADR-0044 emitted name for the value object a field's `@objectRef` names, read + * as the loader bound it (ADR-0059 R7: resolved where the attribute was declared). + * Falls back to the bare (package-stripped) ref when it resolves to no emitted value * object — which is also the byte-identical result whenever there is no * collision. */ - resolveValueObjectName: (ref: string, referrerPkg: string | undefined) => string; + resolveValueObjectName: (field: MetaData) => string; /** FR-019: module specifier to import externally-PROVIDED shared enums from * (`@provided: true` declarations). Undefined when unset — referencing a * provided enum without it is a codegen-time error. */ @@ -151,17 +151,6 @@ export type RenderContextInput = Omit; }; -/** ADR-0042/#228 — the package a field's `@objectRef` resolves in: the FIELD's OWN - * declaring package (which differs from the referring object's when the field is - * inherited via `extends` from an abstract node in another package), falling back - * to `fallbackPkg` (the referring object's package). THE single source of truth for - * the referrer package passed to `RenderContext.resolveValueObjectName`, so every - * value-object reference site resolves a cross-package short-name collision - * identically (they cannot drift). */ -export function fieldDeclaringPackage(field: MetaField, fallbackPkg: string | undefined): string | undefined { - return field.parent?.package ?? field.parent?.fileDefaultPackage ?? fallbackPkg; -} - /** Append the configured extension to a cross-entity module specifier (which is * always a bare, extension-less relative path like `./Foo`). */ export function withExt(spec: string, style: ExtStyle): string { @@ -202,7 +191,6 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext { // absent (bare template unit-tests), the map is empty, so both accessors return // bare names and every consumer is byte-identical to pre-#228 output. const valueObjectNames = opts.valueObjectNames ?? new Map(); - const loadedRoot = opts.loadedRoot; return { ...opts, extStyle: opts.extStyle ?? "js", @@ -222,10 +210,13 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext { packageOf: opts.packageOf ?? new Map(), valueObjectNames, valueObjectEmittedName: (obj: MetaData) => valueObjectNames.get(obj.resolutionKey()) ?? obj.name, - resolveValueObjectName: (ref: string, referrerPkg: string | undefined) => { - const { node } = resolveObjectRef(loadedRoot, ref, referrerPkg ?? ""); + resolveValueObjectName: (field: MetaData) => { + const node = refTarget(field, FIELD_ATTR_OBJECT_REF); const emitted = node !== undefined ? valueObjectNames.get(node.resolutionKey()) : undefined; - return emitted ?? stripPackage(ref); + if (emitted !== undefined) return emitted; + // ADR-0039: resolving — @objectRef may be inherited via extends. + const ref = field.attr(FIELD_ATTR_OBJECT_REF); + return stripPackage(typeof ref === "string" ? ref : ""); }, selfTarget: defaultTarget, entityModuleTarget: opts.entityModuleTarget ?? defaultTarget, diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index ce6eadc2f..51389c5a2 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -37,6 +37,7 @@ import { type MergeStrategy, type BaselineMode, type DecideAndWriteOpts, + type HandEdits, } from "./overwrite-policy.js"; import { stableNameIndex, warnUnsatisfiedRequires, warnMixedApiFrameworks, warnLibrarySelectionMismatch, @@ -50,6 +51,15 @@ import type { AgentSchemaInput } from "./generators/agent-schema-input.js"; * from untrusted sources (e.g. MCP). Mirrors the guard in legacy generate.ts. */ const VALID_ENTITY_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** The owner the run-wide orphan cleanup reports under: it acts for the run, not for one + * generator. */ +const RUN_WIDE_SWEEP = "meta gen"; + +function isNestedIn(path: string, dir: string): boolean { + const rel = relative(dir, path); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); +} + export interface RunGenOpts { config: MetaobjectsGenConfig; metadata: MetaData; @@ -186,6 +196,15 @@ export interface RunGenResult { /** Subset of `files` with status "conflict" — surfaced separately so the * CLI can print the end-of-run summary. */ conflicts: WriteResult[]; + /** + * Where this run discards hand edits (`Generator.handEdits: "overwrite"`): the absolute + * paths emitted by a generator that says so. `verify --codegen` reads it to tell a hand + * edit `meta gen` keeps from one it replaces. + */ + handEditsOverwrite?: { paths: ReadonlySet }; + /** The jobs the orphan sweep ran (or, on a dry run, would run) with, so a caller can + * classify an orphan by `orphanVerdict` exactly as the sweep does. */ + orphanSweepJobs?: readonly OrphanJob[]; } /** @@ -343,7 +362,7 @@ export async function runGen(opts: RunGenOpts): Promise { return rel.startsWith("..") || isAbsolute(rel); }; let untrackedCount = 0; - const policyFor = (fullPath: string): DecideAndWriteOpts => { + const policyFor = (fullPath: string, handEdits?: HandEdits): DecideAndWriteOpts => { if (outsideProject(fullPath)) { untrackedCount++; // With no record to consult, the default baseline REFUSES any existing file that @@ -353,6 +372,7 @@ export async function runGen(opts: RunGenOpts): Promise { return { strategy, genStateDir: untrackedGenStateDir, baseline: baseline === "adopt" ? "adopt" : "fresh" }; } const o: DecideAndWriteOpts = { strategy, genStateDir, baseline }; + if (handEdits !== undefined) o.handEdits = handEdits; if (projectRoot !== undefined) o.outputRelPath = relative(projectRoot, fullPath); return o; }; @@ -758,11 +778,24 @@ export async function runGen(opts: RunGenOpts): Promise { } // 4. Run each generator with a per-target render context; collect with full path. - const emitted: { fullPath: string; content: string; generatedBy: string }[] = []; + const emitted: { fullPath: string; content: string; generatedBy: string; handEdits?: HandEdits }[] = []; // FR-038 §8 — generators that opted into orphan reconciliation, paired with the // directory their policy's relative paths are measured from. Collected here // because `writeOutDir` is resolved per generator inside this loop. const orphanJobs: OrphanJob[] = []; + // Every directory a generator of this run wrote to. The run-wide sweep covers each + // one: a file `meta gen` recorded writing there and no generator emits any more is + // stale output, whichever generator once produced it. This is the jurisdiction + // `meta verify --codegen` already convicts on (codegen-drift.ts, the orphan branch), + // so `gen` now removes exactly what the gate reports and the gate's remedy works. + const runOutDirs = new Set(); + // Per directory: whether every generator writing into it sets `handEdits: "overwrite"`. + const outDirOverwrites = new Map(); + // The generators (and the run-wide job) whose forced deletions are `handEdits` discards. + const overwriteOwners = new Set(); + const handEditsOverwrite = (): NonNullable => ({ + paths: new Set(emitted.filter((f) => f.handEdits === "overwrite").map((f) => f.fullPath)), + }); // Generators that ran and produced no file. See the note at the `files.length === 0` // check below. A Set because a generator may run once per target. const emptyGenerators = new Set(); @@ -779,11 +812,20 @@ export async function runGen(opts: RunGenOpts): Promise { const writeOutDir = projectRoot !== undefined && !isAbsolute(selfTarget.outDir) ? resolve(projectRoot, selfTarget.outDir) : selfTarget.outDir; + runOutDirs.add(writeOutDir); + // A directory's run-wide cleanup may discard an edited orphan only when EVERY generator + // writing into it says `handEdits: "overwrite"`: the manifest does not record which + // generator wrote a file, so one `keep` writer protects the whole directory. + const overwrites = generator.handEdits === "overwrite"; + if (overwrites) overwriteOwners.add(generator.name); + outDirOverwrites.set(writeOutDir, (outDirOverwrites.get(writeOutDir) ?? true) && overwrites); if (generator.orphanPolicy !== undefined) { orphanJobs.push({ generatorName: generator.name, writeOutDir, - policy: generator.orphanPolicy, + policy: overwrites && generator.orphanPolicy.keep !== true + ? { ...generator.orphanPolicy, force: true } + : generator.orphanPolicy, }); } const renderContext = makeRenderContext({ @@ -906,7 +948,12 @@ export async function runGen(opts: RunGenOpts): Promise { `Adjust one generator's filter or output path.`, ); } - emitted.push({ fullPath, content: file.content, generatedBy: generator.name }); + emitted.push({ + fullPath, + content: file.content, + generatedBy: generator.name, + ...(generator.handEdits !== undefined && { handEdits: generator.handEdits }), + }); } } @@ -1030,8 +1077,46 @@ export async function runGen(opts: RunGenOpts): Promise { ); }; + // The run-wide jobs, AFTER every opt-in policy job: the first decision on a path wins, + // and a policy that sets `force` must get to decide before this job refuses the path. + // + // Only a run of the WHOLE declared list sweeps run-wide. `--generators` leaves the other + // entries' output unproduced by construction, so all of it would look orphaned. The opt-in + // policy jobs are scoped to their own generator's namespace and keep running there. + // `--baseline=adopt` writes nothing, so it deletes nothing either. + // + // It covers every declared outDir, written this run or not, as `meta verify --codegen` + // walks every declared outDir: a directory whose generators were all moved or removed + // still holds their recorded output, and the gate tells the user `meta gen` deletes it. + const sweepDirs = [...new Set([ + ...runOutDirs, + ...Object.values(targets) + .filter((t) => t.outDir.length > 0) + .map((t) => (projectRoot !== undefined && !isAbsolute(t.outDir) ? resolve(projectRoot, t.outDir) : t.outDir)), + ])]; + const sweepJobs: OrphanJob[] = + opts.generatorNames === undefined && baseline !== "adopt" + ? [ + ...orphanJobs, + ...sweepDirs.map((writeOutDir) => { + const nested = sweepDirs.filter((other) => isNestedIn(other, writeOutDir)); + const owns = (rel: string): boolean => { + const full = resolve(writeOutDir, rel); + return !nested.some((inner) => isNestedIn(full, inner) || full === inner); + }; + return { + generatorName: RUN_WIDE_SWEEP, + writeOutDir, + policy: outDirOverwrites.get(writeOutDir) === true + ? { owns, force: true } + : { owns }, + }; + }), + ] + : orphanJobs; + const sweep = (dryRun: boolean): void => { - if (projectRoot === undefined || orphanJobs.length === 0) return; + if (projectRoot === undefined || sweepJobs.length === 0) return; // NEVER reconcile a PARTIAL run. `meta gen ` narrows the object set, so // `emitted` is a subset of the full output BY CONSTRUCTION — every path belonging @@ -1074,7 +1159,7 @@ export async function runGen(opts: RunGenOpts): Promise { genStateDir, projectRoot, emittedRelPaths: emitted.map((f) => relative(projectRoot, f.fullPath)), - jobs: orphanJobs, + jobs: sweepJobs, dryRun, }); // Both kinds are the same file outcome — gone. What differs is what it cost, @@ -1098,10 +1183,23 @@ export async function runGen(opts: RunGenOpts): Promise { warnings.push(refusedOrphanMessage(paths, owner)); } } - if (result.forced.length > 0) { + // `handEdits: "overwrite"` asked for these, so each is named on its own line: a + // discarded edit is never silent. + const discarded = result.forced.filter((relPath) => { + const by = result.forcedBy.get(relPath); + return by === RUN_WIDE_SWEEP || (by !== undefined && overwriteOwners.has(by)); + }); + for (const relPath of discarded) { + warnings.push( + `${dryRun ? "would delete" : "deleted"} hand-edited ${relPath}, which no generator emits any more ` + + `(handEdits: "overwrite")`, + ); + } + const policyForced = result.forced.filter((relPath) => !discarded.includes(relPath)); + if (policyForced.length > 0) { warnings.push( - `Deleted ${result.forced.length} hand-edited generated file(s) because a ` + - `generator's orphanPolicy sets force: ${result.forced.join(", ")}. ` + + `Deleted ${policyForced.length} hand-edited generated file(s) because a ` + + `generator's orphanPolicy sets force: ${policyForced.join(", ")}. ` + `Hand-written content in them is gone — recover from version control.`, ); } @@ -1117,7 +1215,7 @@ export async function runGen(opts: RunGenOpts): Promise { // as "overwrite" while the real run refused it — the one case the preview most // needs to be right about. A merge outcome is still coarse (see // previewWriteStatus), because clean-vs-conflicted is unknowable without merging. - const policyOpts = policyFor(file.fullPath); + const policyOpts = policyFor(file.fullPath, file.handEdits); writes.push({ path: file.fullPath, status: previewWriteStatus(file.fullPath, file.content, policyOpts), @@ -1129,7 +1227,7 @@ export async function runGen(opts: RunGenOpts): Promise { // A preview that hides a pending deletion is worse than no preview at all, so // the sweep still runs — in decide-and-report mode, touching nothing. sweep(true); - return { files: writes, warnings, conflicts }; + return { files: writes, warnings, conflicts, handEditsOverwrite: handEditsOverwrite(), orphanSweepJobs: sweepJobs }; } for (const file of emitted) { @@ -1137,8 +1235,14 @@ export async function runGen(opts: RunGenOpts): Promise { // distinct entries (e.g. `database/Post.ts` vs `web/Post.queries.ts`). // Without an explicit projectRoot we let decideAndWrite derive a stable // hash-of-path key — fine for ephemeral test runs. - const result = decideAndWrite(file.fullPath, file.content, policyFor(file.fullPath)); + const result = decideAndWrite(file.fullPath, file.content, policyFor(file.fullPath, file.handEdits)); writes.push(result); + if (result.discardedEdit === true) { + warnings.push( + `overwrote hand-edited ${relativeForDisplay(file.fullPath)} with fresh output ` + + `(handEdits: "overwrite" on ${file.generatedBy})`, + ); + } if (result.status === "conflict") { conflicts.push(result); warnings.push( @@ -1165,7 +1269,7 @@ export async function runGen(opts: RunGenOpts): Promise { // Amendment 9), tool state included. if (installedEngine !== undefined && config.generators.length > 0) saveEngineVersion(genStateDir, installedEngine); - return { files: writes, warnings, conflicts }; + return { files: writes, warnings, conflicts, handEditsOverwrite: handEditsOverwrite(), orphanSweepJobs: sweepJobs }; } /** diff --git a/server/typescript/packages/codegen-ts/src/templates/callable-file.ts b/server/typescript/packages/codegen-ts/src/templates/callable-file.ts index 0dad1ce42..343644280 100644 --- a/server/typescript/packages/codegen-ts/src/templates/callable-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/callable-file.ts @@ -30,6 +30,7 @@ import { TYPE_FIELD, TYPE_SOURCE, OBJECT_SUBTYPE_VALUE, + refTarget, } from "@metaobjectsdev/metadata"; import { GENERATED_HEADER } from "../constants.js"; import { crossEntitySpecifier } from "../import-path.js"; @@ -90,18 +91,16 @@ export function renderCallableFile(entity: MetaObject, ctx?: RenderContext): str const procName = source.physicalName; // ADR-0039: resolving — a source may inherit @parameterRef via extends. const argsRef = source.attr(SOURCE_ATTR_PARAMETER_REF); - const argsObjectName = typeof argsRef === "string" && argsRef !== "" ? argsRef : undefined; + const argsRefValue = typeof argsRef === "string" && argsRef !== "" ? argsRef : undefined; - // Resolve the parameter value-object (when set) — same root as the entity. - const root = entity.root(); - // ADR-0039: resolving — root has no super (children()==ownChildren()). + // ADR-0059 R7: the parameter value-object the loader bound, never a short-name match. + const argsTarget = argsRefValue !== undefined ? refTarget(source, SOURCE_ATTR_PARAMETER_REF) : undefined; const argsObject = - argsObjectName !== undefined - ? (root.children().find( - (c) => - c.subType === OBJECT_SUBTYPE_VALUE && c.name === argsObjectName, - ) as MetaObject | undefined) + argsTarget !== undefined && argsTarget.subType === OBJECT_SUBTYPE_VALUE + ? (argsTarget as MetaObject) : undefined; + // The generated type is named after the object, not the reference spelling. + const argsObjectName = argsObject?.name ?? argsRefValue; // Build the parameter list for the SQL call site. Declaration order = arg // order. Empty when argsObject is undefined (zero-arg proc). diff --git a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts index 14803b094..7568548d9 100644 --- a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts +++ b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts @@ -3,7 +3,7 @@ // plus the relations() block auto-emitted at the end. import { code, imp, joinCode, type Code } from "ts-poet"; -import { MetaObject, MetaField, MetaIndex, isMetaObject, stripPackage } from "@metaobjectsdev/metadata"; +import { MetaObject, MetaField, MetaIndex, isMetaObject } from "@metaobjectsdev/metadata"; import { IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_EXPR, IDENTITY_ATTR_USING, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE, @@ -12,8 +12,10 @@ import { FIELD_ATTR_OBJECT_REF, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_DATE, + IDENTITY_REFERENCE_ATTR_REFERENCES, + refTarget, } from "@metaobjectsdev/metadata"; -import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; +import type { RenderContext } from "../render-context.js"; import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js"; import { mapColumnType, PG_IDENTITY_CAPABLE_FNS, @@ -411,6 +413,7 @@ ${joinCode(columnLines, { on: ",\n", trim: false })} interface FkInfo { targetVarName: string; // e.g., "users" targetEntityName: string; // e.g., "User" — used for the import path + targetPackage: string | undefined; // the target's effective package, for the import path targetPkField: string; // e.g., "id" } @@ -439,20 +442,16 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map { const refRaw = field.attr(FIELD_ATTR_OBJECT_REF); const emitted = typeof refRaw === "string" - ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage)) + ? ctx.resolveValueObjectName(field) : name; return imp(`t:${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`); }; diff --git a/server/typescript/packages/codegen-ts/src/templates/entity-file.ts b/server/typescript/packages/codegen-ts/src/templates/entity-file.ts index 27c009e7d..fec28c46e 100644 --- a/server/typescript/packages/codegen-ts/src/templates/entity-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/entity-file.ts @@ -8,8 +8,8 @@ import { code, imp, joinCode, type Code } from "ts-poet"; import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; -import { FIELD_ATTR_OBJECT_REF, SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; -import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; +import { SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; +import type { RenderContext } from "../render-context.js"; import { renderDrizzleSchema } from "./drizzle-schema.js"; import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js"; import { renderZodValidators, primaryIdentityFieldNames } from "./zod-validators.js"; @@ -153,8 +153,7 @@ export function renderEntityFile( // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`) // under a cross-package short-name collision. const voRef = (field: MetaField): { name: string; module: string } => { - const ref = field.attr(FIELD_ATTR_OBJECT_REF); - const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entityPkg)); + const name = ctx.resolveValueObjectName(field); const module = valueObjectModuleSpecifier(name, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle); return { name, module }; }; diff --git a/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts b/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts index 900cc6a11..e25e44001 100644 --- a/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts +++ b/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts @@ -25,25 +25,17 @@ import { FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_ENUM, FIELD_ATTR_OBJECT_REF, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import { fields, isArray, scalarKind, jsonStringLiteral } from "./fr010-field-mapping.js"; import { emittedVoName } from "./value-object-import.js"; import type { RenderContext } from "../render-context.js"; -// ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident. -// ADR-0042: resolveObjectRef gives package-local-before-root-level precedence for a bare ref, FQN-exact otherwise. -function findObject(root: MetaData, name: string, referrerPkg = ""): MetaData | undefined { - return resolveObjectRef(root, name, referrerPkg).node; -} - -/** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */ -function refVo(field: MetaData, root: MetaData): MetaData | undefined { - const ref = field.attr(FIELD_ATTR_OBJECT_REF); - if (typeof ref !== "string") return undefined; - // ADR-0042: resolve as authored — a bare ref binds the declaring VO's package, - // an FQN exactly; NO bare-tail fallback (never bind a same-named VO elsewhere). - return findObject(root, ref, field.parent?.package ?? field.parent?.fileDefaultPackage ?? ""); +/** The @objectRef target VO for a nested-object field (ADR-0059 R7: the target the + * loader bound, resolved where the attribute was declared), or undefined when + * unresolvable. */ +function refVo(field: MetaData): MetaData | undefined { + return refTarget(field, FIELD_ATTR_OBJECT_REF); } /** True iff the field is a nested object reference (field.object — distinct from the @@ -75,7 +67,7 @@ export function mapperName(vo: MetaData, ctx?: RenderContext): string { /** The nullable mirror TS type for one field — nested-aware (recurses into nested mirror names). */ function nestedMirrorType(field: MetaData, root: MetaData, ctx?: RenderContext): string { if (isObjectField(field)) { - const target = refVo(field, root); + const target = refVo(field); const base = target !== undefined ? mirrorName(target, ctx) : "unknown"; const elem = `${base} | null`; return isArray(field) ? `(${elem})[] | null` : elem; @@ -143,7 +135,7 @@ function emitMirror( // Recurse into nested VOs (post-order via the shared seen set → dedupe + cycle guard). for (const f of fields(vo)) { if (isObjectField(f)) { - const target = refVo(f, root); + const target = refVo(f); if (target !== undefined) emitMirror(target, root, mirrorName(target, ctx), seen, out, ctx); } } @@ -195,7 +187,7 @@ function emitMapper( for (const f of fields(vo)) { if (isObjectField(f)) { - const target = refVo(f, root); + const target = refVo(f); if (target !== undefined) emitMapper(target, root, seen, out, ctx); } } @@ -206,7 +198,7 @@ function mapperArg(field: MetaData, root: MetaData, ctx?: RenderContext): string const key = jsonStringLiteral(field.name); if (isObjectField(field)) { - const target = refVo(field, root); + const target = refVo(field); if (target === undefined) return "null /* unresolved @objectRef */"; const fn = mapperName(target, ctx); if (isArray(field)) { @@ -256,7 +248,7 @@ export function usedHelpers(vo: MetaData, root: MetaData): Set { seen.add(cur.resolutionKey()); for (const f of fields(cur)) { if (isObjectField(f)) { - const target = refVo(f, root); + const target = refVo(f); if (target === undefined) continue; stack.push(target); if (isArray(f)) used.add("mapObjectList"); @@ -300,7 +292,7 @@ export function hasNested(vo: MetaData, root: MetaData): boolean { seen.add(cur.resolutionKey()); for (const f of cur.children().filter((c) => c.type === TYPE_FIELD)) { if (isObjectField(f)) { - const target = refVo(f, root); + const target = refVo(f); if (target !== undefined) { stack.push(target); } diff --git a/server/typescript/packages/codegen-ts/src/templates/extractor.ts b/server/typescript/packages/codegen-ts/src/templates/extractor.ts index 27704cbc3..dbe2d30e0 100644 --- a/server/typescript/packages/codegen-ts/src/templates/extractor.ts +++ b/server/typescript/packages/codegen-ts/src/templates/extractor.ts @@ -30,7 +30,7 @@ import { FIELD_ATTR_OBJECT_REF, FIELD_ATTR_REQUIRED, TEMPLATE_ATTR_RESPONSE_REF, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import { responseShape } from "./find-inbound.js"; import { templateSymbolBase } from "../naming.js"; @@ -41,24 +41,16 @@ import { enumValues } from "../enum-meta.js"; import type { RenderContext } from "../render-context.js"; import { emittedVoName, valueObjectImport } from "./value-object-import.js"; -// ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident. -// ADR-0042: resolveObjectRef gives package-local-before-root-level precedence for a bare ref, FQN-exact otherwise. -function findObject(root: MetaData, name: string, referrerPkg = ""): MetaData | undefined { - return resolveObjectRef(root, name, referrerPkg).node; -} - // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident. function findTemplate(root: MetaData, name: string): MetaData | undefined { return root.children().find((c) => c.type === TYPE_TEMPLATE && c.name === name); } -/** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */ -function refVo(field: MetaData, root: MetaData): MetaData | undefined { - const ref = field.attr(FIELD_ATTR_OBJECT_REF); - if (typeof ref !== "string") return undefined; - // ADR-0042: resolve as authored — a bare ref binds the declaring VO's package, - // an FQN exactly; NO bare-tail fallback (never bind a same-named VO elsewhere). - return findObject(root, ref, field.parent?.package ?? field.parent?.fileDefaultPackage ?? ""); +/** The @objectRef target VO for a nested-object field (ADR-0059 R7: the target the + * loader bound, resolved where the attribute was declared), or undefined when + * unresolvable. */ +function refVo(field: MetaData): MetaData | undefined { + return refTarget(field, FIELD_ATTR_OBJECT_REF); } function isObjectField(field: MetaData): boolean { @@ -111,7 +103,7 @@ function strictArg(field: MetaData, root: MetaData, ownerName: string, ctx?: Ren const required = isFieldRequired(field); if (isObjectField(field)) { - const target = refVo(field, root); + const target = refVo(field); if (target === undefined) { // Unresolved @objectRef — the payload type would be `unknown`; pass through as-is. return required ? `m.${name}!` : `m.${name} ?? undefined`; @@ -206,7 +198,7 @@ function emitMapper( for (const f of fields(vo)) { if (isObjectField(f)) { - const target = refVo(f, root); + const target = refVo(f); if (target !== undefined) emitMapper(target, root, seen, out, ctx); } } @@ -263,7 +255,7 @@ function reachablePayloadGroups( // Recurse into nested object refs (their interfaces live in their own modules). for (const f of fields(cur)) { if (isObjectField(f)) { - const target = refVo(f, root); + const target = refVo(f); if (target !== undefined) visit(target); } } @@ -279,7 +271,7 @@ function reachableMirrorTypes(vo: MetaData, root: MetaData, ctx?: RenderContext) const visit = (cur: MetaData) => { for (const f of fields(cur)) { if (isObjectField(f)) { - const target = refVo(f, root); + const target = refVo(f); if (target !== undefined && !seen.has(target.resolutionKey())) { seen.add(target.resolutionKey()); out.push(mirrorName(target, ctx)); diff --git a/server/typescript/packages/codegen-ts/src/templates/find-inbound.ts b/server/typescript/packages/codegen-ts/src/templates/find-inbound.ts index 13b0e7c44..4c457039f 100644 --- a/server/typescript/packages/codegen-ts/src/templates/find-inbound.ts +++ b/server/typescript/packages/codegen-ts/src/templates/find-inbound.ts @@ -23,7 +23,7 @@ import { RESPONSE_FORMAT_DEFAULT, RESPONSE_FORMAT_XML, type ResponseFormat, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; /** What an inbound generator needs about one responding prompt. */ @@ -73,8 +73,8 @@ export function responseShape(root: MetaData, tmpl: MetaData): InboundShape | un // ADR-0039: resolving — @responseRef may be inherited via extends. const ref = tmpl.attr(TEMPLATE_ATTR_RESPONSE_REF); if (typeof ref !== "string") return undefined; - // ADR-0042: a bare @responseRef resolves package-locally, then root-level. - const vo = resolveObjectRef(root, ref, tmpl.package ?? tmpl.fileDefaultPackage ?? "").node; + // ADR-0059 R7: the value object the loader bound for @responseRef. + const vo = refTarget(tmpl, TEMPLATE_ATTR_RESPONSE_REF); if (!vo) return undefined; return { vo, ref, format: responseFormatOf(tmpl) }; } diff --git a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts index 9cbd0f47e..b880265b0 100644 --- a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts +++ b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts @@ -42,7 +42,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { isTphReadNullTolerant } from "./zod-validators.js"; import { sharedEnumForField } from "../enum-shared.js"; import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "../enum-import.js"; -import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; +import type { RenderContext } from "../render-context.js"; import { effectivePackage } from "../docs-paths.js"; /** @@ -311,7 +311,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render // Drizzle .$type<> use) so all three agree. Without a ctx (bare unit-test // calls) fall back to the bare name + flat same-dir specifier. const entityPkg = effectivePackage(entity); - const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -330,7 +330,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const entityPkg = effectivePackage(entity); - const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts b/server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts index 966a6bb49..9cc8fbd7d 100644 --- a/server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts +++ b/server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts @@ -16,6 +16,9 @@ import { FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, OBJECT_SUBTYPE_VALUE, + IDENTITY_REFERENCE_ATTR_REFERENCES, + refTarget, + stripPackage, } from "@metaobjectsdev/metadata"; /** Render the fenced ```mermaid erDiagram``` block for the whole model (entities + @@ -190,8 +193,11 @@ function renderRelationships(entities: MetaObject[]): string[] { const lines: string[] = []; for (const entity of entities) { for (const ref of entity.referenceIdentities()) { - const refTo = ref.targetEntity; - if (typeof refTo !== "string" || refTo.length === 0) continue; + // Mermaid names a node by the entity's short name, as every other line here does; + // the reference value is canonical (`pkg::Name`, ADR-0059) and not a Mermaid id. + const raw = ref.targetEntity; + if (typeof raw !== "string" || raw.length === 0) continue; + const refTo = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES)?.name ?? stripPackage(raw); // One-to-many by default — a foreign key on `entity` references one row on `refTo`. lines.push(`${refTo} ||--o{ ${entity.name} : "references"`); } diff --git a/server/typescript/packages/codegen-ts/src/templates/output-parser.ts b/server/typescript/packages/codegen-ts/src/templates/output-parser.ts index 45dad5f6a..1ce72468c 100644 --- a/server/typescript/packages/codegen-ts/src/templates/output-parser.ts +++ b/server/typescript/packages/codegen-ts/src/templates/output-parser.ts @@ -21,7 +21,7 @@ import { TEMPLATE_ATTR_RESPONSE_REF, VALIDATOR_SUBTYPE_ARRAY, RESPONSE_FORMAT_XML, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import type { MetaField } from "@metaobjectsdev/metadata"; import { enumValues } from "../enum-meta.js"; @@ -87,12 +87,6 @@ const SCALAR_ZOD: Record = { inet: "z.string()", }; -// ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident. -// ADR-0042: resolveObjectRef gives package-local-before-root-level precedence for a bare ref, FQN-exact otherwise. -function findObject(root: MetaData, name: string, referrerPkg = ""): MetaData | undefined { - return resolveObjectRef(root, name, referrerPkg).node; -} - // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident. function findTemplate(root: MetaData, name: string): MetaData | undefined { return root.children().find((c) => c.type === TYPE_TEMPLATE && c.name === name); @@ -116,16 +110,14 @@ function fieldZod(field: MetaData, root: MetaData, seen: ReadonlySet, de const isArray = field.resolvedIsArray(); let base: string; if (field.subType === FIELD_SUBTYPE_OBJECT) { - const refName = field.attr(FIELD_ATTR_OBJECT_REF); - if (typeof refName !== "string") { - base = "z.unknown()"; - } else if (seen.has(refName)) { - // Cycle guard — emit unknown for self-references (rare; lazy schemas not in scope for v1). + // ADR-0059 R7: the VO the loader bound for @objectRef, resolved where it was declared. + const inner = refTarget(field, FIELD_ATTR_OBJECT_REF); + if (inner === undefined || seen.has(inner.resolutionKey())) { + // Unresolvable, or a cycle guard — emit unknown for self-references (rare; lazy + // schemas not in scope for v1). base = "z.unknown()"; } else { - // ADR-0042: a bare nested @objectRef resolves in the declaring VO's package. - const inner = findObject(root, refName, field.parent?.package ?? field.parent?.fileDefaultPackage ?? ""); - base = inner ? renderObjectSchema(inner, root, new Set(seen).add(refName), depth + 1) : "z.unknown()"; + base = renderObjectSchema(inner, root, new Set(seen).add(inner.resolutionKey()), depth + 1); } } else if (field.subType === FIELD_SUBTYPE_ENUM) { // The members are declared, resolving (so an `extends`-inherited @values is seen), @@ -196,9 +188,9 @@ export function renderOutputParser( : `template "${templateName}" missing @responseRef`, ); } - const { vo, ref: payloadRef } = shape; + const { vo } = shape; - const schema = renderObjectSchema(vo, root, new Set([payloadRef]), 0); + const schema = renderObjectSchema(vo, root, new Set([vo.resolutionKey()]), 0); // One base for every symbol in this file, shared with promptRender()'s handle — the // three template emitters used to spell the same node three ways (see naming.ts). const base = templateSymbolBase(templateName); diff --git a/server/typescript/packages/codegen-ts/src/templates/output-prompt.ts b/server/typescript/packages/codegen-ts/src/templates/output-prompt.ts index 3cf7c43e2..bc8191020 100644 --- a/server/typescript/packages/codegen-ts/src/templates/output-prompt.ts +++ b/server/typescript/packages/codegen-ts/src/templates/output-prompt.ts @@ -51,10 +51,12 @@ export function renderOutputPrompt(root: MetaData, templateName: string): string : `template "${templateName}" missing @responseRef`, ); } - const { vo, ref: payloadRef } = shape; + const { vo } = shape; - // rootName == response VO name so the fragment and extract() agree. - const spec = specLiteral(vo, tmpl, payloadRef); + // rootName == response VO's short name, as extractObject() uses it, so the fragment and + // extract() agree. Never the reference value: that is canonical (`pkg::Name`, ADR-0059) + // and not a valid XML element name. + const spec = specLiteral(vo, tmpl, vo.name); // templateSymbolBase, as render-helper.ts and output-parser.ts do: a lowercase-initial // template name emitted `rendershipmentRiskFormat` beside `renderShipmentRisk` from // prompt-render, so one model produced two conventions for two sibling functions. diff --git a/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts b/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts index 9382a6606..8041c3f4d 100644 --- a/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts +++ b/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts @@ -18,7 +18,7 @@ import { import { projectionViewName } from "../projection/extract-view-spec.js"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "../constants.js"; import type { ColumnNamingStrategy } from "../metaobjects-config.js"; -import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; +import type { RenderContext } from "../render-context.js"; import { valueObjectModuleSpecifier } from "../import-path.js"; import type { Dialect } from "../metaobjects-config.js"; import { physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "../names.js"; @@ -113,7 +113,7 @@ export function renderProjectionDecl( const ref = field.attr(FIELD_ATTR_OBJECT_REF); const rawRef = typeof ref === "string" ? ref : ""; const name = ctx - ? ctx.resolveValueObjectName(rawRef, fieldDeclaringPackage(field, projectionPkg)) + ? ctx.resolveValueObjectName(field) : stripPackage(rawRef); const module = ctx ? valueObjectModuleSpecifier(name, ctx.packageOf, projectionPkg, ctx.outputLayout, ctx.extStyle) diff --git a/server/typescript/packages/codegen-ts/src/templates/queries.ts b/server/typescript/packages/codegen-ts/src/templates/queries.ts index 0858611bd..d51777ea5 100644 --- a/server/typescript/packages/codegen-ts/src/templates/queries.ts +++ b/server/typescript/packages/codegen-ts/src/templates/queries.ts @@ -4,10 +4,13 @@ import { code, imp, type Code } from "ts-poet"; import { type MetaObject, + IDENTITY_REFERENCE_ATTR_REFERENCES, + isMetaObject, + refTarget, stripPackage, } from "@metaobjectsdev/metadata"; import type { RenderContext } from "../render-context.js"; -import { getPkInfo, subTypeToTsType } from "../pk-resolver.js"; +import { getPkInfo, pkInfoOf, subTypeToTsType } from "../pk-resolver.js"; import { supportsReturning } from "../dialect-module.js"; import { findByIdFnName, @@ -218,8 +221,10 @@ export async function ${fnName}(db: Db, ${pkField}: ${pkType}): Promise interface ReverseFk { /** FK FIELD name on this entity (logical), e.g. `currentSceneId`. */ fkField: string; - /** Target entity (the `T` referenced), e.g. `Scene`. Drives the value TS type. */ + /** Target entity name (the `T` referenced), e.g. `Scene`. An identifier, never a lookup key. */ targetEntity: string; + /** The entity `@references` binds to (ADR-0059 R7). Its PK type drives the value TS type. */ + target?: MetaObject | undefined; } /** Collect this entity's OWN reverse FK targets, in declaration order. */ @@ -227,9 +232,11 @@ export function reverseFksFor(entity: MetaObject): ReverseFk[] { const out: ReverseFk[] = []; for (const ref of entity.referenceIdentities()) { const fkField = ref.fields[0]; - const target = ref.targetEntity; - if (!fkField || !target) continue; - out.push({ fkField, targetEntity: stripPackage(target) }); + const targetRef = ref.targetEntity; + if (!fkField || !targetRef) continue; + const bound = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); + const target = isMetaObject(bound) ? bound : undefined; + out.push({ fkField, targetEntity: target?.name ?? stripPackage(targetRef), target }); } return out; } @@ -244,7 +251,7 @@ export function renderReverseFinderFns(entity: MetaObject, fk: ReverseFk, ctx: R // name is the argument to integer()/text()), so column access uses fk.fkField. // The FK value type is the target entity's PK type (the FK references it). Fall // back to the FK field's own subType, then to number (long-shaped key default). - const targetPk = ctx.pkMap.get(fk.targetEntity); + const targetPk = fk.target === undefined ? undefined : pkInfoOf(fk.target); const ownField = entity.findField(fk.fkField); const valueType = subTypeToTsType(targetPk?.fieldSubType ?? ownField?.subType ?? "long"); diff --git a/server/typescript/packages/codegen-ts/src/templates/relations-block.ts b/server/typescript/packages/codegen-ts/src/templates/relations-block.ts index 807df46f5..8c6694a6c 100644 --- a/server/typescript/packages/codegen-ts/src/templates/relations-block.ts +++ b/server/typescript/packages/codegen-ts/src/templates/relations-block.ts @@ -8,7 +8,8 @@ import { type RenderContext } from "../render-context.js"; import { crossEntitySpecifier } from "../import-path.js"; import type { RelationEntry } from "../relation-resolver.js"; import { effectivePackage } from "../docs-paths.js"; -import { tphStorageName } from "../tph.js"; +import { tphStorageObject } from "../tph.js"; +import { pkInfoOf } from "../pk-resolver.js"; /** * Render the relations() block for one entity. @@ -32,7 +33,7 @@ export function renderRelationsBlock(entity: MetaObject, ctx: RenderContext): Co const thisEntityPackage = effectivePackage(entity); const lines: Code[] = entries.map((entry) => - renderRelationEntry(entry, ctx, entity.name, varName, thisEntityPackage), + renderRelationEntry(entry, ctx, entity, varName, thisEntityPackage), ); return code`export const ${relationsVarName} = ${relationsFn}(${varName}, (${params}) => ({ @@ -44,7 +45,7 @@ ${joinCode(lines, { on: ",\n", trim: false })} function renderRelationEntry( entry: RelationEntry, ctx: RenderContext, - thisEntityName: string, + thisEntity: MetaObject, thisVarName: string, thisEntityPackage: string | undefined, ): Code { @@ -53,11 +54,11 @@ function renderRelationEntry( // then hops junction → target via the junction's one() sides). The // mountM2mRoute the routes generator emits performs the flattened two-stage // traversal for the REST contract. - if (entry.cardinality === CARDINALITY_MANY && entry.junctionEntity !== undefined) { + if (entry.cardinality === CARDINALITY_MANY && entry.junctionEntity !== undefined && entry.junction !== undefined) { const junctionSpec = crossEntitySpecifier( ctx.outputLayout, thisEntityPackage, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, ); @@ -67,22 +68,23 @@ function renderRelationEntry( // Bind to the table that stores the target's rows: a TPH subtype's module has no // table const, so a navigation onto `Carrier` binds `parties` from `Party`. - const targetTableEntity = tphStorageName(entry.targetEntity, ctx.loadedRoot); + const targetTable = tphStorageObject(entry.target); + const targetTableEntity = targetTable.name; // A navigation onto this entity's OWN table (`Order.parent`, or a TPH base onto one of // its subtypes) names the local const: importing it from this very module clashes with // its declaration (TS2440). Otherwise imp() tracks and emits the cross-entity import. - const targetVarSym = targetTableEntity === thisEntityName + const targetVarSym = targetTable === thisEntity ? thisVarName : imp(`${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, thisEntityPackage, - ctx.packageOf.get(targetTableEntity), + effectivePackage(targetTable), targetTableEntity, ctx.extStyle, )}`); if (entry.cardinality === CARDINALITY_ONE) { - const pkInfo = ctx.pkMap.get(targetTableEntity); + const pkInfo = pkInfoOf(targetTable); const targetPkField = pkInfo?.fieldName ?? "id"; return code` ${entry.name}: one(${targetVarSym}, { fields: [${thisVarName}.${entry.fkField ?? "id"}], references: [${targetVarSym}.${targetPkField}] })`; } diff --git a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts index f4cd62a0e..296aded88 100644 --- a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts +++ b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts @@ -42,7 +42,7 @@ import { TEMPLATE_ATTR_HTML_BODY_REF, TEMPLATE_ATTR_TEXT_BODY_REF, isMetaField, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import { verify, @@ -57,12 +57,6 @@ import type { ExtStyle } from "../metaobjects-config.js"; import type { RenderContext } from "../render-context.js"; import { valueObjectImport } from "./value-object-import.js"; -// ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident. -// ADR-0042: package-local — resolveObjectRef binds a bare @objectRef in `referrerPkg` first (else root-level), an FQN exactly. -function findObject(root: MetaData, name: string, referrerPkg: string): MetaData | undefined { - return resolveObjectRef(root, name, referrerPkg).node; -} - // ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident. function findTemplate(root: MetaData, name: string): MetaData | undefined { return root.children().find((c) => c.type === TYPE_TEMPLATE && c.name === name); @@ -87,38 +81,26 @@ import type { ${payloadTypeName} } from ${JSON.stringify(payloadSpecifier)};`; /** * Walk an `object.value` view-object into a render `PayloadField[]`. Object-ref - * fields recurse into their referenced view-object; a `seen` set guards a - * (pathological) reference cycle. Replicates cli's `derivePayloadFieldTree` — + * fields recurse into the view-object the loader bound for their `@objectRef` + * (ADR-0059 R7); a `seen` set guards a (pathological) reference cycle. Replicates cli's `derivePayloadFieldTree` — * codegen-ts must not depend on the cli package (wrong layer / would cycle), so * the same small walk lives here against the metadata constants codegen-ts * already imports. */ function derivePayloadFieldTree( - root: MetaData, - voName: string, - referrerPkg: string, - seen: ReadonlySet = new Set(), + vo: MetaData | undefined, + seen: ReadonlySet = new Set(), ): PayloadField[] { - if (seen.has(voName)) return []; - const vo = findObject(root, voName, referrerPkg); - if (!vo) return []; - // ADR-0042: a nested @objectRef resolves in the FIELD's own declaring package - // (fallback below), which differs from this VO's when the field is inherited via - // extends from an abstract VO elsewhere. - const voPkg = vo.package ?? vo.fileDefaultPackage ?? ""; - const nextSeen = new Set(seen).add(voName); + if (vo === undefined || seen.has(vo)) return []; + const nextSeen = new Set(seen).add(vo); const fields: PayloadField[] = []; for (const f of vo.children().filter(isMetaField)) { // ADR-0039: effective array-ness — `isArray` may be inherited via extends. Carried only // when true, so a helper for a payload with no list field is byte-identical. const arrayness = f.resolvedIsArray() ? { isArray: true } : {}; - if (f.subType === FIELD_SUBTYPE_OBJECT) { - const ref = f.attr(FIELD_ATTR_OBJECT_REF); - if (typeof ref === "string") { - const fieldPkg = f.parent?.package ?? f.parent?.fileDefaultPackage ?? voPkg; - fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen), ...arrayness }); - continue; - } + if (f.subType === FIELD_SUBTYPE_OBJECT && typeof f.attr(FIELD_ATTR_OBJECT_REF) === "string") { + fields.push({ name: f.name, fields: derivePayloadFieldTree(refTarget(f, FIELD_ATTR_OBJECT_REF), nextSeen), ...arrayness }); + continue; } fields.push({ name: f.name, ...arrayness }); } @@ -205,22 +187,20 @@ export function renderRenderHelper( if (typeof payloadRef !== "string") { throw new Error(`template "${templateName}" missing @payloadRef`); } - // ADR-0042: a bare @payloadRef resolves in the template's package. - const tmplPkg = tmpl.package ?? tmpl.fileDefaultPackage ?? ""; - const vo = findObject(root, payloadRef, tmplPkg); + // ADR-0059 R7: the value object the loader bound for @payloadRef. + const vo = refTarget(tmpl, TEMPLATE_ATTR_PAYLOAD_REF); if (!vo) { throw new Error( `template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`, ); } - const fields = derivePayloadFieldTree(root, payloadRef, tmplPkg); + const fields = derivePayloadFieldTree(vo); const ft = fieldTreeLiteral(fields); // Same base as promptRender()'s handle and outputParser()'s symbols (naming.ts). const fnName = `render${templateSymbolBase(templateName)}`; // ADR-0056 — the payload is the value object's own interface: its emitted name and module - // come from the one helper every template-tier file uses. Package-scoped resolution above - // (findObject/derivePayloadFieldTree) intentionally keeps using the raw `payloadRef`. + // come from the one helper every template-tier file uses. const { name: payloadTypeName, specifier: payloadSpecifier } = valueObjectImport(ctx, vo, outPath, extStyle); diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts index d15523205..91602f7f2 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts @@ -30,7 +30,7 @@ import { routesHandlerName } from "../naming.js"; import { isProjection, isWriteThrough } from "../projection/projection-detector.js"; import { isReport } from "../source-detect.js"; import { itemRouteField, reportDecimalColumns } from "../api-surface.js"; -import { DEFAULT_ID_FIELD } from "../pk-resolver.js"; +import { DEFAULT_ID_FIELD, pkInfoOf } from "../pk-resolver.js"; import type { RelationEntry } from "../relation-resolver.js"; import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js"; import { authSeamJsDoc, type CrudVerb, exposeLine, intersectExpose, TPH_POLYMORPHIC_VERBS } from "../routes-expose.js"; @@ -278,9 +278,10 @@ ${m2mMountsFlat}} function m2mEntriesOf( ctx: RenderContext, name: string, -): Array { +): Array { return (ctx.relationMap.get(name) ?? []).filter( - (e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined, + (e): e is RelationEntry & { junctionEntity: string; junction: MetaObject } => + e.junctionEntity !== undefined && e.junction !== undefined, ); } @@ -293,7 +294,7 @@ function m2mEntriesOf( * has no M:N relationships — CRUD-only output stays byte-identical to before. */ function renderM2mMounts( - entries: ReadonlyArray, + entries: ReadonlyArray, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -337,7 +338,7 @@ interface TphM2mSource { * column). mountM2mRoute appends `/:id/` to the source $path. */ function renderM2mMount( - entry: RelationEntry & { junctionEntity: string }, + entry: RelationEntry & { junctionEntity: string; junction: MetaObject }, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -352,7 +353,7 @@ function renderM2mMount( `${ctx.collectionName(entry.junctionEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, )}`, @@ -361,38 +362,31 @@ function renderM2mMount( // subtype has no table const, and the junction FK can only point at the base table — // and filters the rows to the subtype, because a Broker id in that FK column is not a // Carrier. Every other target binds to itself, and emits no filter. - const declaredTarget = ctx.loadedRoot.findObject(entry.targetEntity); - const target = declaredTarget === undefined ? undefined : tphStorageObject(declaredTarget); - const targetTableEntity = target?.name ?? entry.targetEntity; - const pin = declaredTarget === undefined ? undefined : tphDiscriminatorPin(declaredTarget); + const target = tphStorageObject(entry.target); + const targetTableEntity = target.name; + const pin = tphDiscriminatorPin(entry.target); // A self-join's target table IS the source table, which this file already imports from // the entity module; a second import of the same binding is TS2300, and a SyntaxError // when Node loads the module. - const targetVarSym = targetTableEntity === source.name + const targetVarSym = target === source ? ctx.collectionName(source.name) : imp( `${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(targetTableEntity), + effectivePackage(target), targetTableEntity, ctx.extStyle, )}`, ); const mountM2mRouteSym = imp(`mountM2mRoute@${httpRuntimeSpecifier("drizzle-fastify", ctx, sourcePkg)}`); - const junction = ctx.loadedRoot.findObject(entry.junctionEntity); + const junction = entry.junction; // fromPackage = source.package: this routes file is SOURCE's own module, never the // junction's or the target's — see resolveJunctionColumn's doc comment (B1). - const sourceColumn: Code = junction - ? resolveJunctionColumn(junction, entry.sourceJoinField!, ctx, sourcePkg) - : code`${JSON.stringify(entry.sourceJoinField!)}`; - const targetColumn: Code = junction - ? resolveJunctionColumn(junction, entry.targetJoinField!, ctx, sourcePkg) - : code`${JSON.stringify(entry.targetJoinField!)}`; - const targetPkColumn: Code = target - ? resolveJunctionColumn(target, ctx.pkMap.get(targetTableEntity)?.fieldName ?? "id", ctx, sourcePkg) - : code`${JSON.stringify("id")}`; - const discriminatorLine: Code | string = pin !== undefined && target !== undefined + const sourceColumn: Code = resolveJunctionColumn(junction, entry.sourceJoinField!, ctx, sourcePkg); + const targetColumn: Code = resolveJunctionColumn(junction, entry.targetJoinField!, ctx, sourcePkg); + const targetPkColumn: Code = resolveJunctionColumn(target, pkInfoOf(target)?.fieldName ?? "id", ctx, sourcePkg); + const discriminatorLine: Code | string = pin !== undefined ? code` targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },` : ""; @@ -527,7 +521,7 @@ function renderTphRoutesFile( // compile gate stayed green while the endpoint 404'd. // // The physical columns stage 0 needs, resolved once against the base's own table. - const basePkField = ctx.pkMap.get(baseName)?.fieldName ?? "id"; + const basePkField = pkInfoOf(base)?.fieldName ?? "id"; const basePkColumn = resolveJunctionColumn(base, basePkField, ctx, basePkg); const baseDiscColumn = resolveJunctionColumn(base, discField, ctx, basePkg); const baseM2mMounts = renderM2mMounts(m2mEntriesOf(ctx, baseName), base, ctx, fastifyRef); diff --git a/server/typescript/packages/codegen-ts/src/templates/trace-helper.ts b/server/typescript/packages/codegen-ts/src/templates/trace-helper.ts index 8206ab19b..7005a106a 100644 --- a/server/typescript/packages/codegen-ts/src/templates/trace-helper.ts +++ b/server/typescript/packages/codegen-ts/src/templates/trace-helper.ts @@ -27,7 +27,7 @@ import { TEMPLATE_ATTR_FORMAT, RESPONSE_FORMAT_XML, TEMPLATE_ATTR_TEXT_REF, - resolveObjectRef, + refTarget, } from "@metaobjectsdev/metadata"; import type { MetaData, MetaObject } from "@metaobjectsdev/metadata"; import { libraryManifests } from "@metaobjectsdev/metadata/library"; @@ -126,11 +126,10 @@ export function renderTraceHelper(entity: MetaObject, ctx: GenContext, outPath: const entityName = entity.name; const fnName = `record${toPascalCase(entityName)}`; - // ADR-0056: the request and response types are the value objects' OWN interfaces. ADR-0042: - // a bare ref resolves in the prompt's package first. - const promptPkg = prompt.package ?? prompt.fileDefaultPackage ?? ""; - const requestVo = resolveObjectRef(ctx.loadedRoot, payloadRef, promptPkg).node; - const responseVo = resolveObjectRef(ctx.loadedRoot, responseRef, promptPkg).node; + // ADR-0056: the request and response types are the value objects' OWN interfaces. ADR-0059 + // R7: each is the value object the loader bound for the prompt's ref. + const requestVo = refTarget(prompt, TEMPLATE_ATTR_PAYLOAD_REF); + const responseVo = refTarget(prompt, TEMPLATE_ATTR_RESPONSE_REF); if (requestVo === undefined || responseVo === undefined) return undefined; const extStyle = ctx.config.extStyle ?? "js"; const requestImport = valueObjectImport(ctx.renderContext, requestVo, outPath, extStyle); diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index a4c59592c..47305ddc2 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -46,7 +46,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { sharedEnumForField } from "../enum-shared.js"; import { sharedEnumImportSpecifier } from "../enum-import.js"; import { sharedEnumZodConstName } from "./enums-file.js"; -import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; +import type { RenderContext } from "../render-context.js"; import { valueObjectModuleSpecifier } from "../import-path.js"; // FR-035: the SAME required-predicate that drives the Drizzle column's .notNull() // drives the UpdateSchema's .nullable() exclusion — shared so they cannot drift. @@ -626,7 +626,7 @@ function zodFieldExpr( // Drizzle .$type<> use) so all three agree. Without owner/ctx (bare // unit-test calls) fall back to the bare name + flat same-dir. const ownerPkg = (ctx && owner) ? effectivePackage(owner) : undefined; - const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, ownerPkg)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, ownerPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -648,7 +648,7 @@ function zodFieldExpr( const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const ownerPkg = (ctx && owner) ? effectivePackage(owner) : undefined; - const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, ownerPkg)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, ownerPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/server/typescript/packages/codegen-ts/test/cube/cube-model-orphans.test.ts b/server/typescript/packages/codegen-ts/test/cube/cube-model-orphans.test.ts index 986c4b5d7..1f75e10c5 100644 --- a/server/typescript/packages/codegen-ts/test/cube/cube-model-orphans.test.ts +++ b/server/typescript/packages/codegen-ts/test/cube/cube-model-orphans.test.ts @@ -126,7 +126,7 @@ for (const impl of IMPLEMENTATIONS) { expect(removed(result)).toEqual([abs(WEEK_CUBE)]); expect(readFileSync(abs(PROGRAM_CUBE), "utf8")).toBe(program); // A removal is not a problem and must not masquerade as one. - expect(result.warnings.filter((w) => w.includes("no longer produced"))).toEqual([]); + expect(result.warnings.filter((w) => w.includes("no longer generated"))).toEqual([]); }); test("a run that names entities removes nothing, and says it skipped the cleanup", async () => { @@ -157,7 +157,7 @@ for (const impl of IMPLEMENTATIONS) { expect(readFileSync(abs(WEEK_CUBE), "utf8")).toBe(edited); expect(removed(result)).toEqual([]); - const refusal = result.warnings.find((w) => w.includes("no longer produced")); + const refusal = result.warnings.find((w) => w.includes("no longer generated")); expect(refusal).toBeDefined(); expect(refusal).toContain("Week.yml"); expect(refusal).toContain("cube-model"); diff --git a/server/typescript/packages/codegen-ts/test/golden/cross-package-binding-conformance.test.ts b/server/typescript/packages/codegen-ts/test/golden/cross-package-binding-conformance.test.ts new file mode 100644 index 000000000..400dee411 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/golden/cross-package-binding-conformance.test.ts @@ -0,0 +1,169 @@ +// Cross-port conformance (TS — the reference) for ADR-0059 Decision 7: codegen binds the +// object a reference names, never a same-short-named object and never the object an +// inheritor's package would pick. Loads the shared fixture +// `fixtures/codegen-conformance/cross-package-binding/input/`, checks every binding against +// the port-neutral decision table beside it (`expected-bindings.json`), and checks the +// generated entity, queries and routes modules import the bound target's module. +// +// The fixture loads the wrong candidate first (`a::Tag` before `b::Tag`, `acme::Customer` +// before the root `Customer`), so a short-name lookup or a re-resolve in `acme` binds it. + +import { describe, test, expect } from "bun:test"; +import { mkdtempSync, rmSync, readFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, relative } from "node:path"; +import { + MetaDataLoader, + InMemoryStringSource, + IDENTITY_REFERENCE_ATTR_REFERENCES, + isMetaObject, + isMetaSource, + refTarget, + type MetaObject, + type MetaRoot, +} from "@metaobjectsdev/metadata"; +import { runGen, defineConfig } from "../../src/index.js"; +import { entityFile } from "../../src/generators/entity-file.js"; +import { queriesFile } from "../../src/generators/queries-file.js"; +import { routesFile } from "../../src/generators/routes-file.js"; +import { buildRelationMap } from "../../src/relation-resolver.js"; + +function findRepoRoot(start: string): string { + let dir = start; + while (true) { + if (existsSync(join(dir, "fixtures")) && existsSync(join(dir, "server"))) return dir; + const parent = dirname(dir); + if (parent === dir) throw new Error("could not locate repo root from " + start); + dir = parent; + } +} + +const FIXTURE = join(findRepoRoot(import.meta.dir), "fixtures", "codegen-conformance", "cross-package-binding"); + +interface RelationRow { + cardinality: "one" | "many"; + target: string; + targetTable: string; + fkField?: string; + junction?: string; + junctionTable?: string; + sourceJoinField?: string; + targetJoinField?: string; +} + +const table: { relations: Record; references: Record } = JSON.parse( + readFileSync(join(FIXTURE, "expected-bindings.json"), "utf-8"), +); + +async function loadFixture(): Promise { + const dir = join(FIXTURE, "input"); + const sources = readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .sort() + .map((f) => new InMemoryStringSource(readFileSync(join(dir, f), "utf-8"), { id: f, format: "json" })); + const res = await new MetaDataLoader().load(sources); + expect(res.errors, "the cross-package-binding fixture must load cleanly").toEqual([]); + return res.root; +} + +/** `pkg::Owner.member` → the top-level object and the member name. */ +function split(key: string): { owner: string; member: string } { + const dot = key.lastIndexOf("."); + return { owner: key.slice(0, dot), member: key.slice(dot + 1) }; +} + +function objectByKey(root: MetaRoot, key: string): MetaObject { + const obj = root.objects().find((o) => o.resolutionKey() === key); + if (obj === undefined) throw new Error(`no object ${key}`); + return obj; +} + +function tableOf(obj: MetaObject): string | undefined { + // ADR-0039: resolving — a source may be inherited. + const source = obj.children().find(isMetaSource); + const t = source?.attr("table"); + return typeof t === "string" ? t : undefined; +} + +describe("cross-package binding conformance (TS)", () => { + test("every identity.reference binds the object the decision table names", async () => { + const root = await loadFixture(); + for (const [key, expected] of Object.entries(table.references)) { + const { owner, member } = split(key); + const ref = objectByKey(root, owner).referenceIdentities().find((r) => r.name === member); + expect(ref, key).toBeDefined(); + expect(refTarget(ref!, IDENTITY_REFERENCE_ATTR_REFERENCES)?.resolutionKey(), key).toBe(expected); + } + }); + + test("the relation map binds every relationship's target, junction, tables and join fields", async () => { + const root = await loadFixture(); + const map = buildRelationMap(root); + for (const [key, row] of Object.entries(table.relations)) { + const { owner, member } = split(key); + const source = objectByKey(root, owner); + const entry = (map.get(source.name) ?? []).find((e) => e.name === member); + expect(entry, key).toBeDefined(); + expect(entry!.cardinality, key).toBe(row.cardinality); + expect(entry!.target.resolutionKey(), key).toBe(row.target); + expect(tableOf(entry!.target), key).toBe(row.targetTable); + if (row.cardinality === "one") { + expect(entry!.fkField, key).toBe(row.fkField); + } else { + expect(isMetaObject(entry!.junction) ? entry!.junction.resolutionKey() : undefined, key).toBe(row.junction); + expect(tableOf(entry!.junction!), key).toBe(row.junctionTable); + expect(entry!.sourceJoinField, key).toBe(row.sourceJoinField); + expect(entry!.targetJoinField, key).toBe(row.targetJoinField); + } + } + }); + + test("generated modules import the bound target's module, never the wrong same-named one", async () => { + const root = await loadFixture(); + const tmp = mkdtempSync(join(tmpdir(), "xpkg-binding-")); + const files: Record = {}; + try { + await runGen({ + config: defineConfig({ + outDir: tmp, + outputLayout: "package", + extStyle: "none", + dbImport: "~/db", + dialect: "postgres", + generators: [entityFile(), queriesFile(), routesFile()], + }), + metadata: root, + }); + const walk = (dir: string): void => { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) walk(full); + else if (name.endsWith(".ts")) files[relative(tmp, full)] = readFileSync(full, "utf-8"); + } + }; + walk(tmp); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + // a::Post — the M:N route and the to-one relation reach b::Tag's module. + const postRoutes = files[join("a", "Post.routes.ts")]; + expect(postRoutes, Object.keys(files).join(", ")).toBeDefined(); + expect(postRoutes).toContain('from "../b/Tag"'); + expect(postRoutes).not.toContain('from "./Tag"'); + const post = files[join("a", "Post.ts")]!; + expect(post).toContain('from "../b/Tag"'); + expect(post).not.toContain('from "./Tag"'); + // b::Tag's key is a long; a::Tag's is a string. The reverse finder takes the bound type. + const postQueries = files[join("a", "Post.queries.ts")]!; + expect(postQueries).toContain("featuredTagId: number"); + + // acme::Order inherits root BaseOrder's bare `Customer` / `Label`: both mean the root ones. + const orderRoutes = files[join("acme", "Order.routes.ts")]!; + expect(orderRoutes).toContain('from "../Label"'); + expect(orderRoutes).not.toContain('from "./Label"'); + const order = files[join("acme", "Order.ts")]!; + expect(order).toContain('from "../Customer"'); + expect(order).not.toContain('from "./Customer"'); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/golden/template-source-conformance.test.ts b/server/typescript/packages/codegen-ts/test/golden/template-source-conformance.test.ts index 6def20ebc..586e94343 100644 --- a/server/typescript/packages/codegen-ts/test/golden/template-source-conformance.test.ts +++ b/server/typescript/packages/codegen-ts/test/golden/template-source-conformance.test.ts @@ -319,7 +319,7 @@ describe("annotator ⇆ verify agreement — linked set equals verify's accepted it("for each resolved template source: linked variables == NOT-flagged variables", async () => { const { root, inputDir } = await loadFixture(); const provider = projectProvider(inputDir); - const tree = buildEnrichedPayloadTree(root, "Order", "acme::shop"); + const tree = buildEnrichedPayloadTree(root.findObject("acme::shop::Order")); const plain = toPlainTree(tree); // Every mustache source the two templates reference. diff --git a/server/typescript/packages/codegen-ts/test/orphan-sweep.test.ts b/server/typescript/packages/codegen-ts/test/orphan-sweep.test.ts index 97c1da05c..13bab733f 100644 --- a/server/typescript/packages/codegen-ts/test/orphan-sweep.test.ts +++ b/server/typescript/packages/codegen-ts/test/orphan-sweep.test.ts @@ -236,7 +236,7 @@ describe("sweepOrphans", () => { dryRun: false, }); - expect(result).toEqual({ removed: [], refused: [], forced: [], refusedBy: new Map() }); + expect(result).toEqual({ removed: [], refused: [], forced: [], refusedBy: new Map(), forcedBy: new Map() }); expect(existsSync(join(root, STUB_A))).toBe(true); }); diff --git a/server/typescript/packages/codegen-ts/test/overwrite-policy.test.ts b/server/typescript/packages/codegen-ts/test/overwrite-policy.test.ts index b5d95ff83..f7e6d6ef9 100644 --- a/server/typescript/packages/codegen-ts/test/overwrite-policy.test.ts +++ b/server/typescript/packages/codegen-ts/test/overwrite-policy.test.ts @@ -6,7 +6,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { decideAndWrite } from "../src/overwrite-policy.js"; +import { contentHash, decideAndWrite, forgetGeneratedPaths, readGeneratedHash } from "../src/overwrite-policy.js"; let tmp: string; let state: string; @@ -92,3 +92,34 @@ describe("decideAndWrite (three-way merge)", () => { expect(result.status).toBe("skipped"); }); }); + +describe("shared manifest with object-valued entries", () => { + test("reads an object entry's hash and preserves its fields on record and forget", () => { + const jvm = { hash: contentHash("jvm body"), generator: "java:dto", outputDir: "target/gen" }; + const manifest = join(state, ".hashes.json"); + writeFileSync(manifest, JSON.stringify({ "Jvm.java": jvm, "Plain.txt": "abc", "Gone.java": { ...jvm } }, null, 2) + "\n"); + + expect(readGeneratedHash(state, "Jvm.java")).toBe(jvm.hash); + + const result = decideAndWrite(join(tmp, "Other.ts"), "x\n", { + genStateDir: state, + outputRelPath: "Other.ts", + }); + expect(result.status).toBe("new"); + + forgetGeneratedPaths(state, ["Gone.java"]); + + const saved = JSON.parse(readFileSync(manifest, "utf-8")); + expect(saved["Jvm.java"]).toEqual(jvm); + expect(saved["Plain.txt"]).toBe("abc"); + expect(saved["Other.ts"]).toBe(contentHash("x\n")); + expect("Gone.java" in saved).toBe(false); + + const jvmFile = join(tmp, "Jvm.java"); + writeFileSync(jvmFile, "jvm body"); + const rewrite = decideAndWrite(jvmFile, "jvm body v2", { genStateDir: state, outputRelPath: "Jvm.java" }); + expect(rewrite.status).not.toBe("refused"); + const after = JSON.parse(readFileSync(manifest, "utf-8")); + expect(after["Jvm.java"]).toEqual({ ...jvm, hash: contentHash("jvm body v2") }); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts index a336e6d5d..71ca27e50 100644 --- a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts @@ -513,11 +513,12 @@ describe("ADR-0034 — the requirement-tests reference over a ledger that declar { label: "the uncovered warning switched off", opts: { warnUncovered: false }, files: 6 }, // Seven of the eight requirements uncovered: five are named and the rest counted. { label: "more uncovered requirements than the warning names", opts: { filter: (r) => r.path === "Orders.Recorded" }, files: 1, warns: ", and 2 more." }, - // A custom `path` with no `owns`: the stubs move and the generator says it can no - // longer clean up after a deleted requirement. - { label: "a custom path without owns", opts: { path: specPath }, files: 6, warns: "a custom 'path' was supplied without a matching 'owns'" }, + // A custom `path` with no `owns`: the stubs move, the policy claims nothing, and the + // run-wide sweep cleans up after a deleted requirement, so there is nothing to warn about + // until `reconcileOrphans: false` asks to keep files it cannot name. + { label: "a custom path without owns", opts: { path: specPath }, files: 6, warns: "2 requirement(s) matched no filter" }, { label: "a custom path with its owns, under member grain", opts: { path: specPath, owns: ownsSpecs, grain: "member", forceOrphanDelete: true }, files: 8, warns: "2 requirement(s) matched no filter" }, - { label: "a custom path with orphan reconciliation off", opts: { path: specPath, reconcileOrphans: false, warnUncovered: false }, files: 6 }, + { label: "a custom path with orphan reconciliation off", opts: { path: specPath, reconcileOrphans: false, warnUncovered: false }, files: 6, warns: "cannot tell which files are its stubs" }, ]; for (const run of RUNS) { @@ -595,6 +596,7 @@ describe("ADR-0034 — the requirement-tests reference is the same GENERATOR, no target: g.target, reconciles: g.orphanPolicy !== undefined, force: g.orphanPolicy?.force, + keep: g.orphanPolicy?.keep, owns: g.orphanPolicy === undefined ? null : SAMPLE_PATHS.filter((p) => g.orphanPolicy?.owns(p)), }); @@ -609,6 +611,7 @@ describe("ADR-0034 — the requirement-tests reference is the same GENERATOR, no target: undefined, reconciles: true, force: undefined, + keep: undefined, owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], }, }, @@ -620,6 +623,7 @@ describe("ADR-0034 — the requirement-tests reference is the same GENERATOR, no target: undefined, reconciles: true, force: undefined, + keep: undefined, owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], }, }, @@ -631,13 +635,14 @@ describe("ADR-0034 — the requirement-tests reference is the same GENERATOR, no target: "api-tests", reconciles: true, force: undefined, + keep: undefined, owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], }, }, { label: "a custom path without owns claims NOTHING", opts: { path: specPath }, - expected: { name: "requirement-tests", target: undefined, reconciles: true, force: undefined, owns: [] }, + expected: { name: "requirement-tests", target: undefined, reconciles: true, force: undefined, keep: undefined, owns: [] }, }, { label: "a custom path with owns claims exactly what owns says", @@ -647,6 +652,7 @@ describe("ADR-0034 — the requirement-tests reference is the same GENERATOR, no target: undefined, reconciles: true, force: undefined, + keep: undefined, owns: ["specs/Orders.Recorded.object.entity.spec.ts"], }, }, @@ -658,13 +664,21 @@ describe("ADR-0034 — the requirement-tests reference is the same GENERATOR, no target: undefined, reconciles: true, force: true, + keep: undefined, owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], }, }, { - label: "reconcileOrphans: false declares no policy at all", + label: "reconcileOrphans: false keeps its namespace, and force does not apply", opts: { reconcileOrphans: false, forceOrphanDelete: true }, - expected: { name: "requirement-tests", target: undefined, reconciles: false, force: undefined, owns: null }, + expected: { + name: "requirement-tests", + target: undefined, + reconciles: true, + force: undefined, + keep: true, + owns: ["requirements/Orders.Recorded.object.entity.test.ts", "requirements/deep/nested.test.ts"], + }, }, ]; diff --git a/server/typescript/packages/codegen-ts/test/requirement-orphans-e2e.test.ts b/server/typescript/packages/codegen-ts/test/requirement-orphans-e2e.test.ts index 43085083b..cd67e25cb 100644 --- a/server/typescript/packages/codegen-ts/test/requirement-orphans-e2e.test.ts +++ b/server/typescript/packages/codegen-ts/test/requirement-orphans-e2e.test.ts @@ -170,7 +170,7 @@ describe("requirement stub deletion integrity, through runGen", () => { abs(DROP_STUB), ]); // A removal is not a problem — it should not masquerade as one. - expect(result.warnings.filter((w) => w.includes("no longer produced"))).toEqual([]); + expect(result.warnings.filter((w) => w.includes("no longer generated"))).toEqual([]); }); test("REFUSES to delete a stub whose body was filled in, and says why", async () => { @@ -189,7 +189,7 @@ describe("requirement stub deletion integrity, through runGen", () => { expect(existsSync(abs(DROP_STUB))).toBe(true); expect(readFileSync(abs(DROP_STUB), "utf-8")).toContain("expect(council.slug)"); expect(result.files.filter((f) => f.status === "removed")).toEqual([]); - const refusal = result.warnings.find((w) => w.includes("no longer produced")); + const refusal = result.warnings.find((w) => w.includes("no longer generated")); expect(refusal).toBeDefined(); // Naming the file is the requirement — a refusal nobody can act on gets the // file deleted by hand, which is the outcome the refusal exists to prevent. @@ -203,7 +203,7 @@ describe("requirement stub deletion integrity, through runGen", () => { await gen({ withDrop: false }); const third = await gen({ withDrop: false }); - expect(third.warnings.some((w) => w.includes("no longer produced"))).toBe(true); + expect(third.warnings.some((w) => w.includes("no longer generated"))).toBe(true); expect(existsSync(abs(DROP_STUB))).toBe(true); }); @@ -250,20 +250,29 @@ describe("requirement stub deletion integrity, through runGen", () => { expect(result.files.filter((f) => f.status === "removed")).toEqual([]); }); - test("a custom path with no owns warns, and deletes nothing", async () => { + test("a custom path with no owns: the run-wide sweep still removes the untouched stub", async () => { const generatorOpts = { path: (v: { path: string }, c: string) => `spec/${v.path}__${c}.spec.ts`, }; const first = await gen({ withDrop: true, generatorOpts }); - expect(first.warnings.some((w) => w.includes("without a matching 'owns'"))).toBe(true); + // Nothing to warn about: the policy claims nothing, and the run-wide sweep covers it. + expect(first.warnings.some((w) => w.includes("without a matching 'owns'"))).toBe(false); const custom = join(OUT_DIR, "spec", "drop__object.entity.spec.ts"); expect(existsSync(abs(custom))).toBe(true); - // Claiming nothing is the safe degradation: it can only ever delete less. const result = await gen({ withDrop: false, generatorOpts }); - expect(existsSync(abs(custom))).toBe(true); - expect(result.files.filter((f) => f.status === "removed")).toEqual([]); + expect(existsSync(abs(custom))).toBe(false); + expect(result.files.filter((f) => f.status === "removed").map((f) => f.path)).toEqual([abs(custom)]); + }); + + test("a custom path with no owns and reconcileOrphans: false warns that it cannot keep the stubs", async () => { + const generatorOpts = { + path: (v: { path: string }, c: string) => `spec/${v.path}__${c}.spec.ts`, + reconcileOrphans: false, + }; + const first = await gen({ withDrop: true, generatorOpts }); + expect(first.warnings.some((w) => w.includes("cannot tell which files are its stubs"))).toBe(true); }); test("a custom path WITH owns reconciles correctly and stops warning", async () => { diff --git a/server/typescript/packages/codegen-ts/test/run-wide-orphan-sweep.test.ts b/server/typescript/packages/codegen-ts/test/run-wide-orphan-sweep.test.ts new file mode 100644 index 000000000..3b56ee3c5 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/run-wide-orphan-sweep.test.ts @@ -0,0 +1,218 @@ +// The run-wide orphan sweep: a full `meta gen` deletes a file it recorded writing in a +// target directory that no generator emits any more, refuses one edited since, and never +// acts on a run that did not produce the whole declared output. +// +// The rename case is the one an adopter hit: renaming a value object left the old module +// on disk, `verify --codegen` convicted it and said "run 'meta gen'", and gen never +// deleted anything, so the gate failed the same way after every run. + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { runGen } from "../src/runner.js"; +import { entityFile } from "../src/generators/entity-file.js"; +import type { Generator } from "../src/generator.js"; + +const OUT = "gen"; +const MANIFEST = join(".metaobjects", ".gen-state", ".hashes.json"); + +async function model(...names: string[]): Promise { + const doc = JSON.stringify({ + "metadata.root": { + package: "acme", + children: names.map((name) => ({ + "object.value": { name, children: [{ "field.string": { name: "x" } }] }, + })), + }, + }); + const r = await new MetaDataLoader().load([new InMemoryStringSource(doc)]); + if (r.errors.length > 0) throw new Error(r.errors.map((e) => e.message).join("\n")); + return r.root; +} + +let projectRoot: string; +beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "run-wide-orphans-")); +}); +afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); +}); + +const abs = (rel: string): string => join(projectRoot, rel); +const manifest = (): string => readFileSync(abs(MANIFEST), "utf8"); + +async function gen( + names: string[], + extra: { generators?: Generator[]; entityFilter?: string[]; generatorNames?: string[]; dryRun?: boolean } = {}, +) { + return runGen({ + config: { outDir: OUT, extStyle: "js", generators: extra.generators ?? [entityFile()] }, + metadata: await model(...names), + projectRoot, + ...(extra.entityFilter !== undefined && { entityFilter: extra.entityFilter }), + ...(extra.generatorNames !== undefined && { generatorNames: extra.generatorNames }), + ...(extra.dryRun === true && { dryRun: true }), + }); +} + +describe("run-wide orphan sweep", () => { + test("a renamed value object's old module is deleted and forgotten", async () => { + await gen(["Telemetry"]); + expect(existsSync(abs(`${OUT}/Telemetry.ts`))).toBe(true); + + const result = await gen(["TelemetryReading"]); + expect(existsSync(abs(`${OUT}/TelemetryReading.ts`))).toBe(true); + expect(existsSync(abs(`${OUT}/Telemetry.ts`))).toBe(false); + expect(result.files).toContainEqual({ path: abs(`${OUT}/Telemetry.ts`), status: "removed" }); + expect(manifest()).not.toContain("Telemetry.ts\""); + }); + + test("a hand-edited orphan is refused by name and stays recorded", async () => { + await gen(["Telemetry"]); + writeFileSync(abs(`${OUT}/Telemetry.ts`), `${readFileSync(abs(`${OUT}/Telemetry.ts`), "utf8")}// mine\n`); + + const result = await gen(["TelemetryReading"]); + expect(existsSync(abs(`${OUT}/Telemetry.ts`))).toBe(true); + const refusal = result.warnings.find((w) => w.includes("NOT deleted")); + expect(refusal).toContain(join(OUT, "Telemetry.ts")); + expect(refusal).toContain("Delete each one"); + // Kept, so the refusal repeats until someone resolves it. + expect(manifest()).toContain(join(OUT, "Telemetry.ts")); + }); + + test("a file nobody generated is never touched", async () => { + await gen(["Telemetry"]); + writeFileSync(abs(`${OUT}/helpers.ts`), "export const mine = 1;\n"); + await gen(["TelemetryReading"]); + expect(existsSync(abs(`${OUT}/helpers.ts`))).toBe(true); + }); + + test("an entity-filtered run deletes nothing", async () => { + await gen(["Telemetry", "Other"]); + await gen(["TelemetryReading", "Other"], { entityFilter: ["Other"] }); + expect(existsSync(abs(`${OUT}/Telemetry.ts`))).toBe(true); + }); + + test("a --generators run does not sweep the other entries' output", async () => { + const other: Generator = { + name: "other", + generate: () => [{ path: "other.txt", content: "x\n" }], + }; + await gen(["Telemetry"], { generators: [entityFile(), other] }); + await gen(["Telemetry"], { generators: [entityFile(), other], generatorNames: ["other"] }); + expect(existsSync(abs(`${OUT}/Telemetry.ts`))).toBe(true); + }); + + test("--dry-run reports the deletion and performs none", async () => { + await gen(["Telemetry"]); + const result = await gen(["TelemetryReading"], { dryRun: true }); + expect(result.files).toContainEqual({ path: abs(`${OUT}/Telemetry.ts`), status: "removed" }); + expect(existsSync(abs(`${OUT}/Telemetry.ts`))).toBe(true); + }); +}); + +// `handEdits` on a generator entry: "keep" (the default) protects a hand edit, "overwrite" +// discards it, and names each discarded edit so it is never silent. +describe("handEdits", () => { + const overwriting = (): Generator => ({ ...entityFile(), handEdits: "overwrite" }); + const edit = (rel: string): void => writeFileSync(abs(rel), `${readFileSync(abs(rel), "utf8")}// mine\n`); + + test("keep preserves an edited file (merged here, where a snapshot body exists)", async () => { + await gen(["Telemetry"]); + edit(`${OUT}/Telemetry.ts`); + const result = await gen(["Telemetry", "Other"]); + expect(readFileSync(abs(`${OUT}/Telemetry.ts`), "utf8")).toContain("// mine"); + expect(result.warnings.filter((w) => w.includes("overwrote hand-edited"))).toEqual([]); + }); + + test("overwrite replaces an edited file, re-baselines it, and says so", async () => { + await gen(["Telemetry"], { generators: [overwriting()] }); + edit(`${OUT}/Telemetry.ts`); + const result = await gen(["Telemetry"], { generators: [overwriting()] }); + expect(readFileSync(abs(`${OUT}/Telemetry.ts`), "utf8")).not.toContain("// mine"); + expect(result.warnings).toContainEqual(expect.stringContaining(`overwrote hand-edited ${join(OUT, "Telemetry.ts")}`)); + // Re-baselined: the next run sees an untouched file and writes nothing. + const again = await gen(["Telemetry"], { generators: [overwriting()] }); + expect(again.warnings.filter((w) => w.includes("hand-edited"))).toEqual([]); + }); + + test("overwrite deletes an edited orphan and names it", async () => { + await gen(["Telemetry"], { generators: [overwriting()] }); + edit(`${OUT}/Telemetry.ts`); + const result = await gen(["TelemetryReading"], { generators: [overwriting()] }); + expect(existsSync(abs(`${OUT}/Telemetry.ts`))).toBe(false); + expect(result.warnings).toContainEqual( + expect.stringContaining(`deleted hand-edited ${join(OUT, "Telemetry.ts")}, which no generator emits any more`), + ); + expect(manifest()).not.toContain("Telemetry.ts\""); + }); + + test("overwrite never touches a file gen has no record of writing", async () => { + await gen(["Telemetry"], { generators: [overwriting()] }); + writeFileSync(abs(`${OUT}/TelemetryReading.ts`), "export const mine = 1;\n"); + const result = await gen(["TelemetryReading"], { generators: [overwriting()] }); + expect(readFileSync(abs(`${OUT}/TelemetryReading.ts`), "utf8")).toBe("export const mine = 1;\n"); + expect(result.files).toContainEqual(expect.objectContaining({ path: abs(`${OUT}/TelemetryReading.ts`), status: "refused" })); + }); + + test("one keep generator in a directory protects its edited orphans", async () => { + const keeper: Generator = { name: "keeper", generate: () => [{ path: "keeper.txt", content: "k\n" }] }; + await gen(["Telemetry"], { generators: [overwriting(), keeper] }); + edit(`${OUT}/Telemetry.ts`); + const result = await gen(["TelemetryReading"], { generators: [overwriting(), keeper] }); + expect(existsSync(abs(`${OUT}/Telemetry.ts`))).toBe(true); + expect(result.warnings.find((w) => w.includes("NOT deleted"))).toContain(join(OUT, "Telemetry.ts")); + }); +}); + +describe("nested output directories", () => { + const writer = (name: string, files: string[], extra: Partial = {}): Generator => ({ + name, + generate: () => files.map((path) => ({ path, content: `${name}\n` })), + ...extra, + }); + + async function nested(generators: Generator[]) { + return runGen({ + config: { + outDir: OUT, + extStyle: "js", + targets: { inner: { outDir: `${OUT}/inner` } }, + generators, + }, + metadata: await model("A"), + projectRoot, + }); + } + + const scenario = async (outerMode: "keep" | "overwrite", innerMode: "keep" | "overwrite", innerFirst: boolean) => { + const build = (innerFile: string): Generator[] => { + const outer = writer("outer", ["outer.txt"], { handEdits: outerMode }); + const inner = writer("inner", [innerFile], { handEdits: innerMode, target: "inner" }); + return innerFirst ? [inner, outer] : [outer, inner]; + }; + await nested(build("old.txt")); + const orphan = abs(`${OUT}/inner/old.txt`); + writeFileSync(orphan, `${readFileSync(orphan, "utf8")}// mine\n`); + const result = await nested(build("new.txt")); + return { result, orphan, rel: join(OUT, "inner", "old.txt") }; + }; + + for (const innerFirst of [false, true]) { + test(`an edited orphan in an inner overwrite directory is judged by it (inner first: ${innerFirst})`, async () => { + const { result, orphan, rel } = await scenario("keep", "overwrite", innerFirst); + expect(existsSync(orphan)).toBe(false); + expect(result.warnings).toContainEqual(expect.stringContaining(`deleted hand-edited ${rel}`)); + expect(result.warnings.filter((w) => w.includes("NOT deleted"))).toEqual([]); + expect(result.warnings.filter((w) => w.includes("orphanPolicy sets force"))).toEqual([]); + }); + + test(`an edited orphan in an inner keep directory is refused (inner first: ${innerFirst})`, async () => { + const { result, orphan, rel } = await scenario("overwrite", "keep", innerFirst); + expect(existsSync(orphan)).toBe(true); + expect(result.warnings.find((w) => w.includes("NOT deleted"))).toContain(rel); + }); + } +}); diff --git a/server/typescript/packages/codegen-ts/test/template-payload-tree.test.ts b/server/typescript/packages/codegen-ts/test/template-payload-tree.test.ts index 27054bd57..ba9ae6fd8 100644 --- a/server/typescript/packages/codegen-ts/test/template-payload-tree.test.ts +++ b/server/typescript/packages/codegen-ts/test/template-payload-tree.test.ts @@ -27,7 +27,7 @@ describe("buildEnrichedPayloadTree — docs annotator payload walk", () => { { "field.object": { name: "fromAlpha", "@objectRef": "acme::alpha::Note" } }, { "field.object": { name: "fromBeta", "@objectRef": "acme::beta::Note" } } ] } } ] } }, ); - const tree = buildEnrichedPayloadTree(root, "Digest", "acme::app"); + const tree = buildEnrichedPayloadTree(root.findObject("acme::app::Digest")); const alpha = tree.find((n) => n.name === "fromAlpha"); const beta = tree.find((n) => n.name === "fromBeta"); // The inner field name comes from the correctly-bound VO (pre-fix both were alphaText). diff --git a/server/typescript/packages/docs-site/src/builders/object-data.ts b/server/typescript/packages/docs-site/src/builders/object-data.ts index 4581e1527..2181a0dd5 100644 --- a/server/typescript/packages/docs-site/src/builders/object-data.ts +++ b/server/typescript/packages/docs-site/src/builders/object-data.ts @@ -131,7 +131,9 @@ function fieldRow(f: MetaData, ownerHref: string, g: LinkGraph, cov: CoverageTra let refHref: string | undefined, refName: string | undefined; const oref = a("objectRef"); if (typeof oref === "string") { - const t = g.byFqn(oref.includes("::") ? oref : `${ctxPkg}::${oref}`) ?? g.byFqn(oref); + // `::Name` is root-absolute (ADR-0059): the loader writes it for a root-level target. + const t = oref.startsWith("::") ? g.byFqn(oref.slice(2)) + : g.byFqn(oref.includes("::") ? oref : `${ctxPkg}::${oref}`) ?? g.byFqn(oref); if (t) { refHref = g.relHref(ownerHref, t.href); refName = t.name; const contains = f.subType === "object"; @@ -224,7 +226,9 @@ export function buildObjectPage(fqn: string, g: LinkGraph, cov: CoverageTracker) } else if (id.subType === "reference") { const ref = id.attr("references"); if (ref !== undefined) cov.consumeAttr(id, "references"); const enf = id.attr("enforce"); if (enf !== undefined) cov.consumeAttr(id, "enforce"); - indexes.push({ name: id.name, kind: "fk", fields, extra: [ref ? `→ ${esc(String(ref))}` : "", enf === false || enf === "false" ? "logical" : "", idExtra].filter(Boolean).join(" · "), unique: false }); + // The page quotes the reference as the author wrote it (ADR-0059); the loaded value is canonical. + const shown = id.authoredAttr("references") ?? ref; + indexes.push({ name: id.name, kind: "fk", fields, extra: [shown ? `→ ${esc(String(shown))}` : "", enf === false || enf === "false" ? "logical" : "", idExtra].filter(Boolean).join(" · "), unique: false }); } } for (const ix of o.childrenOfType("index")) { diff --git a/server/typescript/packages/docs-site/src/link-graph.ts b/server/typescript/packages/docs-site/src/link-graph.ts index 75efd1dd5..b9c9bcb73 100644 --- a/server/typescript/packages/docs-site/src/link-graph.ts +++ b/server/typescript/packages/docs-site/src/link-graph.ts @@ -1,7 +1,10 @@ import type { MetaData, MetaObject, MetaRelationship, MetaRoot } from "@metaobjectsdev/metadata"; import { - deriveM2MFields, reportFrom, resolveRelationshipReference, stripPackage, + deriveM2MFields, resolveRelationshipReference, OBJECT_REPORT_ATTR_FROM, OBJECT_SUBTYPE_REPORT, TYPE_OBJECT, + RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH, IDENTITY_REFERENCE_ATTR_REFERENCES, + FIELD_ATTR_OBJECT_REF, ORIGIN_PASSTHROUGH_ATTR_FROM, TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_RESPONSE_REF, + refTarget, } from "@metaobjectsdev/metadata"; import { type LoadedModel, treeOf } from "./load.js"; @@ -54,16 +57,20 @@ export class LinkGraph { (this._from.get(r.from) ?? this._from.set(r.from, []).get(r.from)!).push(r); (this._to.get(r.to) ?? this._to.set(r.to, []).get(r.to)!).push(r); }; - const resolveRef = (raw: string, ctxPkg: string): string | undefined => { - const cand = raw.includes("::") ? raw : `${ctxPkg}::${raw}`; - return this._nodes.has(cand) ? cand : (this._nodes.has(raw) ? raw : undefined); + // ADR-0059 R7 — an edge points at the object the reference attribute is bound to, + // resolved where the attribute was declared, never re-resolved in an inheritor's package. + const bound = (node: MetaData, attr: string): string | undefined => { + const target = refTarget(node, attr); + if (target === undefined) return undefined; + const key = fqnOf(target); + return this._nodes.has(key) ? key : undefined; }; // M:N through-junction edge. Derives the junction FK fields via the metadata // SSOT (deriveM2MFields) — hetero, directed (@sourceRefField), or symmetric // (@symmetric). On a derivation failure (ambiguous self-join) the edge is // SKIPPED — generation never fails. - const addM2mEdge = (from: string, to: string, rel: MetaRelationship, obj: MetaObject, ctxPkg: string, onDelete: string | undefined, subtype: string | undefined): void => { - const through = rel.through ? (resolveRef(rel.through, ctxPkg) ?? rel.through) : undefined; + const addM2mEdge = (from: string, to: string, rel: MetaRelationship, obj: MetaObject, onDelete: string | undefined, subtype: string | undefined): void => { + const through = rel.through ? (bound(rel, RELATIONSHIP_ATTR_THROUGH) ?? rel.through) : undefined; let sourceJoinField: string | undefined, targetJoinField: string | undefined; try { const f = deriveM2MFields(rel, obj, model.root); @@ -78,11 +85,8 @@ export class LinkGraph { const fqn = fqnOf(dn.node); if (dn.kind === "object") { for (const f of dn.node.childrenOfType("field")) { - const ref = f.attr("objectRef"); - if (typeof ref === "string") { - const to = resolveRef(ref, dn.pkg); - if (to) addRef({ from: fqn, to, via: f.name, kind: "field" }); - } + const to = bound(f, FIELD_ATTR_OBJECT_REF); + if (to) addRef({ from: fqn, to, via: f.name, kind: "field" }); } // dn.node is typed MetaData; the enclosing dn.kind === "object" guard // guarantees it is a MetaObject at runtime. The `unknown` bridge is needed @@ -94,15 +98,13 @@ export class LinkGraph { // reference whose (target, first-field) a belongs-to relationship covered. const coveredFk = new Set(); for (const rel of obj.relationships()) { - const objectRef = rel.objectRef; - if (typeof objectRef !== "string") continue; - const to = resolveRef(objectRef, dn.pkg); + const to = bound(rel, RELATIONSHIP_ATTR_OBJECT_REF); if (!to) continue; const cardinality = rel.cardinality === "many" ? "many" : rel.cardinality === "one" ? "one" : undefined; const onDelete = rel.onDelete; const subtype = rel.subType; if (cardinality === "many" && rel.through !== undefined) { - addM2mEdge(fqn, to, rel, obj, dn.pkg, onDelete, subtype); // Task 3 helper + addM2mEdge(fqn, to, rel, obj, onDelete, subtype); // Task 3 helper continue; } // belongs-to (1:N, one) — resolve the identity.reference this relationship @@ -115,43 +117,37 @@ export class LinkGraph { // uses; ambiguous cases resolve to undefined and simply leave every candidate // reference to draw its own "fk" edge, which is the safe (extra-info, not // missing-edge) outcome for a docs graph. - const target = stripPackage(objectRef); - const match = resolveRelationshipReference(obj, rel.name, target, rel.sourceRefField); + const match = resolveRelationshipReference( + obj, rel.name, refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF), rel.sourceRefField, + ); const fkField = match?.fields?.[0]; if (fkField) coveredFk.add(`${to}::${fkField}`); addRef({ from: fqn, to, via: rel.name, kind: "relationship", cardinality, onDelete, subtype }); } for (const id of dn.node.childrenOfType("identity")) { if (id.subType !== "reference") continue; - const ref = id.attr("references"); - if (typeof ref === "string") { - const to = resolveRef(ref, dn.pkg); - if (to) { - const fieldsValue = id.attr("fields") ?? id.name; - const firstField = Array.isArray(fieldsValue) ? String(fieldsValue[0] ?? "") : String(fieldsValue); - if (coveredFk.has(`${to}::${firstField}`)) continue; // superseded by a relationship edge - const via = Array.isArray(fieldsValue) ? fieldsValue.join(", ") : String(fieldsValue); - addRef({ from: fqn, to, via, kind: "fk" }); - } - } + const to = bound(id, IDENTITY_REFERENCE_ATTR_REFERENCES); + if (!to) continue; + const fieldsValue = id.attr("fields") ?? id.name; + const firstField = Array.isArray(fieldsValue) ? String(fieldsValue[0] ?? "") : String(fieldsValue); + if (coveredFk.has(`${to}::${firstField}`)) continue; // superseded by a relationship edge + const via = Array.isArray(fieldsValue) ? fieldsValue.join(", ") : String(fieldsValue); + addRef({ from: fqn, to, via, kind: "fk" }); } for (const f of dn.node.childrenOfType("field")) { for (const org of f.childrenOfType("origin")) { const from = String(org.attr("from") ?? ""), via = String(org.attr("via") ?? ""); (this._origins.get(fqn) ?? this._origins.set(fqn, []).get(fqn)!).push({ field: f.name, from, via }); // connect a projection to its source object so it is not orphaned on diagrams: - // `from` is "pkg::Entity.field" — strip the trailing ".field" to get the source object ref. - const dot = from.lastIndexOf("."); - const srcRef = dot > 0 ? from.slice(0, dot) : ""; - const to = srcRef ? resolveRef(srcRef, dn.pkg) : undefined; + // `from` is "pkg::Entity.field"; its bound target is the source object. + const to = bound(org, ORIGIN_PASSTHROUGH_ATTR_FROM); if (to && to !== fqn) addRef({ from: fqn, to, via: `${f.name} (origin)`, kind: "origin" }); } } // FR-044: a report reads from its @from entity. The edge is what puts the report on // that entity's page and diagrams, and keeps a report from reading as an orphan. if (dn.node.type === TYPE_OBJECT && dn.node.subType === OBJECT_SUBTYPE_REPORT) { - const from = reportFrom(dn.node); - const to = from !== undefined ? resolveRef(from, dn.pkg) : undefined; + const to = bound(dn.node, OBJECT_REPORT_ATTR_FROM); if (to) addRef({ from: fqn, to, via: OBJECT_REPORT_ATTR_FROM, kind: "report" }); } const sup = dn.node.superResolved; @@ -161,18 +157,12 @@ export class LinkGraph { (this._extBy.get(supFqn) ?? this._extBy.set(supFqn, []).get(supFqn)!).push(dn); } } else { - const p = dn.node.attr("payloadRef"); - if (typeof p === "string") { - const to = resolveRef(p, dn.pkg); - if (to) addRef({ from: fqn, to, via: "payloadRef", kind: "payload" }); - } + const toPayload = bound(dn.node, TEMPLATE_ATTR_PAYLOAD_REF); + if (toPayload) addRef({ from: fqn, to: toPayload, via: "payloadRef", kind: "payload" }); // A prompt's @responseRef is the response value-object it PRODUCES — the // missing hop of the data-flow pipeline (prompt → response VO → entity). - const r = dn.node.attr("responseRef"); - if (typeof r === "string") { - const to = resolveRef(r, dn.pkg); - if (to) addRef({ from: fqn, to, via: "responseRef", kind: "response" }); - } + const toResponse = bound(dn.node, TEMPLATE_ATTR_RESPONSE_REF); + if (toResponse) addRef({ from: fqn, to: toResponse, via: "responseRef", kind: "response" }); } } } diff --git a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts index b750c234a..36a726c42 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { IDENTITY_REFERENCE_ATTR_REFERENCES, IDENTITY_SUBTYPE_REFERENCE, OBJECT_SUBTYPE_REPORT, TYPE_IDENTITY, - pluralize, reportReadSource, reportShape, resolveTableName, toSnakeCase, + isMetaObject, pluralize, refTarget, reportReadSource, reportShape, resolveTableName, toSnakeCase, } from "@metaobjectsdev/metadata"; import { loadMetadataFile } from "../src/load-metadata.ts"; import { loadScenarios } from "../src/api-contract-scenario.ts"; @@ -68,8 +68,9 @@ describe("api-contract report corpus", () => { if (entity === undefined) throw new Error(`no entity has table ${table}`); for (const ref of entity.children()) { if (ref.type !== TYPE_IDENTITY || ref.subType !== IDENTITY_SUBTYPE_REFERENCE) continue; - const target = root.objects().find((o) => o.name === ref.attr(IDENTITY_REFERENCE_ATTR_REFERENCES)); - if (target === undefined) throw new Error(`${entity.name}.${ref.name} references no object`); + // ADR-0059: the target the loader bound, not a short-name match on the reference value. + const target = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); + if (target === undefined || !isMetaObject(target)) throw new Error(`${entity.name}.${ref.name} references no object`); expect(tables.indexOf(resolveTableName(target))).toBeLessThan(i); expect(tables.indexOf(resolveTableName(target))).toBeGreaterThanOrEqual(0); } diff --git a/server/typescript/packages/metadata/src/core/attr/meta-attr.ts b/server/typescript/packages/metadata/src/core/attr/meta-attr.ts index da305f1b1..8952e456e 100644 --- a/server/typescript/packages/metadata/src/core/attr/meta-attr.ts +++ b/server/typescript/packages/metadata/src/core/attr/meta-attr.ts @@ -49,6 +49,22 @@ const BASE_ATTR_DATA_TYPE: Readonly> = { }; export class MetaAttr extends MetaData implements DataTypeAware { + /** + * ADR-0059 R5 — for a reference attribute, the top-level object each reference + * names, set once by the loader's `resolveReferences` pass. A scalar value holds + * one node; a list value holds one entry per element (`undefined` where that + * element did not resolve). Unset when the attr is not a reference or did not + * resolve. + */ + resolvedTarget?: MetaData | readonly (MetaData | undefined)[]; + + /** + * ADR-0059 R5 — the value exactly as authored, kept when `resolveReferences` + * rewrites `value` to its canonical spelling. `meta fmt` writes this back + * (`refs: "authored"`). Unset when the value was never rewritten. + */ + authoredValue?: AttrValue; + /** This attribute's coerced value (stored on the instance under RESERVED_KEY_VALUE). */ get value(): AttrValue | undefined { // ADR-0039: own — an attr node's own scalar payload (the RESERVED_KEY_VALUE diff --git a/server/typescript/packages/metadata/src/core/identity/meta-identity.ts b/server/typescript/packages/metadata/src/core/identity/meta-identity.ts index e026b36d0..f7ca1d7c9 100644 --- a/server/typescript/packages/metadata/src/core/identity/meta-identity.ts +++ b/server/typescript/packages/metadata/src/core/identity/meta-identity.ts @@ -16,6 +16,9 @@ import { IDENTITY_REFERENCE_ATTR_ON_UPDATE, } from "./identity-constants.js"; import type { MetaRoot } from "../../shared/meta-root.js"; +import type { MetaObject } from "../object/meta-object.js"; +import { TYPE_OBJECT } from "../../shared/base-types.js"; +import { refTarget } from "../../naming-refs.js"; /** Strongly-typed identity generation strategies. */ export type IdentityGeneration = "increment" | "uuid" | "assigned"; @@ -144,7 +147,7 @@ export class MetaReferenceIdentity extends MetaIdentity { * points at. Priority: explicit dotted-form `@references: "Entity.field"` * override → target entity's primary identity field → "id" fallback. * - * Returns undefined only if the target entity cannot be found in `root`. + * Returns undefined only if the target entity does not resolve. * Centralizes the FK-target resolution rule used by projection view DDL, * Drizzle schema emit, and migration schema comparison. */ @@ -152,17 +155,11 @@ export class MetaReferenceIdentity extends MetaIdentity { const explicit = this.targetFields[0]; if (explicit !== undefined) return explicit; - const targetName = this.targetEntity; - if (targetName === undefined) return undefined; - // targetEntity may be package-qualified (FQN); findObject is keyed by bare - // name, so fall back to the bare suffix after the last "::". Mirrors the - // resolveTargetTable fix; without it a cross-package reference resolves no - // target and the PK column wrongly defaults to "id". - const targetObj = root.findObject(targetName) - ?? (targetName.includes("::") - ? root.findObject(targetName.slice(targetName.lastIndexOf("::") + 2)) - : undefined); - if (!targetObj) return undefined; + // ADR-0059 R7: the target the loader bound for @references, else (a tree built + // without the loader) the value resolved from this reference's package. + const bound = refTarget(this, IDENTITY_REFERENCE_ATTR_REFERENCES); + if (bound === undefined || bound.type !== TYPE_OBJECT) return undefined; + const targetObj = bound as MetaObject; const primary = targetObj.primaryIdentity(); // ADR-0039: resolving — the target's @fields may be inherited via extends. diff --git a/server/typescript/packages/metadata/src/core/relationship/derive-m2m-fields.ts b/server/typescript/packages/metadata/src/core/relationship/derive-m2m-fields.ts index d699559ef..bc2fee305 100644 --- a/server/typescript/packages/metadata/src/core/relationship/derive-m2m-fields.ts +++ b/server/typescript/packages/metadata/src/core/relationship/derive-m2m-fields.ts @@ -44,7 +44,9 @@ import type { MetaRoot } from "../../shared/meta-root.js"; import type { MetaRelationship } from "./meta-relationship.js"; import type { MetaReferenceIdentity } from "../identity/meta-identity.js"; import { stripPackage } from "../../naming.js"; -import { resolveObjectRef } from "../../naming-refs.js"; +import { refTarget } from "../../naming-refs.js"; +import { IDENTITY_REFERENCE_ATTR_REFERENCES } from "../identity/identity-constants.js"; +import { RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH } from "./relationship-constants.js"; import { TYPE_OBJECT } from "../../shared/base-types.js"; import { PACKAGE_SEPARATOR } from "../../shared/structural.js"; @@ -69,37 +71,6 @@ function refFkField(ref: MetaReferenceIdentity): string | undefined { return ref.fields[0]; } -/** - * The root entity a reference name denotes, or undefined — resolved by the ONE - * matcher the loader uses (`resolveObjectRef`, ADR-0041/0042): a fully-qualified - * name binds exactly on its resolution key, a bare name binds in the REFERRER's - * package. - * - * This exists so the SUBJECT comparison below can be made on object IDENTITY. - * A bare-name compare cannot tell `a::NodeBase` from `b::NodeBase`, which made a - * genuine cross-package hetero M:N read as a self-join the moment the subject - * set held two names — and the first-match-wins bare arm this used to carry - * (Java's deferred #174) bound a bare name to whichever same-short-named entity - * happened to load first, which is load-ORDER dependence, not a naming rule. - */ -function findEntity( - root: MetaRoot, - name: string | undefined, - referrerPkg: string, -): MetaObject | undefined { - if (name === undefined || name === "") return undefined; - // ADR-0041/0042 through the single resolveObjectRef matcher — an FQN resolves - // exactly on its resolution key, a BARE name resolves in the REFERRER's - // package. The bare arm used to be `objects().find(o => o.name === bare)`, - // which took whichever same-short-named entity loaded first: a bare - // `@objectRef: "Tag"` on an abstract base in package `base` bound to - // `acme::Tag` merely because the acme source was read first. Callers pass the - // referrer that owns the name they are resolving — the DECLARING entity for - // the relationship's own @objectRef, the JUNCTION for its references' — - // because those are different packages whenever a base is inherited across one. - return resolveObjectRef(root, name, referrerPkg).node as MetaObject | undefined; -} - /** * Derive the source/target junction FK fields for a M:N relationship. * @@ -108,7 +79,7 @@ function findEntity( * @param source the entity the caller is navigating from. Accepted alongside * `rel.parent` as a name for the relationship's subject, and used * as the declaring entity when `rel` has no entity parent. - * @param root the loaded model root (to find the junction entity) + * @param root the loaded model root * @throws M2MDerivationError when the junction is missing/malformed or the * self-join is ambiguous. */ @@ -134,9 +105,10 @@ export function deriveM2MFields( `relationship "${declaringEntity.name}.${rel.name}" is missing @through (required for M:N derivation)`, ); } - // ADR-0041/0042 — resolve @through through the SAME matcher the loader uses - // (resolveObjectRef, via validation-passes' _findObject): an FQN resolves - // exactly on its resolution key, a bare name resolves package-locally. + // ADR-0059 R7 — @through, @objectRef and the junction's @references are read + // as bound targets (refTarget), each resolved in the package of the node that + // declares it, so an inherited reference keeps the meaning it had where it was + // written. // // This used to be `root.findObject(fqn)` with a fall back to the bare suffix // after the last "::". findObject is keyed by BARE name, so the FQN lookup @@ -147,12 +119,7 @@ export function deriveM2MFields( // nowhere near the navigating entity. The pairing then failed, so a valid // cross-package M:N emitted no traversal route in TS and C# and failed the // build in Java, Kotlin and Python. - // - // Note findEntity() below was ALREADY FQN-exact for the same reason (see its - // doc comment). The two halves of one function disagreed about how a - // package-qualified name resolves; only the junction half was wrong. - const referrerPkg = declaringEntity.package ?? declaringEntity.fileDefaultPackage ?? ""; - const junction = resolveObjectRef(root, throughName, referrerPkg).node as MetaObject | undefined; + const junction = refTarget(rel, RELATIONSHIP_ATTR_THROUGH) as MetaObject | undefined; if (junction === undefined) { throw new M2MDerivationError( `relationship "${declaringEntity.name}.${rel.name}" @through "${throughName}" does not resolve to an entity`, @@ -202,16 +169,16 @@ export function deriveM2MFields( name !== undefined && subjectNames.includes(stripPackage(name)); // The junction owns its references' @references names, so those resolve in the - // JUNCTION's package; the relationship's own @objectRef resolves in the - // DECLARING entity's. Under `extends` across packages these differ, and using - // one for both is what bound a bare @objectRef to the wrong same-named entity. - const junctionPkg = junction.package ?? junction.fileDefaultPackage ?? ""; + // package of the node that declares them; the relationship's own @objectRef in + // the package of the node that declares it. Under `extends` across packages these + // differ, and using one for both is what bound a bare @objectRef to the wrong + // same-named entity. const refEntity = (r: MetaReferenceIdentity): MetaObject | undefined => - findEntity(root, r.targetEntity, junctionPkg); + refTarget(r, IDENTITY_REFERENCE_ATTR_REFERENCES) as MetaObject | undefined; // Defensive bare fallback when @objectRef does not resolve — loader validation // normally guarantees it does. Same carve-out the Java reference makes. - const targetEntityNode = findEntity(root, targetName, referrerPkg); + const targetEntityNode = refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF) as MetaObject | undefined; const isSelfJoin = targetEntityNode !== undefined ? isSubject(targetEntityNode) : isSubjectName(targetName); diff --git a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts index c85eb3efd..f723236ac 100644 --- a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts +++ b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts @@ -8,7 +8,8 @@ import type { MetaObject } from "../object/meta-object.js"; import type { MetaReferenceIdentity } from "../identity/meta-identity.js"; -import { stripPackage } from "../../naming.js"; +import { refTarget } from "../../naming-refs.js"; +import { IDENTITY_REFERENCE_ATTR_REFERENCES } from "../identity/identity-constants.js"; export interface ReferenceLookup { /** The entity whose identity.reference points at `other`. */ @@ -39,7 +40,8 @@ export function findReferencesBetween( const found: ReferenceLookup[] = []; for (const [holder, other] of [[a, b], [b, a]] as const) { for (const ref of holder.referenceIdentities()) { - if (stripPackage(ref.targetEntity) === other.name) { + // ADR-0059 R7: compare the bound target, never a short name. + if (refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES) === other) { found.push({ holder, other, referenceIdentity: ref }); } } diff --git a/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts b/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts index bbf227de3..4cfed0714 100644 --- a/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts +++ b/server/typescript/packages/metadata/src/core/relationship/resolve-relationship-reference.ts @@ -10,7 +10,9 @@ import type { MetaObject } from "../object/meta-object.js"; import type { MetaReferenceIdentity } from "../identity/meta-identity.js"; -import { stripPackage } from "../../naming.js"; +import type { MetaData } from "../../shared/meta-data.js"; +import { refTarget } from "../../naming-refs.js"; +import { IDENTITY_REFERENCE_ATTR_REFERENCES } from "../identity/identity-constants.js"; /** * Trailing suffixes stripped from a CANDIDATE's name/FK field when building its @@ -51,20 +53,20 @@ export function referencePairingKeys(ref: MetaReferenceIdentity): Set { } /** - * Every identity.reference on `holder` whose @references targets `targetEntity`. - * Package-insensitive on both sides: @references and @objectRef may each be bare - * or fully qualified. + * Every identity.reference on `holder` whose @references targets `targetEntity`: a + * reference matches when its @references is bound to that node (ADR-0059 R7: identity, + * never a short name). An unresolved target (undefined) matches nothing. */ export function referenceCandidatesFor( holder: MetaObject, - targetEntity: string, + targetEntity: MetaData | undefined, ): MetaReferenceIdentity[] { - const target = stripPackage(targetEntity); + if (targetEntity === undefined) return []; // ADR-0039: resolving — referenceIdentities() honors references inherited via extends. return holder .referenceIdentities() - .filter((ref) => stripPackage(ref.targetEntity ?? "") === target) - .filter((ref) => refFkField(ref) !== undefined); + .filter((ref) => refFkField(ref) !== undefined) + .filter((ref) => refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES) === targetEntity); } /** @@ -82,7 +84,7 @@ export function referenceCandidatesFor( export function resolveRelationshipReference( holder: MetaObject, relationshipName: string, - targetEntity: string, + targetEntity: MetaData | undefined, sourceRefField?: string, ): MetaReferenceIdentity | undefined { const candidates = referenceCandidatesFor(holder, targetEntity); diff --git a/server/typescript/packages/metadata/src/core/reporting/report-describe.ts b/server/typescript/packages/metadata/src/core/reporting/report-describe.ts index 3c2f09108..049d5789a 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-describe.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-describe.ts @@ -18,7 +18,7 @@ import { TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT } from "../../shared/base-ty import { SOURCE_KIND_VIEW } from "../../persistence/source/source-constants.js"; import { AGG_ALL } from "../../persistence/origin/origin-constants.js"; import type { MetaObject } from "../object/meta-object.js"; -import { OBJECT_REPORT_ATTR_FILTER, OBJECT_REPORT_ATTR_SEGMENT } from "../object/object-constants.js"; +import { OBJECT_REPORT_ATTR_FILTER, OBJECT_REPORT_ATTR_SEGMENT, OBJECT_REPORT_ATTR_SPINE } from "../object/object-constants.js"; import { DIMENSION_SUBTYPE_TIME, MEASURE_SUBTYPE_RATIO, @@ -66,9 +66,10 @@ export function describeRowScope(segment: unknown, filter: unknown): string | un /** "`Program.title` via `Purchase.program`": the column a dimension groups by. */ function dimensionColumn(dim: MetaData): string { // ADR-0039: resolving reads, here and in every describer below, so a node that - // `extends` another is described by its effective attributes. - const of = dim.attr(REPORTING_ATTR_OF); - const via = dim.attr(REPORTING_ATTR_VIA); + // `extends` another is described by its effective attributes. References are + // printed as the author wrote them (ADR-0059), not as the loader resolved them. + const of = dim.authoredAttr(REPORTING_ATTR_OF); + const via = dim.authoredAttr(REPORTING_ATTR_VIA); const column = tick(typeof of === "string" ? of : ""); return typeof via === "string" && via !== "" ? `${column} via ${tick(via)}` : column; } @@ -115,7 +116,7 @@ export function describeMeasure(measure: MetaData): string { const quotient = `${tick(String(numerator ?? ""))} / ${tick(String(denominator ?? ""))}`; return fallback !== "" ? `${quotient}${fallback}` : `${quotient}, null when the denominator is 0`; } - const columns = stringList(measure.attr(REPORTING_ATTR_OF)).map(tick); + const columns = stringList(measure.authoredAttr(REPORTING_ATTR_OF)).map(tick); const scope = describeRowScope(measure.attr(REPORTING_ATTR_SEGMENT), measure.attr(REPORTING_ATTR_FILTER)); if (columns.length === 0) { // @agg any / all with no @of (loader rule M0): the row condition is the quantified predicate. @@ -145,8 +146,10 @@ export function describeReportField(field: ReportField): string { * `@from` rows and need no sentence. The spine is printed as declared. */ export function describeReportRows(shape: ReportShape, root: MetaRoot): string | undefined { - const spine = reportSpine(shape.report); const entity = reportSpineEntity(shape.report, shape.from, root); + // Printed as the author wrote it (ADR-0059). + const authored = reportSpine(shape.report) !== undefined ? shape.report.authoredAttr(OBJECT_REPORT_ATTR_SPINE) : undefined; + const spine = typeof authored === "string" ? authored : undefined; if (spine === undefined || entity === undefined) return undefined; return ( `one row per distinct dimension tuple among the rows of ${tick(entity.name)}, reached by ${tick(spine)}, ` + diff --git a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts index bede23a95..e6231bf63 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts @@ -7,7 +7,7 @@ import type { MetaData } from "../../shared/meta-data.js"; import type { MetaRoot } from "../../shared/meta-root.js"; import { isMetaObject } from "../../shared/node-guards.js"; -import { resolveObjectRef } from "../../naming-refs.js"; +import { refTarget, resolveObjectRef } from "../../naming-refs.js"; import { CHILD_REF_SEPARATOR, PACKAGE_SEPARATOR } from "../../shared/structural.js"; import type { MetaObject } from "../object/meta-object.js"; import type { MetaField } from "../field/meta-field.js"; @@ -162,7 +162,7 @@ function reportingViaTerminal( if (hops === undefined) return undefined; let current: MetaObject = from; for (const hop of hops) { - const target = resolveViaHop(current, hop, root)?.target; + const target = resolveViaHop(current, hop)?.target; if (target === undefined) return undefined; current = target; } @@ -240,7 +240,8 @@ export function reportSpineHops(report: MetaObject, from: MetaObject, root: Meta /** * One hop of a `@via`/`@spine` path, as the loader's rule R8 walks it: the `relationship.*` * (read through its `@objectRef`) or `identity.reference` (through its `@references`) of - * `current` named `hopName`, with its target resolved in `current`'s package. Returns + * `current` named `hopName`, with its target resolved in the package of the node that declares + * the hop's reference attribute (ADR-0059 R3). Returns * `target: undefined` for an unresolvable hop or a ref attr that is not a string / does not * reach a metadata object. * ADR-0039: resolving children(), so a hop inherited through extends is found. @@ -248,7 +249,6 @@ export function reportSpineHops(report: MetaObject, from: MetaObject, root: Meta function resolveViaHop( current: MetaObject, hopName: string, - root: MetaRoot, ): { hop: MetaData; isReference: boolean; target: MetaObject | undefined } | undefined { const rel = current.children().find((c) => c.type === TYPE_RELATIONSHIP && c.name === hopName); const refNode = rel !== undefined @@ -259,8 +259,7 @@ function resolveViaHop( const hop = rel ?? refNode; if (hop === undefined) return undefined; const isReference = rel === undefined; - const ref = hop.attr(isReference ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF); - const node = typeof ref === "string" ? resolveObjectRef(root, ref, packageOfKey(current.resolutionKey())).node : undefined; + const node = refTarget(hop, isReference ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF); return { hop, isReference, target: isMetaObject(node) ? node : undefined }; } @@ -276,7 +275,7 @@ export function reportSpineEntity(report: MetaObject, from: MetaObject, root: Me if (hops === undefined) return undefined; let current: MetaObject = from; for (const hop of hops) { - const target = resolveViaHop(current, hop, root)?.target; + const target = resolveViaHop(current, hop)?.target; if (target === undefined) throw unresolved(report.name, `@spine '${reportSpine(report) ?? ""}' hop '${hop}'`); current = target; } @@ -316,7 +315,7 @@ function isTimeGrain(grain: string | undefined): grain is TimeGrain { function viaJoinsInner(hops: readonly string[], from: MetaObject, root: MetaRoot): boolean { let current = from; for (const hopName of hops) { - const resolved = resolveViaHop(current, hopName, root); + const resolved = resolveViaHop(current, hopName); if (resolved === undefined || resolved.target === undefined) return false; const { hop, isReference, target } = resolved; @@ -328,7 +327,7 @@ function viaJoinsInner(hops: readonly string[], from: MetaObject, root: MetaRoot } else { const sourceRefField = hop.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); reference = resolveRelationshipReference( - current, hopName, target.name, typeof sourceRefField === "string" ? sourceRefField : undefined, + current, hopName, target, typeof sourceRefField === "string" ? sourceRefField : undefined, ); } if (reference === undefined) return false; diff --git a/server/typescript/packages/metadata/src/core/yaml-desugar.ts b/server/typescript/packages/metadata/src/core/yaml-desugar.ts index 5a4bc6396..172eca580 100644 --- a/server/typescript/packages/metadata/src/core/yaml-desugar.ts +++ b/server/typescript/packages/metadata/src/core/yaml-desugar.ts @@ -35,13 +35,9 @@ import { RESERVED_KEYS, RESERVED_KEY_CHILDREN, RESERVED_KEY_NAME, - RESERVED_KEY_PACKAGE, - RESERVED_KEY_EXTENDS, RESERVED_KEY_IS_ARRAY, TYPE_SUBTYPE_SEPARATOR, - PACKAGE_SEPARATOR, } from "../shared/structural.js"; -import { expandRef, REF_BEARING_ATTR_NAMES } from "../naming-refs.js"; import { getPositionMap, setPositionMap, @@ -76,34 +72,13 @@ export interface DesugarResult { /** Desugar a parsed-YAML authoring document into a canonical-shaped object. */ export function desugar(input: unknown, registry: TypeRegistry): DesugarResult { const errors: CollectedError[] = []; - // FR-032 (ADR-0032): the desugar threads the effective package context down - // the tree so every ref-bearing attr (extends + @objectRef/@references/@from/ - // @of/@via/@parameterRef/@payloadRef/@responseRef) is expanded to its FQN via - // expandRef. The root context is empty (the metadata.root's own `package` - // body key seeds the first real context inside desugarNode). - const node = desugarNode(input, registry, errors, "", ""); + // ADR-0059 R1: the desugar is syntax only. Reference VALUES (extends, @objectRef, + // @of, ...) pass through exactly as written; the loader's one resolver gives every + // form its meaning once the whole tree exists. + const node = desugarNode(input, registry, errors, ""); return { canonical: node ?? {}, errors }; } -// FR-032: compute a node's effective package from its raw `package` body key -// (inheriting the parent context when absent). Mirrors parser-core's -// expandPackageForPath so the desugar's ref-expansion context matches the JSON -// loader's package resolution. The `package` attr itself is NEVER ref-expanded -// (ADR-0032 §2.1 — it is the node's identity, taken literally/absolute). -function effectivePackageFor(rawBody: unknown, parentPkg: string): string { - if (typeof rawBody !== "object" || rawBody === null || Array.isArray(rawBody)) { - return parentPkg; - } - const rawPkg = (rawBody as Record)[RESERVED_KEY_PACKAGE]; - if (typeof rawPkg !== "string") return parentPkg; - // Legacy expandPackageForPath: a leading "::" on a child package prepends the - // parent; otherwise it is used as-is. - if (parentPkg !== "" && rawPkg.startsWith(PACKAGE_SEPARATOR)) { - return parentPkg + rawPkg; - } - return rawPkg; -} - // Desugar one node — a single-key mapping { "type.subType": body }. // Returns the canonical node object, or undefined if `input` is not a usable // node (the caller substitutes a placeholder). @@ -112,7 +87,6 @@ function desugarNode( registry: TypeRegistry, errors: CollectedError[], path: string, - parentPkg: string, ): Record | undefined { if (typeof input !== "object" || input === null || Array.isArray(input)) { errors.push({ message: `Node at ${path} must be a mapping with one type key` }); @@ -149,16 +123,11 @@ function desugarNode( // Rule 1: a bare `type` key → the type's registry default subType. const canonicalKey = resolveKey(key, registry, errors, path); - // FR-032: this node's effective package context (its own `package` body key, - // else inherited). Used to expand its ref-bearing attrs AND threaded to its - // children so their bare/relative refs resolve against the right package. - const nodePkg = effectivePackageFor(rawBody, parentPkg); - // Rule 2: a scalar body → { name: }. // FR5b — propagate the wrapper-key's position into the synthesized body // when the input body was a scalar (no body-side positions to inherit). const wrapperKeyPos = wrapperPositions?.[rawKey]; - const body = desugarBody(rawBody, registry, canonicalKey, errors, path, wrapperKeyPos, nodePkg); + const body = desugarBody(rawBody, registry, canonicalKey, errors, path, wrapperKeyPos); // Rule 4 (cont.): stamp isArray onto the canonical body. if (isArray) body[RESERVED_KEY_IS_ARRAY] = true; @@ -169,7 +138,7 @@ function desugarNode( const children: unknown[] = []; for (let i = 0; i < rawChildren.length; i++) { const childPath = `${path}.${RESERVED_KEY_CHILDREN}[${i}]`; - const child = desugarNode(rawChildren[i], registry, errors, childPath, nodePkg); + const child = desugarNode(rawChildren[i], registry, errors, childPath); // On a bad child keep an empty-object placeholder so sibling indices // stay stable; the error is already collected. children.push(child ?? {}); @@ -230,10 +199,6 @@ function desugarBody( * empty bodies; for mapping bodies we use the body's own position-by-key * map. */ wrapperKeyPos: { line: number; col: number } | undefined, - /** FR-032: this node's effective package context, used to expand its - * ref-bearing attrs (extends + @objectRef/@references/@from/@of/@via/ - * @parameterRef/@payloadRef/@responseRef) to FQN via expandRef. */ - nodePkg: string, ): Record { if ( typeof rawBody === "string" || @@ -280,26 +245,16 @@ function desugarBody( let outKey: string; if (RESERVED_KEYS.has(key) || key.startsWith(ATTR_PREFIX)) { outKey = key; - // FR-032: expand a ref-bearing value to FQN. `extends` (reserved) and the - // @-prefixed ref attrs are expanded; everything else is copied verbatim. + // ADR-0059 R1: every value, references included, is copied as written. const attrName = key.startsWith(ATTR_PREFIX) ? key.slice(ATTR_PREFIX.length) : ""; - if (key === RESERVED_KEY_EXTENDS || (attrName !== "" && REF_BEARING_ATTR_NAMES.has(attrName))) { - out[key] = expandRefValue(src[key], nodePkg, errors, path, key); - } else { - out[key] = src[key]; - } + out[key] = src[key]; // D2 also applies to author-written @-keys (the awkward form). if (attrName !== "" && !RESERVED_KEYS.has(attrName)) { checkCoercion(attrName, src[key], schemaIndex, errors, path); } } else { outKey = `${ATTR_PREFIX}${key}`; - // FR-032: a bare (sigil-free) ref-bearing attr is expanded to FQN too. - if (REF_BEARING_ATTR_NAMES.has(key)) { - out[outKey] = expandRefValue(src[key], nodePkg, errors, path, outKey); - } else { - out[outKey] = src[key]; - } + out[outKey] = src[key]; checkCoercion(key, src[key], schemaIndex, errors, path); } const pos = srcPositions?.[key]; @@ -312,41 +267,6 @@ function desugarBody( return out; } -// --------------------------------------------------------------------------- -// FR-032 (ADR-0032) — expand a ref-bearing value to FQN. -// -// A ref value is a single string (most refs) or a string-array (@references can -// carry multiple). expandRef preserves any FR-024 dotted child suffix and the -// `package` attr is never touched here (it is not a ref). A parent-relative -// over-drop (more `..::` than the package has segments) throws inside expandRef; -// we collect it as ERR_BAD_ATTR_VALUE and pass the raw value through so the rest -// of the desugar/load proceeds and surfaces a single actionable error. -// --------------------------------------------------------------------------- - -function expandRefValue( - raw: unknown, - nodePkg: string, - errors: CollectedError[], - path: string, - refLabel: string, -): unknown { - if (typeof raw === "string") { - try { - return expandRef(raw, nodePkg); - } catch (err) { - errors.push({ - message: `Cannot expand reference '${refLabel}' at ${path}: ${(err as Error).message}`, - code: "ERR_BAD_ATTR_VALUE", - }); - return raw; - } - } - if (Array.isArray(raw)) { - return raw.map((el) => (typeof el === "string" ? expandRefValue(el, nodePkg, errors, path, refLabel) : el)); - } - return raw; -} - // --------------------------------------------------------------------------- // D2 — YAML type-coercion guard // --------------------------------------------------------------------------- diff --git a/server/typescript/packages/metadata/src/errors.ts b/server/typescript/packages/metadata/src/errors.ts index 92d1ee82d..05923a8dd 100644 --- a/server/typescript/packages/metadata/src/errors.ts +++ b/server/typescript/packages/metadata/src/errors.ts @@ -201,12 +201,6 @@ export const ERROR_CODES = [ // ADR-0023 — a registration was attempted against a registry sealed after its // agreed metamodel-provider bootstrap. Codegen cannot invent metamodel attrs. "ERR_REGISTRY_SEALED", - // FR-032 (ADR-0032) — a ref-bearing attr (extends/@objectRef/@references/ - // origin @from/@of/@via/@parameterRef/@payloadRef/@responseRef) in CANONICAL - // JSON used a relative authoring form (leading `::` or `..::`). Canonical JSON - // is the self-contained interchange form: every ref MUST be fully-qualified. - // Relative navigation is YAML-only (the desugar expands it via expandRef). - "ERR_RELATIVE_REF_IN_CANONICAL", // FR-033 — a provider set's merged metamodel constraint graph is contradictory. // Surfaced by validateConstraints (constraint-validate.ts) at registry compose: // dangling ref / unsatisfiable required child / bad cardinality / closed-set diff --git a/server/typescript/packages/metadata/src/fmt.ts b/server/typescript/packages/metadata/src/fmt.ts index 911b486f8..7ece42ae0 100644 --- a/server/typescript/packages/metadata/src/fmt.ts +++ b/server/typescript/packages/metadata/src/fmt.ts @@ -149,7 +149,9 @@ export function formatMetadataFile(content: string, opts: FormatFileOptions): Fo }; } - return { ok: true, text: canonicalSerialize(result.root) }; + // ADR-0059 (C2): formatting never rewrites a reference — refs and packages are + // written as authored; only layout, key order and sugar are normalized. + return { ok: true, text: canonicalSerialize(result.root, { refs: "authored" }) }; } catch (err) { return { ok: false, diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index 542d63db9..f0200021d 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -296,7 +296,21 @@ export type { FormatFileOptions, FormatFileResult } from "./fmt.js"; export { resolveSuperRef } from "./super-resolve.js"; // FR-032 (ADR-0032) — canonical reference expansion + FQN object matching. -export { expandRef, isRelativeRef, refMatchesObject, resolveObjectRef, didYouMeanHint, REF_BEARING_ATTR_NAMES } from "./naming-refs.js"; +export { + expandRef, + isRelativeRef, + refMatchesObject, + resolveObjectRef, + resolveRef, + lookupCanonical, + refTarget, + spellRef, + splitChildTail, + RefOverDropError, + didYouMeanHint, + REF_BEARING_ATTR_NAMES, +} from "./naming-refs.js"; +export { resolveReferences } from "./loader/resolve-references.js"; // Loader hierarchy export { MetaDataLoader, declaredTopLevelKeys } from "./loader/meta-data-loader.js"; diff --git a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts index 85c38fb72..b02b53e26 100644 --- a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts +++ b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts @@ -36,6 +36,7 @@ import { validateFieldMutability } from "../core/field/validate-field-mutability import { validateEnumNormalizeAmbiguity } from "../core/field/validate-enum-normalize-ambiguity.js"; import { validateDiscriminator } from "../core/object/validate-discriminator.js"; import { resolveDeferredSupers, EXTENDS_TARGET_MISMATCH_RULE } from "../super-resolve.js"; +import { resolveReferences } from "./resolve-references.js"; import { validateSubtypeRules } from "../subtype-rules.js"; import { validateMaxOccurs } from "../validate-max-occurs.js"; import { validateIdentityPassthrough } from "../core/identity/validate-identity-passthrough.js"; @@ -590,6 +591,19 @@ export class MetaDataLoader { ); continue; } + if (failure.kind === "over-drop") { + // ADR-0059 R2 — `..::` dropped more segments than the package has. + errors.push( + new ParseError( + `the extends ref '${failure.ref}' on ${failure.nodeFqn} climbs above the root package — each '..::' drops one segment of the declaring package`, + { + code: "ERR_BAD_ATTR_VALUE", + source: resolvedSource(failure.source, failure.nodeFqn, failure.ref), + }, + ), + ); + continue; + } errors.push( new ParseError( `the SuperClass '${failure.ref}' does not exist (referenced by ${failure.nodeFqn})`, @@ -750,6 +764,10 @@ export class MetaDataLoader { // finite number, boolean → true|false). Enum @default membership is validated // by validateAttrSchema (Check 5). Cross-port parity with Java/Python/C#. errors.push(...validateFieldDefaults(root)); + + // ADR-0059 R5 — after the last validation pass, before freeze: resolve every + // reference once, record its target and rewrite it to its canonical spelling. + resolveReferences(root); } // If nothing parsed successfully, synthesize an empty root so callers diff --git a/server/typescript/packages/metadata/src/loader/reporting-validation.ts b/server/typescript/packages/metadata/src/loader/reporting-validation.ts index 8a3908d96..ad043f3c1 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -25,7 +25,7 @@ import type { MetaData } from "../shared/meta-data.js"; import { ParseError, type ErrorCode } from "../errors.js"; -import { resolveObjectRef } from "../naming-refs.js"; +import { refTarget, resolveObjectRef } from "../naming-refs.js"; import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js"; import { TYPE_OBJECT, @@ -510,10 +510,9 @@ function walkToOneVia( ); return undefined; } - const targetRef = hop.attr(isReference ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF); - // ADR-0042 — a hop target resolves in the package of the entity declaring the hop. - const target = - typeof targetRef === "string" ? resolveObjectRef(root, targetRef, pkgOf(current)).node : undefined; + // ADR-0059 R3 — a hop target resolves in the package of the node that declares the + // hop's reference attribute, so an inherited hop means what it meant where it was written. + const target = refTarget(hop, isReference ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF); if (target === undefined) { err(`${named} hop '${hopName}' on '${nameOf(current)}' targets no object.`); return undefined; @@ -523,6 +522,16 @@ function walkToOneVia( return current; } +/** + * The entity a dimension's `@via` reaches, walked from `declaring` with D2's rules, + * or undefined when the walk fails (D2 reports why). ADR-0059 R3: a dimension's `@of` + * resolves in this entity's package, so the loader's `resolveReferences` pass reuses + * the walk rather than restating it. + */ +export function dimensionViaTerminal(root: MetaData, declaring: MetaData, via: string): MetaData | undefined { + return walkToOneVia(root, declaring, via, () => {}, d2Walk(declaring)); +} + function checkMeasure(ctx: MemberCtx, measure: MetaMeasure): void { const err = (message: string): void => ctx.sink.push(measure, ERR_INVALID_MEASURE, ctx.label, `: ${message}`, ctx.suffix); diff --git a/server/typescript/packages/metadata/src/loader/resolve-references.ts b/server/typescript/packages/metadata/src/loader/resolve-references.ts new file mode 100644 index 000000000..ba18eea2b --- /dev/null +++ b/server/typescript/packages/metadata/src/loader/resolve-references.ts @@ -0,0 +1,144 @@ +// ADR-0059 R5 — resolve every reference once, after validation and before freeze. +// +// Sources do not interpret references (R1): YAML and canonical JSON hand the loader +// each reference string exactly as written. The validation passes resolve those +// authored strings through the shared resolver and report failures with their own +// codes. This pass then walks the physical tree once and, for every reference that +// resolves, records the target and rewrites the value to its canonical spelling (R6), +// keeping the authored string beside it. After the pass every consumer reads values +// that mean the same thing whatever package they are read from, and the serializer +// writes them as they are. +// +// A reference that does not resolve is left exactly as written: validation has +// already reported it. + +import type { MetaData, AttrValue } from "../shared/meta-data.js"; +import { MetaAttr } from "../core/attr/meta-attr.js"; +import { TYPE_DIMENSION } from "../shared/base-types.js"; +import { REPORTING_ATTR_OF, REPORTING_ATTR_VIA } from "../core/reporting/reporting-constants.js"; +import { + OBJECT_REPORT_ATTR_FROM, + OBJECT_REPORT_ATTR_SPINE, + OBJECT_SUBTYPE_REPORT, +} from "../core/object/object-constants.js"; +import { TYPE_OBJECT } from "../shared/base-types.js"; +import { + REF_BEARING_ATTR_NAMES, + effectivePackageOf, + resolveObjectRef, + spellRef, + splitChildTail, + topLevelNodeOf, +} from "../naming-refs.js"; +import { resolveSuperRef } from "../super-resolve.js"; +import { dimensionViaTerminal } from "./reporting-validation.js"; + +/** One resolved reference: its owner object and the canonical spelling. */ +interface Resolved { + readonly target: MetaData; + readonly canonical: string; +} + +/** Resolve one reference string's owner in `refPkg`, and spell it canonically. Root-level + * targets are spelled `::Name` unless both the resolution package and the declaring + * package are root-level, so a consumer reading from either package binds the same node. */ +function resolveOne(root: MetaData, value: string, refPkg: string, declPkg: string): Resolved | undefined { + const { owner, tail } = splitChildTail(value); + const target = resolveObjectRef(root, owner, refPkg).node; + if (target === undefined) return undefined; + return { target, canonical: spellRef(target, refPkg !== "" ? refPkg : declPkg, tail) }; +} + +/** R3 — the package an own reference attribute of `node` resolves in. */ +function referrerPackage(root: MetaData, node: MetaData, attr: string, declPkg: string): string { + // `@of` on a dimension that walks `@via`: the package of the entity the walk reaches. + if (node.type === TYPE_DIMENSION && attr === REPORTING_ATTR_OF) { + // ADR-0039: resolving — the @via that governs this @of may be inherited. + const via = node.attr(REPORTING_ATTR_VIA); + const declaring = node.parent; + if (typeof via === "string" && via !== "" && declaring !== undefined) { + const terminal = dimensionViaTerminal(root, declaring, via); + return terminal !== undefined ? effectivePackageOf(terminal) : declPkg; + } + } + // `@spine` on a report: the package of the `@from` entity. + if (node.type === TYPE_OBJECT && node.subType === OBJECT_SUBTYPE_REPORT && attr === OBJECT_REPORT_ATTR_SPINE) { + // ADR-0039: resolving — @from may be inherited. + const from = node.attr(OBJECT_REPORT_ATTR_FROM); + if (typeof from === "string" && from !== "") { + const fromNode = resolveObjectRef(root, from, declPkg).node; + if (fromNode !== undefined) return effectivePackageOf(fromNode); + } + } + return declPkg; +} + +function resolveAttr(root: MetaData, node: MetaData, attr: MetaAttr, declPkg: string): void { + const value = attr.value; + const refPkg = referrerPackage(root, node, attr.name, declPkg); + if (typeof value === "string") { + if (value === "") return; + const r = resolveOne(root, value, refPkg, declPkg); + if (r === undefined) return; + attr.resolvedTarget = r.target; + if (r.canonical !== value) { + attr.authoredValue ??= value; + node.setAttr(attr.name, r.canonical); + } + return; + } + if (Array.isArray(value) && value.every((v) => typeof v === "string")) { + const items = value as readonly string[]; + const resolved = items.map((v) => (v === "" ? undefined : resolveOne(root, v, refPkg, declPkg))); + if (resolved.every((r) => r === undefined)) return; + attr.resolvedTarget = resolved.map((r) => r?.target); + const canonical: AttrValue = items.map((v, i) => resolved[i]?.canonical ?? v); + if (canonical.some((v, i) => v !== items[i])) { + attr.authoredValue ??= [...items]; + node.setAttr(attr.name, canonical); + } + } +} + +/** Canonical `extends`: the owner spelled from the declaring node's package, tail kept. */ +function resolveExtends(root: MetaData, node: MetaData): void { + const ref = node.superRef; + if (ref === undefined || node.superResolved === undefined) return; + const pkg = effectivePackageOf(node); + const { owner, tail } = splitChildTail(ref); + let ownerNode: MetaData | undefined; + try { + ownerNode = resolveSuperRef(owner, pkg, root); + } catch { + return; // an over-drop never resolved, so superResolved would be unset + } + if (ownerNode === undefined) return; + const canonical = spellRef(ownerNode, pkg, tail); + if (canonical === ref) return; + node.authoredSuperRef ??= ref; + node.setSuper(canonical); +} + +/** + * Resolve every own reference attribute (ADR-0059 R4) and every `extends` in the + * tree, record the targets, and rewrite resolved values to their canonical spelling. + * Must run after the last validation pass and before `freeze()`. + */ +export function resolveReferences(root: MetaData): void { + const visit = (node: MetaData): void => { + if (node !== root) { + resolveExtends(root, node); + // R3 default: the effective package of the top-level node that physically + // declares the attribute. Own attributes are declared here, by definition. + const declPkg = effectivePackageOf(topLevelNodeOf(node)); + // ADR-0039: own — this pass rewrites the declared-here layer; an inherited + // attribute is resolved once, on the node that declares it. + for (const attr of node.ownMetaAttrs()) { + if (REF_BEARING_ATTR_NAMES.has(attr.name)) resolveAttr(root, node, attr, declPkg); + } + } + // ADR-0039: own — structural walk over the physical declaration tree. + for (const child of node.ownChildren()) visit(child); + }; + visit(root); +} diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index fa5fbf04d..5f3650e8d 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -15,7 +15,7 @@ import type { MetaObject } from "../core/object/meta-object.js"; import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js"; import { contradictionHint, contradictionsFor } from "../attr-contradictions.js"; import { ParseError, type ErrorCode } from "../errors.js"; -import { resolveObjectRef, didYouMeanHint } from "../naming-refs.js"; +import { resolveObjectRef, didYouMeanHint, refTarget } from "../naming-refs.js"; import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js"; import { resolvedSource, type ErrorSource } from "../source.js"; import { @@ -138,7 +138,6 @@ import type { MetaRelationship } from "../core/relationship/meta-relationship.js // Rule (f) runs the REAL M:N derivation so the loader and codegen cannot drift. import { deriveM2MFields, M2MDerivationError } from "../core/relationship/derive-m2m-fields.js"; import { isReadOnlySource, isWritableSource } from "../shared/node-guards.js"; -import { stripPackage } from "../naming.js"; import { FILTER_COMPOSE_OR, FILTER_COMPOSE_AND, @@ -957,8 +956,8 @@ function _inferViaSingleHop( .filter((c) => c.type === TYPE_RELATIONSHIP) .filter((rel) => { // ADR-0039: resolving — a relationship may inherit @objectRef via extends. - const ref = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF); - return typeof ref === "string" && stripPackage(ref) === targetEntity.name; + // ADR-0059 R7: compare the bound target, never a short name. + return refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF) === targetEntity; }); // FR5d — referrer is `::`, target is the from/of ref // whose implicit path could not be resolved. @@ -2207,7 +2206,11 @@ export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[] const objectRef = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF); if (typeof objectRef !== "string" || objectRef === "") continue; - const candidates = referenceCandidatesFor(obj, objectRef); + // ADR-0059 R7: match references by the bound target node. An unresolved + // @objectRef is reported by its own pass. + const targetNode = refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF); + if (targetNode === undefined) continue; + const candidates = referenceCandidatesFor(obj, targetNode); const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); const declared = typeof sourceRefField === "string" && sourceRefField !== "" @@ -2237,7 +2240,7 @@ export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[] // No @sourceRefField declared: ambiguity only exists with 2+ candidates — // resolveRelationshipReference's name-pairing step (ladder step 3) decides. if (candidates.length <= 1) continue; - const resolved = resolveRelationshipReference(obj, rel.name, objectRef); + const resolved = resolveRelationshipReference(obj, rel.name, targetNode); if (resolved) continue; errors.push( diff --git a/server/typescript/packages/metadata/src/loader/validation-registry.ts b/server/typescript/packages/metadata/src/loader/validation-registry.ts index 5cc609ee5..be619b998 100644 --- a/server/typescript/packages/metadata/src/loader/validation-registry.ts +++ b/server/typescript/packages/metadata/src/loader/validation-registry.ts @@ -8,9 +8,13 @@ import type { MetaData } from "../shared/meta-data.js"; import { ParseError } from "../errors.js"; -import { didYouMeanHint } from "../naming-refs.js"; +import { + didYouMeanHint, + ownerCandidateKeys, + referrerPackageFor, + RefOverDropError, +} from "../naming-refs.js"; import { TYPE_OBJECT } from "../shared/base-types.js"; -import { PACKAGE_SEPARATOR } from "../shared/structural.js"; import type { TypeRegistry } from "../registry.js"; import type { LoaderCode, SymbolTable, ValidationContext } from "../validation-types.js"; @@ -38,15 +42,25 @@ class SymbolTableImpl implements SymbolTable { return t; } - /** ADR-0042 package-local resolution within the `type` bucket: FQN → exact - * resolution-key match; bare → the referrer's own package (`::`), - * else a root-level node of that type. */ + /** ADR-0059 R2 resolution within the `type` bucket: `::Rest` root-absolute, + * `..::Rest` parent-relative, `pkg::Name` exact, bare `Name` in the referrer's own + * package then root-level. Exact keys only. An over-dropping `..::` resolves to + * nothing here; the walk reports it as ERR_BAD_ATTR_VALUE. */ resolve(type: string, ref: string, referrerPkg: string): MetaData | undefined { const bucket = this.byType.get(type); if (bucket === undefined) return undefined; - if (ref.includes(PACKAGE_SEPARATOR)) return bucket.get(ref); - const localKey = referrerPkg !== "" ? `${referrerPkg}${PACKAGE_SEPARATOR}${ref}` : ref; - return bucket.get(localKey) ?? bucket.get(ref); + let keys: readonly string[]; + try { + keys = ownerCandidateKeys(ref, referrerPkg); + } catch (e) { + if (e instanceof RefOverDropError) return undefined; + throw e; + } + for (const key of keys) { + const hit = bucket.get(key); + if (hit !== undefined) return hit; + } + return undefined; } resolveObject(ref: string, referrerPkg: string): MetaData | undefined { @@ -94,7 +108,21 @@ export function runRegisteredValidation(root: MetaData, registry: TypeRegistry): const raw = node.attr(desc.attr); if (typeof raw !== "string" || raw === "") continue; // absence is the required-attr pass's job const entityRef = desc.dottedFieldPath ? (raw.split(".")[0] ?? raw) : raw; - const target = ctx.symbols.resolve(desc.targetType, entityRef, pkg); + // ADR-0059 R3: an inherited reference resolves in the package of the + // top-level node that physically declares it, not the inheritor's. + const refPkg = referrerPackageFor(node, desc.attr); + try { + ownerCandidateKeys(entityRef, refPkg); + } catch (e) { + if (!(e instanceof RefOverDropError)) throw e; + ctx.error( + "ERR_BAD_ATTR_VALUE", + node, + `${node.type}.${node.subType} "${node.name}" @${desc.attr} "${raw}" climbs above the root package — each '..::' drops one segment of the declaring package.`, + ); + continue; + } + const target = ctx.symbols.resolve(desc.targetType, entityRef, refPkg); // Qualify the node name with its owning entity (e.g. "Order.items") so the error is // locatable from the message alone, not just the source envelope. const qname = node.parent?.name ? `${node.parent.name}.${node.name}` : node.name; diff --git a/server/typescript/packages/metadata/src/naming-refs.ts b/server/typescript/packages/metadata/src/naming-refs.ts index 595629ab9..e447325e7 100644 --- a/server/typescript/packages/metadata/src/naming-refs.ts +++ b/server/typescript/packages/metadata/src/naming-refs.ts @@ -1,30 +1,25 @@ -// FR-032 (ADR-0032) — canonical reference expansion. +// References: the one resolver every reference form goes through (ADR-0059). // -// `expandRef(raw, packageContext)` is the SINGLE ref-expansion primitive. It -// lowers an authored metadata reference to its fully-qualified canonical form -// per ADR-0032 §2.1 — deterministically, with NO root fallback: +// Sources do not interpret references: YAML, canonical JSON and any future source +// build the tree with every reference string exactly as authored. This module +// gives each form its meaning, once, against the built tree: // -// bare `Name` (no `::`, no leading `.`) → `

    ::Name` (current package only; -// stays bare when P is empty/root). -// qualified `pkg::Name` (contains `::`, → unchanged (absolute from root). -// NOT leading `::`) -// `::Rest` (leading `::`) → strip the leading `::`; the -// remainder is absolute from root -// (so `::a::b::C` ≡ `a::b::C`, -// `::Apple` = root-level Apple). -// `..::Rest` (one or more leading `..::`) → drop one package segment from P -// per `..::`, then resolve Rest -// (itself bare/qualified) against the -// reduced package. Over-drop (more -// `..::` than P has segments) throws. +// `::Rest` root-absolute — the node whose resolution key is exactly `Rest` +// (`::Apple` is the root-level Apple; `::a::b::C` is `a::b::C`). +// `..::Rest` parent-relative — drop one segment of the referrer's package per +// `..::`, then the exact key `::Rest`. More `..::` than +// the package has segments is ERR_BAD_ATTR_VALUE. +// `pkg::Name` exact. +// `Name` the referrer's own package (`::Name`), else the +// root-level `Name` (ADR-0042 §2). Never another package. // -// The trailing FR-024 DOTTED CHILD suffix (`.child` / `.child.grandchild`) is -// PRESERVED verbatim: only the OWNER part (everything before the first `.` in -// the final `::`-segment) is expanded; the `.child...` tail is reattached. +// No form falls back to another: `::Apple` never binds a local `acme::Apple`. +// A FR-024 dotted child tail (`Owner.child.grand`) starts at the first `.` after the +// last `::`; only the owner part is resolved by form, and the tail is kept as written. // -// The desugar runs this on every ref-bearing attr so canonical JSON is FQN-only -// (ADR-0032 §2.2/§2.3); the resolution layer then does pure FQN matching. The -// `package` attribute is NEVER expanded — it is the node's identity. +// `spellRef` is the inverse: the one canonical spelling of a resolved target that +// resolves to the same node with or without a referrer. The `resolveReferences` +// loader pass rewrites every reference to it, so consumers read canonical values. import type { MetaData } from "./shared/meta-data.js"; import { PACKAGE_SEPARATOR, PACKAGE_PARENT, CHILD_REF_SEPARATOR } from "./shared/structural.js"; @@ -39,22 +34,20 @@ import { ORIGIN_AGGREGATE_ATTR_VIA, } from "./persistence/origin/origin-constants.js"; import { SOURCE_ATTR_PARAMETER_REF } from "./persistence/source/source-constants.js"; +import { OBJECT_REPORT_ATTR_FROM, OBJECT_REPORT_ATTR_SPINE } from "./core/object/object-constants.js"; import { TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_RESPONSE_REF } from "./template/template-constants.js"; const PARENT_PREFIX = PACKAGE_PARENT + PACKAGE_SEPARATOR; // "..::" /** - * The inline (`@`-prefixed) attribute names whose VALUE is a metadata reference - * subject to FR-032 expansion/guarding (ADR-0032 §3). The structural `extends` - * key is handled separately (it is not `@`-prefixed). `@objectRef`/`@references` - * are pure object refs; `@from`/`@of`/`@via` carry an entity HEAD (possibly with - * a dotted relationship/field tail — expandRef preserves the tail); - * `@parameterRef`/`@payloadRef`/`@responseRef` reference value-objects. These are - * expanded by the YAML desugar and rejected (when still relative) by the - * canonical-JSON guard. `@through` (the M:N junction ref) is in the set per - * ADR-0042 §4 — it desugars to FQN and resolves package-local like every other - * object ref. `@sourceRefField` (a FK FIELD name, not an object ref) is NOT in - * the set. + * The inline (`@`-prefixed) attribute names whose VALUE is a metadata object + * reference (ADR-0059 R4). The structural `extends` key is handled separately (it is + * not `@`-prefixed). `@objectRef`/`@references`/`@through` are pure object refs; + * `@from`/`@of`/`@via`/`@spine` carry an entity HEAD with a dotted + * relationship/field tail; `@parameterRef`/`@payloadRef`/`@responseRef` name + * value-objects. The same set is used in every port. `@sourceRefField` (a FK FIELD + * name) and `@implementedBy` (which may name a template as well as an object) are + * NOT in the set. */ export const REF_BEARING_ATTR_NAMES: ReadonlySet = new Set([ RELATIONSHIP_ATTR_OBJECT_REF, // = FIELD_ATTR_OBJECT_REF (same spelling "objectRef") @@ -68,13 +61,11 @@ export const REF_BEARING_ATTR_NAMES: ReadonlySet = new Set([ SOURCE_ATTR_PARAMETER_REF, TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_RESPONSE_REF, + OBJECT_REPORT_ATTR_FROM, // = ORIGIN_PASSTHROUGH_ATTR_FROM ("from") + OBJECT_REPORT_ATTR_SPINE, ]); -/** - * True when `raw` is a relative reference form (`::Rest` or `..::Rest`) that the - * YAML desugar must expand before canonical JSON. Canonical JSON must be FQN; a - * relative ref surviving into it is `ERR_RELATIVE_REF_IN_CANONICAL`. - */ +/** True when `raw` is a relative reference form (`::Rest` or `..::Rest`). */ export function isRelativeRef(raw: string): boolean { return raw.startsWith(PACKAGE_SEPARATOR) || raw.startsWith(PARENT_PREFIX); } @@ -86,7 +77,7 @@ export function isRelativeRef(raw: string): boolean { * at the first `.` AFTER the last `::`. Returns `{ owner, tail }` where `tail` * includes the leading `.` (or is "" when there is no child suffix). */ -function splitChildTail(raw: string): { owner: string; tail: string } { +export function splitChildTail(raw: string): { owner: string; tail: string } { const lastSep = raw.lastIndexOf(PACKAGE_SEPARATOR); const segStart = lastSep === -1 ? 0 : lastSep + PACKAGE_SEPARATOR.length; const dotInSeg = raw.indexOf(CHILD_REF_SEPARATOR, segStart); @@ -95,8 +86,11 @@ function splitChildTail(raw: string): { owner: string; tail: string } { } /** - * Expand an authored reference's OWNER part (no child tail) to its FQN per - * ADR-0032 §2.1. Throws on parent-relative over-drop. + * Expand an authored reference's OWNER part (no child tail) against a package + * context: `::Rest` → `Rest`, `..::Rest` → the reduced package `::Rest`, + * `pkg::Name` unchanged, bare `Name` → `

    ::Name`. Throws on parent-relative + * over-drop. The resolver uses it for the two relative forms only; a bare name is + * resolved own-package-then-root (see `ownerCandidateKeys`). */ function expandOwner(owner: string, packageContext: string): string { // root-absolute: leading "::" → strip; the remainder is absolute from root. @@ -136,73 +130,176 @@ function expandOwner(owner: string, packageContext: string): string { } /** - * Expand an authored reference to its fully-qualified canonical form per - * ADR-0032 §2.1, preserving any FR-024 dotted child suffix. The package context - * `P` is the declaring node's effective package (its own `package` or the - * file-default). Throws on parent-relative over-drop — the caller emits an error. + * Expand an authored reference against a package context, preserving any FR-024 + * dotted child suffix. Throws on parent-relative over-drop — the caller emits an + * error. */ export function expandRef(raw: string, packageContext: string): string { const { owner, tail } = splitChildTail(raw); return expandOwner(owner, packageContext) + tail; } +/** A parent-relative reference with more `..::` than its referrer's package has segments. */ +export class RefOverDropError extends Error { + readonly code = "ERR_BAD_ATTR_VALUE" as const; +} + /** - * ADR-0042 — does root-level object `node` satisfy object reference `ref`, - * declared by a node whose effective package is `referrerPkg`? A single - * package-local matcher: - * - **FQN** `ref` (contains `::`) → EXACT match on `resolutionKey()`. No - * bare-tail fallback, so an FQN pointing at one package never binds a - * same-named object in another. - * - **bare** `ref` (no `::`) → the referrer's OWN package - * (`::`), else a **root-level** (empty-package) object - * whose resolution key IS `ref`. No cross-package bare resolution, no - * globally-unique scan. - * - * Objects keep a BARE `fqn()` per the FR5d cross-port contract, so the canonical - * FQN accessor is `resolutionKey()` (`::`) — - * this mirrors `super-resolve`'s `findInTree`. This is the single matcher the - * non-super resolvers (origin `@from`/`@of`/`@via` heads, template - * `@payloadRef`/`@responseRef`, source `@parameterRef`, relationship `@through`) - * share, so resolution behaves identically everywhere a ref resolves. - * - * `referrerPkg` defaults to `""` — the fail-closed FQN-exact-plus-root-level - * behavior. The LOADER always passes the real referrer package (bare refs there - * must resolve package-locally); downstream CODEGEN resolvers, which run on - * already-validated metadata whose refs are FQN, may omit it. An empty - * `referrerPkg` never binds a bare ref across a package boundary (fail-closed), - * so omission can only fail to resolve — never mis-resolve. + * The resolution keys an OWNER reference (no child tail) may bind, in the order to + * try them (ADR-0059 R2). Every form but bare is one exact key; bare is the + * referrer's own package, then root-level. Throws RefOverDropError on over-drop. */ -export function refMatchesObject(node: MetaData, ref: string, referrerPkg = ""): boolean { - const key = node.resolutionKey(); - if (ref.includes(PACKAGE_SEPARATOR)) return key === ref; - if (referrerPkg !== "" && key === `${referrerPkg}${PACKAGE_SEPARATOR}${ref}`) return true; - return key === ref; // root-level (empty-package) object whose key is the bare name +export function ownerCandidateKeys(owner: string, referrerPkg: string): readonly string[] { + if (owner.startsWith(PACKAGE_SEPARATOR)) return [owner.slice(PACKAGE_SEPARATOR.length)]; + if (owner.startsWith(PARENT_PREFIX)) { + try { + return [expandOwner(owner, referrerPkg)]; + } catch (err) { + throw new RefOverDropError((err as Error).message); + } + } + if (owner.includes(PACKAGE_SEPARATOR)) return [owner]; + return referrerPkg !== "" ? [`${referrerPkg}${PACKAGE_SEPARATOR}${owner}`, owner] : [owner]; } /** - * Resolve a metadata OBJECT reference under the ADR-0042 package-local contract - * (see `refMatchesObject` for the matcher). `referrerPkg` is the effective - * package of the node carrying the ref. Returns `{ node }` on resolution, or - * `{}` (node undefined) when nothing matches — there is NO ambiguous outcome - * (bare = package-local, so ambiguity is unreachable). The SINGLE resolver every - * object-ref site shares so the contract is uniform. + * Does root-level object `node` satisfy object reference `ref` (no child tail), + * declared by a node whose effective package is `referrerPkg`? True when the node's + * resolution key is the FIRST candidate key that any object could bind — so it + * agrees with `resolveObjectRef`. Never matches on a short name. + */ +export function refMatchesObject(node: MetaData, ref: string, referrerPkg: string): boolean { + const resolved = resolveObjectRef(node.parent ?? node, ref, referrerPkg).node; + if (resolved !== undefined) return resolved === node; + return false; +} + +/** The root's direct children of `type` (ADR-0039: root has no super). */ +function topLevelOfType(root: MetaData, type: string | undefined): readonly MetaData[] { + const all = root.children(); + return type === undefined ? all : all.filter((c) => c.type === type); +} + +/** Bind an owner reference among `candidates` by exact resolution key (R2). */ +function bindOwner( + candidates: readonly MetaData[], + owner: string, + referrerPkg: string, +): { node?: MetaData | undefined; error?: string | undefined } { + let keys: readonly string[]; + try { + keys = ownerCandidateKeys(owner, referrerPkg); + } catch (err) { + return { error: (err as Error).message }; + } + for (const key of keys) { + const node = candidates.find((c) => c.resolutionKey() === key); + if (node !== undefined) return { node }; + } + return {}; +} + +/** + * Resolve a metadata OBJECT reference (no child tail) under ADR-0059 R2. + * `referrerPkg` is the referrer's package (R3). Returns `{ node }` on resolution, + * `{ error }` for a parent-relative over-drop, or `{}` when nothing matches. The + * single object resolver every object-ref site shares. */ export function resolveObjectRef( root: MetaData, ref: string, referrerPkg: string, -): { node?: MetaData | undefined } { - const objects = root.children().filter((c) => c.type === TYPE_OBJECT); - if (ref.includes(PACKAGE_SEPARATOR)) { - return { node: objects.find((c) => c.resolutionKey() === ref) }; +): { node?: MetaData | undefined; error?: string | undefined } { + return bindOwner(topLevelOfType(root, TYPE_OBJECT), ref, referrerPkg); +} + +/** + * Resolve a reference that may carry a dotted child tail (ADR-0059 R2): bind the + * owner among the root's top-level nodes (of `ownerType` when given), then walk the + * tail by child name. Intermediate segments must be unique among the current node's + * effective children; the last segment is scoped to `lastType` when given. Returns + * the owner as well as the target, so a caller can spell the owner and keep the tail. + */ +export function resolveRef( + root: MetaData, + ref: string, + referrerPkg: string, + opts?: { ownerType?: string; lastType?: string }, +): { node?: MetaData | undefined; owner?: MetaData | undefined; error?: string | undefined } { + const { owner, tail } = splitChildTail(ref); + const bound = bindOwner(topLevelOfType(root, opts?.ownerType), owner, referrerPkg); + if (bound.node === undefined) return bound; + if (tail === "") return { node: bound.node, owner: bound.node }; + const path = tail.slice(CHILD_REF_SEPARATOR.length).split(CHILD_REF_SEPARATOR); + if (path.some((p) => p === "")) return {}; + let current: MetaData = bound.node; + for (let i = 0; i < path.length - 1; i++) { + const matches = current.children().filter((c) => c.name === path[i]); + if (matches.length !== 1) return { owner: bound.node }; + current = matches[0]!; } - // Bare: PREFER the referrer's own package, THEN a root-level (empty-package) - // object — mirroring the loader symbol table's `byKey.get(localKey) ?? get(ref)` - // so both resolvers agree when a root-level object shares the bare name. - const localKey = referrerPkg !== "" ? `${referrerPkg}${PACKAGE_SEPARATOR}${ref}` : ref; - const own = objects.find((c) => c.resolutionKey() === localKey); - if (own !== undefined) return { node: own }; - return { node: localKey !== ref ? objects.find((c) => c.resolutionKey() === ref) : undefined }; + const last = path[path.length - 1]!; + const target = current + .children() + .find((c) => c.name === last && (opts?.lastType === undefined || c.type === opts.lastType)); + return { node: target, owner: bound.node }; +} + +/** + * ADR-0059 R7 — resolve a CANONICAL reference with no referrer: strip one leading + * `::`, match a top-level node's resolution key exactly, then walk any dotted tail. + * For consumers that hold a resolved model and no referrer context. Never matches + * on a short name. + */ +export function lookupCanonical( + root: MetaData, + value: string, + opts?: { ownerType?: string; lastType?: string }, +): MetaData | undefined { + const { owner, tail } = splitChildTail(value); + const exact = owner.startsWith(PACKAGE_SEPARATOR) ? owner : `${PACKAGE_SEPARATOR}${owner}`; + return resolveRef(root, exact + tail, "", opts).node; +} + +/** + * ADR-0059 R6 — the canonical spelling of a resolved owner, as written from a + * referrer in package `referrerPkg`: `pkg::Name` for a packaged target, `::Name` for + * a root-level target from a packaged referrer, `Name` from a root-level referrer. + * Every spelling resolves to the same node with or without a referrer. + */ +export function spellRef(target: MetaData, referrerPkg: string, tail = ""): string { + const key = target.resolutionKey(); + const rootLevel = !key.includes(PACKAGE_SEPARATOR); + return (rootLevel && referrerPkg !== "" ? `${PACKAGE_SEPARATOR}${key}` : key) + tail; +} + +/** Effective package of a node: its own `package`, else its file's (ADR-0059 R10). */ +export function effectivePackageOf(node: MetaData): string { + return node.package ?? node.fileDefaultPackage ?? ""; +} + +/** The top-level node (a direct child of the root) that physically contains `node`. */ +export function topLevelNodeOf(node: MetaData): MetaData { + let current = node; + while (current.parent !== undefined && current.parent.parent !== undefined) current = current.parent; + return current; +} + +/** + * ADR-0059 R3 default — the package a reference attribute resolves in: the effective + * package of the top-level node that PHYSICALLY declares the attribute. For an + * attribute `node` inherits through `extends`, that is the ancestor that wrote it, + * so an inherited reference means what it meant where it was written. + */ +export function referrerPackageFor(node: MetaData, attrName: string): string { + let declarer: MetaData | undefined = node; + const seen = new Set(); + // ADR-0039: own — locating the layer that physically declares the attr. + while (declarer !== undefined && !declarer.ownHasAttr(attrName) && !seen.has(declarer)) { + seen.add(declarer); + declarer = declarer.superResolved; + } + return effectivePackageOf(topLevelNodeOf(declarer ?? node)); } /** @@ -223,3 +320,29 @@ export function didYouMeanHint(root: MetaData, ref: string): string { if (candidates.length === 0) return ""; return ` An object named "${shortName}" exists in: ${candidates.join(", ")}. Qualify it with its package (FQN).`; } + +/** + * ADR-0059 R7 — the object a reference attribute on `node` names: the owner the loader + * bound once (`MetaAttr.resolvedTarget`), read RESOLVING so an inherited attribute + * works (ADR-0039). For a dotted ref (`Week.id`, `Program.weeks`) it is the owner + * object, not the tail. A tree built without the loader has no recorded target, so the + * value is resolved here from the package of the node that declared it. Undefined when + * the attribute is absent, is a list, or names nothing. Consumers use this instead of + * comparing a reference value to a short name. + */ +export function refTarget(node: MetaData, attrName: string): MetaData | undefined { + const seen = new Set(); + for (let n: MetaData | undefined = node; n !== undefined && !seen.has(n); n = n.superResolved) { + seen.add(n); + // ADR-0039: own — locating the layer that declares the attr, walking the super chain. + const attr = n.ownMetaAttr(attrName); + if (attr === undefined) continue; + const recorded = attr.resolvedTarget; + if (recorded !== undefined) return Array.isArray(recorded) ? undefined : (recorded as MetaData); + const value = attr.value; + if (typeof value !== "string" || value === "") return undefined; + const { owner } = splitChildTail(value); + return resolveObjectRef(node.root(), owner, effectivePackageOf(topLevelNodeOf(n))).node; + } + return undefined; +} diff --git a/server/typescript/packages/metadata/src/object-serializer.ts b/server/typescript/packages/metadata/src/object-serializer.ts index 24f4a474f..119fdd207 100644 --- a/server/typescript/packages/metadata/src/object-serializer.ts +++ b/server/typescript/packages/metadata/src/object-serializer.ts @@ -20,6 +20,7 @@ import { DATA_TYPE_OBJECT, } from "./data-type.js"; import { TYPE_OBJECT } from "./shared/base-types.js"; +import { lookupCanonical } from "./naming-refs.js"; const TYPE_DISCRIMINATOR = "@type"; @@ -142,16 +143,13 @@ function coerceNumeric(raw: unknown): unknown { return raw; } -/** Resolve an object-typed field's `@objectRef` to its target MetaObject by - * walking to the tree root and looking up the named object child. Returns - * undefined when there is no ref or it does not resolve (caller passes the - * raw value through — best-effort). */ +/** Resolve an object-typed field's `@objectRef` to its target MetaObject. The loader + * has already rewritten the ref to its canonical spelling (ADR-0059 R5), so it is + * looked up exactly, with no referrer (R7). Returns undefined when there is no ref + * or it does not resolve (caller passes the raw value through — best-effort). */ function resolveObjectRef(field: MetaField, mo: MetaObject): MetaObject | undefined { const ref = field.objectRef; if (ref === undefined) return undefined; - // ADR-0039: own — lookup is on the tree ROOT, which has no super chain - // (it never `extends`), so its own children ARE its effective children. - const found: MetaData | undefined = mo.root().ownChildByTypeAndName(TYPE_OBJECT, ref); - if (found === undefined) return undefined; - return found as MetaObject; + const found: MetaData | undefined = lookupCanonical(mo.root(), ref, { ownerType: TYPE_OBJECT }); + return found as MetaObject | undefined; } diff --git a/server/typescript/packages/metadata/src/parser-core.ts b/server/typescript/packages/metadata/src/parser-core.ts index 73de8c62e..fc3798e93 100644 --- a/server/typescript/packages/metadata/src/parser-core.ts +++ b/server/typescript/packages/metadata/src/parser-core.ts @@ -39,6 +39,7 @@ import { extendsTargetCompatible, EXTENDS_TARGET_MISMATCH_RULE, } from "./super-resolve.js"; +import { RefOverDropError } from "./naming-refs.js"; import { JsonPathBuilder, type Segment as JsonPathSegment } from "./json-path.js"; import { getYamlPosition, type YamlPosition } from "./core/yaml-positions.js"; import { @@ -66,7 +67,6 @@ import { import { ATTR_SUBTYPE_PROPERTIES, ATTR_SUBTYPE_STRINGARRAY } from "./core/attr/attr-constants.js"; import { attrClassFor } from "./attr-class-map.js"; import type { AttrValue } from "./shared/meta-data.js"; -import { isRelativeRef, REF_BEARING_ATTR_NAMES } from "./naming-refs.js"; // --------------------------------------------------------------------------- // Public API @@ -234,44 +234,6 @@ function reportProblem( warnings.push(msg); } -// --------------------------------------------------------------------------- -// FR-032 (ADR-0032) — canonical-JSON ref guard. -// -// Canonical JSON is the self-contained interchange form: every ref-bearing attr -// MUST be fully-qualified. A relative authoring form (leading `::` or `..::`) -// surviving into canonical JSON is `ERR_RELATIVE_REF_IN_CANONICAL`. The guard -// fires ONLY for JSON-format input — YAML-format input has already been -// desugar-expanded via expandRef (so any `::`/`..::` there is correct authoring -// that was lowered to FQN before buildTree sees it). Like ERR_RESERVED_ATTR, -// this is a hard error routed through the loader's error sink even in lax mode. -// --------------------------------------------------------------------------- - -function guardRelativeRefInCanonical( - refLabel: string, - rawValue: unknown, - strict: boolean, - warnings: string[], - path: string, -): void { - if (_currentFormat !== "json") return; - if (typeof rawValue !== "string") return; - if (!isRelativeRef(rawValue)) return; - const msg = - `Relative reference '${rawValue}' on ${refLabel} at ${path} is not allowed in ` + - `canonical JSON — canonical JSON must be fully-qualified. Relative forms ` + - `(leading '::' or '..::') are YAML-authoring sugar that the desugar expands.`; - if (strict) { - throw new ParseError(msg, { code: "ERR_RELATIVE_REF_IN_CANONICAL", source: errSource() }); - } - if (_currentErrors !== undefined) { - _currentErrors.push( - new ParseError(msg, { code: "ERR_RELATIVE_REF_IN_CANONICAL", source: errSource() }), - ); - } else { - warnings.push(msg); - } -} - // --------------------------------------------------------------------------- // The one message for an authored `.base`, so both doors say the same thing. // @@ -701,10 +663,11 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult { if (opts.intoRoot !== undefined) { // --- Merge mode: parse root's attrs/children into the existing root --- // The JSON root's own package/super/reserved-keys are not re-applied to the - // existing root. BUT: children from the NEW JSON should inherit from the - // NEW root's package, not the existing root's. + // existing root. Children from the NEW document take the NEW document's + // package — and a document with no `package` declares root-level nodes, + // whatever was loaded before it (ADR-0059 R10: the default package is per file). const newRootPkg = rootDataObj[RESERVED_KEY_PACKAGE]; - const contextPkg = (typeof newRootPkg === "string" ? newRootPkg : opts.intoRoot.package) ?? ""; + const contextPkg = typeof newRootPkg === "string" ? newRootPkg : ""; parseNodeInto( rootDataObj, opts.intoRoot, @@ -825,9 +788,9 @@ function parseNodeFresh( // this node by its effective qualified key regardless of post-merge tree // shape or load order. This does NOT touch the node's `package` or `fqn()` // (objects stay bare per FR5d). Mirrors Java's parse-time getDefaultPackageName(). - if (inheritedContextPkg !== "") { - model.setFileDefaultPackage(inheritedContextPkg); - } + // Recorded even when empty: "" says the file declares no package (ADR-0059 + // R10), which is not the same as a node built in code with no file at all. + model.setFileDefaultPackage(inheritedContextPkg); // --- Apply reserved keys (package, extends, abstract, isArray) --- applyReservedKeys(model, nodeData, strict, source, path, warnings, inheritedContextPkg); @@ -861,7 +824,17 @@ function parseNodeFresh( if (model.superRef !== undefined && accumRoot !== undefined && !_deferSuperResolution) { // FR-024: thread the referrer's type so dotted `Entity.child` refs resolve // type-scoped — kept consistent with the deferred path (super-resolve.ts). - const superModel = resolveSuperRef(model.superRef, effectivePkg, accumRoot, { type: model.type }); + let superModel: MetaData | undefined; + try { + superModel = resolveSuperRef(model.superRef, effectivePkg, accumRoot, { type: model.type }); + } catch (e) { + if (!(e instanceof RefOverDropError)) throw e; + // ADR-0059 R2 — `..::` dropped more segments than the package has. + throw new ParseError( + `the extends ref '${model.superRef}' on ${model.fqn()} climbs above the root package — each '..::' drops one segment of the declaring package`, + { code: "ERR_BAD_ATTR_VALUE", source: resolvedSource(errSource(), model.fqn(), model.superRef) }, + ); + } if (superModel !== undefined) { // FR-024 — a dotted child-targeting ref must resolve to a node of the // SAME type and subtype as the extending node. Dotted-only check; the @@ -1458,9 +1431,6 @@ function applyReservedKeys( if (typeof rawExtends !== "string") { reportProblem(`"${RESERVED_KEY_EXTENDS}" must be a string at ${path}`, strict, warnings, "ERR_UNRESOLVED_SUPER"); } else { - // FR-032: canonical JSON `extends` must be FQN; reject a surviving - // relative form (no-op for YAML-format input, which was desugar-expanded). - guardRelativeRefInCanonical(`"${RESERVED_KEY_EXTENDS}"`, rawExtends, strict, warnings, path); model.setSuper(rawExtends); } } @@ -1548,13 +1518,6 @@ function applyInlineAttrsAndUnknownKeys( const rawVal = nodeData[key]; - // FR-032: a ref-bearing inline attr (@objectRef/@references/@from/@of/@via/ - // @parameterRef/@payloadRef/@responseRef) in canonical JSON must be FQN — - // reject a surviving relative form. No-op for YAML-format input. - if (REF_BEARING_ATTR_NAMES.has(attrName)) { - guardRelativeRefInCanonical(`${ATTR_PREFIX}${attrName}`, rawVal, strict, warnings, path); - } - try { const attr = materializeAttr(model, attrName, rawVal, registry); model.setMetaAttr(attr); diff --git a/server/typescript/packages/metadata/src/serializer-json.ts b/server/typescript/packages/metadata/src/serializer-json.ts index a8ebae582..06077835c 100644 --- a/server/typescript/packages/metadata/src/serializer-json.ts +++ b/server/typescript/packages/metadata/src/serializer-json.ts @@ -41,15 +41,28 @@ const SOURCE_RDB_FUSED_KEY = `${TYPE_SOURCE}${TYPE_SUBTYPE_SEPARATOR}${SOURCE_SU // Public API // --------------------------------------------------------------------------- +/** + * How references are written (ADR-0059 R8). + * - "canonical" (default): each reference as the loader resolved it — `pkg::Name`, + * `::Name` for a root-level target from a packaged referrer — and the document + * package rule below. The output loads back to the same model from any source. + * - "authored": each reference, `extends` included, exactly as written, and every + * `package` where it was written. Only `meta fmt` uses it, so formatting never + * rewrites what an author wrote (ADR-0059, C2). + */ +export type RefSpelling = "canonical" | "authored"; + export interface SerializeOptions { /** Pretty-print indent. 0 = no whitespace. Default 2. */ indent?: number; + /** Reference spelling; default "canonical". */ + refs?: RefSpelling; } export function serializeJson(model: MetaData, opts?: SerializeOptions): string { const indent = opts?.indent ?? 2; - const nodeObj = serializeNode(model, false); + const nodeObj = serializeNode(model, false, opts?.refs ?? "canonical", undefined); return JSON.stringify(nodeObj, null, indent === 0 ? undefined : indent); } @@ -82,17 +95,57 @@ function fusedKey(type: string, subType: string): string { // Serialize a single node — returns { ".": { ...body } } // --------------------------------------------------------------------------- +/** + * How a node's `package` key is written. `undefined`: the node's own `package`, as + * authored. Otherwise the package the document rule (ADR-0059 R8) assigns it; "" + * writes none. + */ +type PackageSlot = string | undefined; + function serializeNode( model: MetaData, effective: boolean, + refs: RefSpelling, + pkgSlot: PackageSlot, ): Record { - const inner = serializeNodeInner(model, effective); + const inner = serializeNodeInner(model, effective, refs, pkgSlot); return { [fusedKey(model.type, model.subType)]: inner }; } +/** Effective package of a top-level node (ADR-0059 R10): own, else its file's. A + * node built in code has no file, so it takes the root's package, as re-loading the + * output would give it. */ +function topLevelPackage(node: MetaData, root: MetaData): string { + return node.package ?? node.fileDefaultPackage ?? root.package ?? ""; +} + +/** + * ADR-0059 R8 document package rule, for a `metadata.root` in canonical mode. + * Every top-level node in one non-empty package P: the root carries P (a node keeps + * a package it carries itself, as before). Otherwise the root carries none and each packaged top-level node + * carries its own. A root with no named top-level node keeps its own package. + */ +function documentPackages(root: MetaData): { rootPkg: string | undefined; perNode: boolean } { + // ADR-0039: own — the serializer writes the declared layer; the root has no super. + const named = root.ownChildren().filter((c) => c.name !== ""); + if (named.length === 0) return { rootPkg: root.package, perNode: false }; + const pkgs = new Set(named.map((c) => topLevelPackage(c, root))); + if (pkgs.size === 1) { + const only = [...pkgs][0]!; + // When every node already names P itself and the root names none, the + // document is already complete as written; adding a root package would only + // repeat it. + const selfNamed = root.package === undefined && named.every((c) => c.package === only); + if (only !== "") return { rootPkg: selfNamed ? undefined : only, perNode: false }; + } + return { rootPkg: undefined, perNode: true }; +} + function serializeNodeInner( model: MetaData, effective: boolean, + refs: RefSpelling, + pkgSlot: PackageSlot, ): Record { // Canonical body-key order: // 1. name 2. package 3. extends 4. abstract @@ -104,12 +157,19 @@ function serializeNodeInner( obj[RESERVED_KEY_NAME] = model.name; } - if (model.package !== undefined && model.package !== "") { - obj[RESERVED_KEY_PACKAGE] = model.package; + const isDocRoot = model.type === TYPE_METADATA && model.subType === SUBTYPE_ROOT; + const doc = isDocRoot && refs === "canonical" ? documentPackages(model) : undefined; + + const pkg = doc !== undefined ? doc.rootPkg : pkgSlot !== undefined ? pkgSlot : model.package; + if (pkg !== undefined && pkg !== "") { + obj[RESERVED_KEY_PACKAGE] = pkg; } - if (model.superRef !== undefined) { - obj[RESERVED_KEY_EXTENDS] = model.superRef; + // After the loader's resolveReferences pass `superRef` is canonical (ADR-0059 R5); + // the authored spelling is kept beside it for `meta fmt`. + const superRef = refs === "authored" ? (model.authoredSuperRef ?? model.superRef) : model.superRef; + if (superRef !== undefined) { + obj[RESERVED_KEY_EXTENDS] = superRef; } if (model.isAbstract === true) { @@ -141,13 +201,20 @@ function serializeNodeInner( const serializedChildren: Record[] = []; for (const child of childList) { - // Attrs never appear in children(); only structural nodes recurse. - serializedChildren.push(serializeNode(child, effective)); + // Attrs never appear in children(); only structural nodes recurse. A top-level + // node's package follows the document rule; deeper nodes keep their own. + // In a one-package document a node keeps any package it carries itself (the parser + // gives a root-level field its file's package), so single-package output is unchanged. + const childSlot: PackageSlot = + doc === undefined || !doc.perNode ? undefined : child.name !== "" ? topLevelPackage(child, model) : ""; + serializedChildren.push(serializeNode(child, effective, refs, childSlot)); } // Attrs ALWAYS emit inline @name (D5 — attrs have no children, one canonical form). for (const [attrName, attrValue] of attrMap) { - obj[`${ATTR_PREFIX}${attrName}`] = attrValue; + // ADR-0039: own — the authored value lives on the declaring attr node. + const authored = refs === "authored" && !effective ? model.ownMetaAttr(attrName)?.authoredValue : undefined; + obj[`${ATTR_PREFIX}${attrName}`] = authored ?? attrValue; } if (serializedChildren.length > 0) { @@ -167,8 +234,8 @@ function serializeNodeInner( // byte-identical output from the same input metamodel. // --------------------------------------------------------------------------- -export function canonicalSerialize(model: MetaData): string { - const raw = serializeJson(model, { indent: 2 }); +export function canonicalSerialize(model: MetaData, opts?: { refs?: RefSpelling }): string { + const raw = serializeJson(model, { indent: 2, refs: opts?.refs ?? "canonical" }); const parsed = JSON.parse(raw) as unknown; // FR-016 / ADR-0018: rewrite legacy @table → kind-matching alias on source.rdb @@ -188,7 +255,7 @@ export function canonicalSerialize(model: MetaData): string { * Used by the conformance harness's expected-effective fixtures. */ export function canonicalSerializeEffective(model: MetaData): string { - const nodeObj = serializeNode(model, true); + const nodeObj = serializeNode(model, true, "canonical", undefined); const raw = JSON.stringify(nodeObj, null, 2); const parsed = JSON.parse(raw) as unknown; diff --git a/server/typescript/packages/metadata/src/shared/meta-data.ts b/server/typescript/packages/metadata/src/shared/meta-data.ts index 7bc83a10c..b28c75939 100644 --- a/server/typescript/packages/metadata/src/shared/meta-data.ts +++ b/server/typescript/packages/metadata/src/shared/meta-data.ts @@ -34,7 +34,10 @@ export abstract class MetaData { // order and post-merge tree shape. Mirrors Java's BaseMetaDataParser, which // folds getDefaultPackageName() into the registered name at parse time. fileDefaultPackage?: string; - superRef?: string; // raw super reference string, pre-resolution + superRef?: string; // super reference string; canonical after the loader's resolveReferences pass + /** ADR-0059 R5 — the `extends` value exactly as authored, kept when the loader's + * `resolveReferences` pass rewrites `superRef` to its canonical spelling. */ + authoredSuperRef?: string; private _superData?: MetaData; // post-resolution pointer; set by setSuperResolved() after parsing isAbstract: boolean = false; @@ -384,6 +387,24 @@ export abstract class MetaData { return this.attrs().get(name); } + /** + * ADR-0059 — effective attr value for `name` as the author WROTE it: for a + * reference the loader rewrote to its canonical spelling, the authored string; + * for every other attr, the value. For human-facing text (descriptions) only — + * code that binds a reference reads `attr()`. + */ + authoredAttr(name: string): AttrValue | undefined { + assertNotStructuralKeyword(name, "authoredAttr"); + const seen = new Set(); + for (let n: MetaData | undefined = this; n !== undefined && !seen.has(n); n = n._superData) { + seen.add(n); + // ADR-0039: own — locating the layer that declares the attr, walking the super chain. + const node = n._attrNodes.get(name); + if (node !== undefined) return node.authoredValue ?? node.value; + } + return undefined; + } + /** True if `name` resolves to an effective attr (own or inherited). */ hasAttr(name: string): boolean { assertNotStructuralKeyword(name, "hasAttr"); diff --git a/server/typescript/packages/metadata/src/shared/meta-root.ts b/server/typescript/packages/metadata/src/shared/meta-root.ts index 4c2aae0c8..21e6ba4cd 100644 --- a/server/typescript/packages/metadata/src/shared/meta-root.ts +++ b/server/typescript/packages/metadata/src/shared/meta-root.ts @@ -8,6 +8,8 @@ import { TYPE_OBJECT, TYPE_FIELD } from "./base-types.js"; import type { MetaObject } from "../core/object/meta-object.js"; import type { MetaField } from "../core/field/meta-field.js"; import { DEFAULT_PHYSICAL_NAMING, type PhysicalNaming } from "../naming.js"; +import { PACKAGE_SEPARATOR } from "./structural.js"; +import { lookupCanonical } from "../naming-refs.js"; export class MetaRoot extends MetaData { /** @@ -34,9 +36,18 @@ export class MetaRoot extends MetaData { ); } - /** Find an object by name. */ + /** + * Find an object by name, or by a canonical reference (ADR-0059): a value with a + * package separator (`acme::Program`, `::Program`) binds the object whose resolution + * key matches exactly, so a reference the loader rewrote to canonical form still finds + * its target. A bare name matches a root-level child by name, as before. + */ findObject(name: string): MetaObject | undefined { return this.cached(`findObject:${name}`, () => { + if (name.includes(PACKAGE_SEPARATOR)) { + const node = lookupCanonical(this, name, { ownerType: TYPE_OBJECT }); + return node !== undefined && node.type === TYPE_OBJECT ? (node as MetaObject) : undefined; + } // ADR-0039: own — the metadata root has no super chain (it never `extends`). const child = this.ownChildByTypeAndName(TYPE_OBJECT, name); return child !== undefined ? (child as MetaObject) : undefined; diff --git a/server/typescript/packages/metadata/src/super-resolve.ts b/server/typescript/packages/metadata/src/super-resolve.ts index fead50d16..aa81b086f 100644 --- a/server/typescript/packages/metadata/src/super-resolve.ts +++ b/server/typescript/packages/metadata/src/super-resolve.ts @@ -1,26 +1,20 @@ -// super reference resolution — pure FQN match (FR-032 / ADR-0032) +// super reference resolution (ADR-0059) // -// FR-032 made canonical refs fully-qualified: the YAML desugar expands every -// authored relative/bare-current ref to its FQN (`expandRef`, naming-refs.ts), -// and the canonical-JSON guard rejects any surviving `::`/`..::` form. So the -// refs that reach this resolver are already FQN, and resolution is a pure tree -// match: -// Qualified "pkg::Name" → findInTree(fqn) -// Bare "Name" (rare in-tree) → current package only (`::Name`), -// then literal (root-level). NO root -// fallback for a packaged bare ref. -// The old `::`-strip (absolute), `..::`-reduce (relative) and -// bare-current-then-root-fallback branches were DELETED — that fallback was the -// silent-shadowing footgun ADR-0032 removes. +// An `extends` ref resolves through the same rules as every other reference +// (`ownerCandidateKeys`, naming-refs.ts): `::Rest` root-absolute, `..::Rest` +// parent-relative, `pkg::Name` exact, bare `Name` in the declaring node's own +// file package, then root-level — never another package. The package context is +// the declaring node's effective package (`package ?? fileDefaultPackage`). // // FR-024: a ref whose final `::`-segment is dotted (`pkg::Customer.id`) targets // a child nested in the owner object — resolved type-scoped to the referrer. // -// Super resolution is IMMEDIATE during parse (Java behavior). This module -// provides only the lookup helper; the parser calls it inline. +// Super resolution is deferred by the loader (`resolveDeferredSupers`); the parser +// calls `resolveSuperRef` inline only when deferral is off. import type { MetaData } from "./shared/meta-data.js"; import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "./shared/structural.js"; +import { ownerCandidateKeys, RefOverDropError } from "./naming-refs.js"; import { TYPE_IDENTITY } from "./shared/base-types.js"; import { IDENTITY_UNIQUE_KEY_SUBTYPES } from "./core/identity/identity-constants.js"; @@ -29,35 +23,16 @@ import { IDENTITY_UNIQUE_KEY_SUBTYPES } from "./core/identity/identity-constants // --------------------------------------------------------------------------- /** - * Recursively searches the tree for a node whose qualified name matches `fqn`. - * - * A node is matched by its own `fqn()` (covers the explicit-package and - * no-package-at-all cases) OR by its EFFECTIVE qualified key - * `::` (`MetaData.resolutionKey()`) — the - * file-default package captured at PARSE time. This mirrors Java, where - * `BaseMetaDataParser` folds the file-default package into the registered name - * at parse time (so an object `BaseEntity` declared under `package: acme` is - * registered as `acme::BaseEntity` and a fully-qualified `extends: - * acme::BaseEntity` resolves) — even though TS keeps object `fqn()` bare for - * the FR5d referrer-envelope cross-port contract. - * - * Because the resolution key is captured at parse time (not derived from the - * post-merge tree), this is independent of load order AND of whether the base - * node's file-default package differs from the referrer's — the cross-PACKAGE - * cross-file case. No package is threaded down the walk anymore. + * The top-level node (a direct child of `root`) whose resolution key is exactly + * `key` — `::`, or the bare name for a + * root-level node. The key is captured at parse time, so the match does not depend + * on load order or on post-merge tree shape. Exact only: a bare key never matches a + * packaged node (ADR-0059 R2). */ -function findInTree(root: MetaData, fqn: string): MetaData | undefined { - if (root.fqn() === fqn) return root; - if (root.package === undefined && root.name !== "" && root.resolutionKey() === fqn) { - return root; - } - // ADR-0039: own — super-resolution walk over the PHYSICAL declaration tree; - // resolving accessors would be circular while the super chain is being wired. - for (const child of root.ownChildren()) { - const found = findInTree(child, fqn); - if (found !== undefined) return found; - } - return undefined; +function findTopLevel(root: MetaData, key: string): MetaData | undefined { + // ADR-0039: own — super-resolution reads the PHYSICAL declaration layer; the root + // has no super, so its own children are its children. + return root.ownChildren().find((c) => c.name !== "" && c.resolutionKey() === key); } // --------------------------------------------------------------------------- @@ -170,6 +145,7 @@ function parseChildTargetingRef( * bare lookup; a dotted ref with no `referrerScope` is unresolvable. * * @param ref - The raw super reference (e.g., "Fruit", "::pkg::Name", "..::common::id", "Customer.id") + * @throws RefOverDropError when a `..::` ref drops more segments than the package has * @param contextPackage - The package of the model whose super is being resolved * @param root - The root MetaData of the accumulating tree to search within * @param referrerScope - FR-024: the extending node's type — required to resolve dotted refs @@ -211,24 +187,14 @@ export function resolveSuperRef( .find((c) => c.name === last && c.type === referrerScope.type); } // ------------------------------------------------------------------------- - // 1. FR-032 (ADR-0032): refs in the loaded tree are already fully-qualified. - // The YAML desugar expanded every relative/bare-current ref to FQN - // (`expandRef`), and the canonical-JSON guard rejects any surviving - // `::`/`..::` form (ERR_RELATIVE_REF_IN_CANONICAL). So resolution is a - // pure FQN match — the old `::`-strip (absolute), `..::`-reduce (relative) - // and bare-current-then-ROOT-FALLBACK branches are deleted (the footgun). - // - // A qualified ref (`pkg::Name`) matches its node directly. A bare ref - // (no `::`) is the rare in-tree case: a root-level/empty-package type, or - // a bare ref constructed during dotted-owner recursion — resolve it in the - // current package ONLY (try `::ref`), then bare (root-level) as the - // literal name. NO root fallback for a packaged bare ref. + // 1. A plain ref binds one top-level node by its form (ADR-0059 R2). An + // over-dropping `..::` throws RefOverDropError; the deferred pass reports it. // ------------------------------------------------------------------------- - if (!ref.includes(PACKAGE_SEPARATOR) && contextPackage !== "") { - const inPackage = findInTree(root, `${contextPackage}${PACKAGE_SEPARATOR}${ref}`); - if (inPackage !== undefined) return inPackage; + for (const key of ownerCandidateKeys(ref, contextPackage)) { + const found = findTopLevel(root, key); + if (found !== undefined) return found; } - return findInTree(root, ref); + return undefined; } // --------------------------------------------------------------------------- @@ -249,8 +215,10 @@ export interface DeferredSuperFailure { * target's type/subtype differs from the extending node's (loader emits * ERR_EXTENDS_TARGET_MISMATCH). Applies ONLY to dotted refs — top-level * extends behavior is unchanged. + * - "over-drop" — a `..::` ref drops more package segments than the declaring + * node's package has (ADR-0059 R2; loader emits ERR_BAD_ATTR_VALUE). */ - kind: "unresolved" | "target-mismatch"; + kind: "unresolved" | "target-mismatch" | "over-drop"; /** target-mismatch only: the resolved target's identity, for the message. */ target?: { type: string; subType: string }; /** target-mismatch only: the extending node's type/subtype, for the message. */ @@ -298,6 +266,20 @@ export function resolveDeferredSupers(root: MetaData): DeferredSuperFailure[] { if (n.superRef !== undefined) pending.push(n); }); + // ADR-0059 R2: an over-dropping `..::` is a bad value, not a missing target. + const tryResolve = ( + ref: string, + pkg: string, + scope?: ReferrerScope, + ): MetaData | undefined | "over-drop" => { + try { + return resolveSuperRef(ref, pkg, root, scope); + } catch (e) { + if (e instanceof RefOverDropError) return "over-drop"; + throw e; + } + }; + const resolveNode = (node: MetaData): void => { if (node.superRef === undefined || node.superResolved !== undefined) return; if (attempted.has(node)) return; // already resolved-or-failed this pass — never re-report @@ -311,15 +293,19 @@ export function resolveDeferredSupers(root: MetaData): DeferredSuperFailure[] { if (isChildTargetingRef(node.superRef)) { const parsed = parseChildTargetingRef(node.superRef); if (parsed !== undefined) { - const ownerNode = resolveSuperRef(parsed.ownerRef, effectivePkg, root); - if (ownerNode !== undefined) resolveNode(ownerNode); + const ownerNode = tryResolve(parsed.ownerRef, effectivePkg); + if (ownerNode !== undefined && ownerNode !== "over-drop") resolveNode(ownerNode); } } // FR-024: thread the referrer's type so dotted `Entity.child` refs resolve // type-scoped (a field ref selects fields; an identity ref identities). - const target = resolveSuperRef(node.superRef, effectivePkg, root, { type: node.type }); + const target = tryResolve(node.superRef, effectivePkg, { type: node.type }); + if (target === "over-drop") { + failures.push({ nodeFqn: node.fqn(), ref: node.superRef, source: node.source, kind: "over-drop" }); + return; + } if (target !== undefined) { // FR-024: a dotted ref must target a node of the SAME type and subtype // as the extending node. Dotted-only — top-level extends is unchanged. diff --git a/server/typescript/packages/metadata/test/conformance-twin-roundtrip.test.ts b/server/typescript/packages/metadata/test/conformance-twin-roundtrip.test.ts new file mode 100644 index 000000000..5bfc36e1e --- /dev/null +++ b/server/typescript/packages/metadata/test/conformance-twin-roundtrip.test.ts @@ -0,0 +1,139 @@ +// Two source-independence gates over the shared conformance corpus (ADR-0059). +// +// Gate 1 — YAML twin. Every fixture's input loads once as canonical JSON and once +// with the SAME texts read as YAML sources, in the same order. JSON is valid YAML, +// so the second load goes through the YAML desugar. Both loads must report the same +// ordered error codes and, when clean, serialize identically. A reference means the +// same thing whatever source it was written in, so any difference is a defect. +// +// Gate 2 — round trip. Every fixture's `expected.json` loads as the only input and +// must serialize back to itself byte for byte (trailing whitespace aside, as the corpus +// runner compares). Canonical JSON is an output format, +// so what the serializer writes has to load back to the same model. +// +// Exclusions live in shared files beside the corpus, one reason per entry, so every +// port skips the same fixtures for the same reasons. + +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { + discoverFixtures, + isContractOnlyFixture, + isDocsOnlyFixture, + type Fixture, +} from "@metaobjectsdev/conformance"; +import { composeRegistry, type MetaDataTypeProvider } from "../src/provider.js"; +import { MetaDataLoader } from "../src/loader/meta-data-loader.js"; +import { InMemoryStringSource, type MetaDataSource } from "../src/loader/meta-data-source.js"; +import { DirectorySource } from "../src/loader/sources/directory-source.js"; +import { canonicalSerialize, canonicalSerializeEffective } from "../src/serializer-json.js"; +import type { TypeRegistry } from "../src/registry.js"; +import { PROVIDERS } from "./conformance/adapter.js"; + +const CORPUS = + process.env.METAOBJECTS_CONFORMANCE_CORPUS ?? + join(import.meta.dir, "../../../../../fixtures/conformance"); + +interface Exclusions { + readonly exclusions: Readonly>; +} + +async function readExclusions(file: string): Promise>> { + const parsed = JSON.parse(await readFile(join(CORPUS, file), "utf8")) as Exclusions; + return parsed.exclusions; +} + +const twinExclusions = await readExclusions("yaml-twin-exclusions.json"); +const roundTripExclusions = await readExclusions("round-trip-exclusions.json"); +const fixtures = (await discoverFixtures(CORPUS)).filter( + (f) => !isDocsOnlyFixture(f) && !isContractOnlyFixture(f), +); +const fixtureNames = new Set(fixtures.map((f) => f.name)); + +/** The fixture's registry, or undefined when its providers do not compose (the + * provider-composition fixtures — their outcome is a composition error, not a tree). */ +function registryFor(fix: Fixture): TypeRegistry | undefined { + const providers: MetaDataTypeProvider[] = []; + for (const id of fix.providers) { + const p = PROVIDERS[id]; + if (p === undefined) return undefined; + providers.push(p); + } + try { + return composeRegistry(providers); + } catch { + return undefined; + } +} + +interface Loaded { + readonly codes: readonly string[]; + readonly canonical?: string; + readonly effective?: string; +} + +async function loadAll(sources: MetaDataSource[], registry: TypeRegistry): Promise { + const result = await new MetaDataLoader({ registry, strict: true }).load(sources); + const codes = result.errors.map((e) => { + const code = (e as { code?: unknown }).code; + return typeof code === "string" ? code : "ERR_UNKNOWN"; + }); + if (codes.length > 0) return { codes }; + return { + codes, + canonical: canonicalSerialize(result.root), + effective: canonicalSerializeEffective(result.root), + }; +} + +describe("exclusion files", () => { + test("every excluded fixture exists", () => { + for (const name of [...Object.keys(twinExclusions), ...Object.keys(roundTripExclusions)]) { + expect(fixtureNames.has(name)).toBe(true); + } + }); +}); + +describe("gate 1: YAML twin", () => { + for (const fix of fixtures) { + if (twinExclusions[fix.name] !== undefined) continue; + test(`twin: ${fix.name}`, async () => { + const registry = registryFor(fix); + if (registry === undefined) return; // provider-composition fixture: no tree to twin + const files = await new DirectorySource(fix.inputDir).expand(); + const texts = await Promise.all(files.map((f) => f.read())); + const asJson = files.map((f, i) => new InMemoryStringSource(texts[i]!, { id: f.id, format: f.format })); + // Only canonical-JSON inputs get a twin; a file already authored as YAML stays YAML. + const asYaml = files.map( + (f, i) => + new InMemoryStringSource(texts[i]!, { + id: f.id, + format: extname(f.id).toLowerCase() === ".json" ? "yaml" : f.format, + }), + ); + const json = await loadAll(asJson, registry); + const yaml = await loadAll(asYaml, registry); + expect(yaml.codes).toEqual(json.codes); + if (json.canonical !== undefined) { + expect(yaml.canonical).toBe(json.canonical); + if (fix.hasExpectedEffective) expect(yaml.effective).toBe(json.effective); + } + }); + } +}); + +describe("gate 2: round trip", () => { + for (const fix of fixtures) { + if (!fix.hasExpected || roundTripExclusions[fix.name] !== undefined) continue; + test(`round-trip: ${fix.name}`, async () => { + const registry = registryFor(fix); + if (registry === undefined) return; + const text = await readFile(join(fix.dir, "expected.json"), "utf8"); + const reloaded = await loadAll([new InMemoryStringSource(text, { id: "expected.json" })], registry); + expect(reloaded.codes).toEqual([]); + // Trimmed, as the corpus runner compares goldens: a golden's trailing blank line is not content. + expect(reloaded.canonical?.trim()).toBe(text.trim()); + }); + } +}); diff --git a/server/typescript/packages/metadata/test/conformance/adapter.ts b/server/typescript/packages/metadata/test/conformance/adapter.ts index 7c846e286..f5a65e5a1 100644 --- a/server/typescript/packages/metadata/test/conformance/adapter.ts +++ b/server/typescript/packages/metadata/test/conformance/adapter.ts @@ -114,7 +114,7 @@ const duplicateXCloneProvider: MetaDataTypeProvider = { * the actual provider objects to compose a registry. Test-only providers * (suffixed below) feed the provider-extension-* fixtures. */ -const PROVIDERS: Readonly> = { +export const PROVIDERS: Readonly> = { [coreTypesProvider.id]: coreTypesProvider, // "metaobjects-core-types" [dbProvider.id]: dbProvider, // "metaobjects-db" [docProvider.id]: docProvider, // "metaobjects-documentation" diff --git a/server/typescript/packages/metadata/test/inherited-root-reference.test.ts b/server/typescript/packages/metadata/test/inherited-root-reference.test.ts new file mode 100644 index 000000000..a21dc6704 --- /dev/null +++ b/server/typescript/packages/metadata/test/inherited-root-reference.test.ts @@ -0,0 +1,64 @@ +// ADR-0059 Decisions 6 and 7: a root-level declarer writes a root target bare, and a bare +// canonical value means the ROOT-level object. A consumer reading it through an inheritor +// in another package must still bind the root object, never the inheritor's same-named one. +// Model: `fixtures/codegen-conformance/cross-package-binding/` — root `BaseOrder` writes +// `Customer` and `Label` bare; `acme::Order` extends it while `acme::Customer` and +// `acme::Label` exist and load first. + +import { describe, expect, test } from "bun:test"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { MetaDataLoader } from "../src/loader/meta-data-loader.js"; +import { InMemoryStringSource } from "../src/loader/meta-data-source.js"; +import { lookupCanonical, refTarget } from "../src/naming-refs.js"; +import { deriveM2MFields } from "../src/core/relationship/derive-m2m-fields.js"; +import { IDENTITY_REFERENCE_ATTR_REFERENCES } from "../src/core/identity/identity-constants.js"; +import { RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH } from "../src/core/relationship/relationship-constants.js"; +import type { MetaObject } from "../src/core/object/meta-object.js"; +import type { MetaRoot } from "../src/shared/meta-root.js"; + +function repoRoot(start: string): string { + let dir = start; + while (!(existsSync(join(dir, "fixtures")) && existsSync(join(dir, "server")))) { + const parent = dirname(dir); + if (parent === dir) throw new Error("repo root not found"); + dir = parent; + } + return dir; +} + +async function load(): Promise { + const dir = join(repoRoot(import.meta.dir), "fixtures", "codegen-conformance", "cross-package-binding", "input"); + const sources = readdirSync(dir) + .sort() + .map((f) => new InMemoryStringSource(readFileSync(join(dir, f), "utf-8"), { id: f, format: "json" })); + const { root, errors } = await new MetaDataLoader().load(sources); + expect(errors).toEqual([]); + return root; +} + +const byKey = (root: MetaRoot, key: string): MetaObject => + root.objects().find((o) => o.resolutionKey() === key)!; + +describe("an inherited bare canonical reference binds the root object", () => { + test("refTarget and lookupCanonical bind the root Customer from acme::Order", async () => { + const root = await load(); + const order = byKey(root, "acme::Order"); + const rel = order.relationships().find((r) => r.name === "customer")!; + const ref = order.referenceIdentities().find((r) => r.name === "customerRef")!; + expect(rel.attr(RELATIONSHIP_ATTR_OBJECT_REF)).toBe("Customer"); + expect(refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF)).toBe(byKey(root, "Customer")); + expect(refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES)).toBe(byKey(root, "Customer")); + expect(lookupCanonical(root, "Customer")).toBe(byKey(root, "Customer")); + expect(lookupCanonical(root, "acme::Customer")).toBe(byKey(root, "acme::Customer")); + }); + + test("the M:N derivation from acme::Order uses the root junction and the root Label", async () => { + const root = await load(); + const order = byKey(root, "acme::Order"); + const rel = order.relationships().find((r) => r.name === "labels")!; + expect(refTarget(rel, RELATIONSHIP_ATTR_THROUGH)).toBe(byKey(root, "OrderLabel")); + expect(refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF)).toBe(byKey(root, "Label")); + expect(deriveM2MFields(rel, order, root)).toEqual({ sourceField: "orderId", targetField: "labelId" }); + }); +}); diff --git a/server/typescript/packages/metadata/test/meta/find-reference.test.ts b/server/typescript/packages/metadata/test/meta/find-reference.test.ts index 6398731b9..0bdbe9cbb 100644 --- a/server/typescript/packages/metadata/test/meta/find-reference.test.ts +++ b/server/typescript/packages/metadata/test/meta/find-reference.test.ts @@ -45,7 +45,7 @@ describe("findReferenceBetween", () => { expect(lookup!.holder.name).toBe("Purchase"); expect(lookup!.other.name).toBe("Program"); expect(lookup!.referenceIdentity.fields).toEqual(["programId"]); - expect(lookup!.referenceIdentity.targetEntity).toBe("Program"); + expect(lookup!.referenceIdentity.targetEntity).toBe("test::Program"); }); test("has-many: reference on child side (Program -> Week)", async () => { diff --git a/server/typescript/packages/metadata/test/meta/meta-misc.test.ts b/server/typescript/packages/metadata/test/meta/meta-misc.test.ts index f1e067861..da53fd73c 100644 --- a/server/typescript/packages/metadata/test/meta/meta-misc.test.ts +++ b/server/typescript/packages/metadata/test/meta/meta-misc.test.ts @@ -929,7 +929,7 @@ describe("registry dispatch — origin subtype classes", () => { expect(origin).toBeInstanceOf(MetaPassthroughOrigin); expect(origin).toBeInstanceOf(MetaOrigin); expect(origin).toBeInstanceOf(MetaData); - expect((origin as MetaPassthroughOrigin).from).toBe("Program.title"); + expect((origin as MetaPassthroughOrigin).from).toBe("demo::Program.title"); }); it("parsed origin.aggregate node is instanceof MetaAggregateOrigin and MetaOrigin", async () => { @@ -994,8 +994,8 @@ describe("registry dispatch — origin subtype classes", () => { expect(origin).toBeInstanceOf(MetaOrigin); expect(origin).toBeInstanceOf(MetaData); expect((origin as MetaAggregateOrigin).agg).toBe("count"); - expect((origin as MetaAggregateOrigin).of).toBe("Week.id"); - expect((origin as MetaAggregateOrigin).via).toBe("Program.weeks"); + expect((origin as MetaAggregateOrigin).of).toBe("demo::Week.id"); + expect((origin as MetaAggregateOrigin).via).toBe("demo::Program.weeks"); }); it("origin.passthrough is NOT instanceof MetaAggregateOrigin", () => { diff --git a/server/typescript/packages/metadata/test/meta/meta-reference-identity.test.ts b/server/typescript/packages/metadata/test/meta/meta-reference-identity.test.ts index 6c855e9f1..e3dba1756 100644 --- a/server/typescript/packages/metadata/test/meta/meta-reference-identity.test.ts +++ b/server/typescript/packages/metadata/test/meta/meta-reference-identity.test.ts @@ -42,7 +42,7 @@ describe("identity.reference subtype", () => { const ref = refs[0]!; expect(ref).toBeInstanceOf(MetaReferenceIdentity); expect(ref.fields).toEqual(["programId"]); - expect(ref.targetEntity).toBe("Program"); + expect(ref.targetEntity).toBe("test::Program"); expect(ref.targetFields).toEqual([]); }); @@ -74,7 +74,7 @@ describe("identity.reference subtype", () => { const purchase = root.objects().find((o) => o.name === "Purchase")!; const ref = purchase.referenceIdentities()[0]!; - expect(ref.targetEntity).toBe("Subscriber"); + expect(ref.targetEntity).toBe("test::Subscriber"); expect(ref.targetFields).toEqual(["email"]); }); @@ -107,7 +107,7 @@ describe("identity.reference subtype", () => { const thing = root.objects().find((o) => o.name === "Thing")!; const ref = thing.referenceIdentities()[0]!; expect(ref.fields).toEqual(["fa", "fb"]); - expect(ref.targetEntity).toBe("Other"); + expect(ref.targetEntity).toBe("test::Other"); expect(ref.targetFields).toEqual(["a", "b"]); }); diff --git a/server/typescript/packages/metadata/test/relationship-m2m.test.ts b/server/typescript/packages/metadata/test/relationship-m2m.test.ts index 58a06baae..cd0228e55 100644 --- a/server/typescript/packages/metadata/test/relationship-m2m.test.ts +++ b/server/typescript/packages/metadata/test/relationship-m2m.test.ts @@ -46,7 +46,7 @@ describe("FR-017 M:N slim vocabulary", () => { ] } }); expect(errors).toHaveLength(0); const out = canonicalSerialize(root); - expect(out).toContain('"@through": "PostTag"'); + expect(out).toContain('"@through": "acme::PostTag"'); }); test("@joinEntity / @joinFields are no longer schema-known attrs (no through getter)", async () => { diff --git a/server/typescript/packages/metadata/test/report-shape.test.ts b/server/typescript/packages/metadata/test/report-shape.test.ts index 2e2069fd2..c46c2e772 100644 --- a/server/typescript/packages/metadata/test/report-shape.test.ts +++ b/server/typescript/packages/metadata/test/report-shape.test.ts @@ -464,7 +464,7 @@ describe("reportShape Table C (FR-044 @spine and measure @default)", () => { const { root, errors } = await new MetaDataLoader().load([file("acme", [catalog, program, broken, spineSales])]); expect(errors.length).toBe(1); expect(() => reportShape(report(root, "SpineSales"), root)).toThrow( - "report 'SpineSales': dimension 'programSubtitle' @via 'Program.catalog' does not resolve.", + "report 'SpineSales': dimension 'programSubtitle' @via 'acme::Program.catalog' does not resolve.", ); }); diff --git a/server/typescript/packages/metadata/test/reporting-validation.test.ts b/server/typescript/packages/metadata/test/reporting-validation.test.ts index 124150fde..ca8a8da50 100644 --- a/server/typescript/packages/metadata/test/reporting-validation.test.ts +++ b/server/typescript/packages/metadata/test/reporting-validation.test.ts @@ -1620,14 +1620,14 @@ describe("report accessors", () => { const purchase = root.children().find((c) => c.name === "Purchase")!; const revenue = purchase.children().find((c) => c.name === "revenue")!; expect(revenue).toBeInstanceOf(MetaMeasure); - expect((revenue as MetaMeasure).ofColumns()).toEqual(["Purchase.amountCents"]); + expect((revenue as MetaMeasure).ofColumns()).toEqual(["acme::shop::Purchase.amountCents"]); const event = root.children().find((c) => c.name === "WorkoutEvent")!; const days = event.children().find((c) => c.name === "daysEngaged") as MetaMeasure; expect(days.ofColumns()).toEqual([ - "WorkoutEvent.programId", - "WorkoutEvent.customerEmail", - "WorkoutEvent.weekNumber", - "WorkoutEvent.dayNumber", + "acme::shop::WorkoutEvent.programId", + "acme::shop::WorkoutEvent.customerEmail", + "acme::shop::WorkoutEvent.weekNumber", + "acme::shop::WorkoutEvent.dayNumber", ]); }); @@ -1665,7 +1665,74 @@ describe("report accessors", () => { const { root, errors } = await loadInline(m); expect(errors).toEqual([]); const report = (name: string) => root.children().find((c) => c.name === name)!; - expect(reportSpine(report("ProgramPurchases"))).toBe("Purchase.program"); + expect(reportSpine(report("ProgramPurchases"))).toBe("acme::shop::Purchase.program"); expect(reportSpine(report("DailyRevenue"))).toBeUndefined(); }); }); + +// ADR-0059 R3 — a `@via` hop inherited from another package resolves its target in the +// package of the node that declares the hop's `@objectRef`, not the walked entity's. Here +// `base::Order.customer` means `base::Customer`; `app::BigOrder` inherits it while +// `app::Customer` exists and loads first. +describe("inherited @via hop across packages", () => { + const baseFile = { + "metadata.root": { + package: "base", + children: [ + { + "object.entity": { + name: "Customer", + children: [{ "source.rdb": { "@table": "base_customers" } }, field("long", "id"), field("string", "name"), primary()], + }, + }, + { + "object.entity": { + name: "Order", + children: [ + { "source.rdb": { "@table": "orders" } }, + field("long", "id"), + field("long", "customerId"), + primary(), + { "identity.reference": { name: "customerRef", "@references": "Customer", "@fields": ["customerId"] } }, + { "relationship.association": { name: "customer", "@objectRef": "Customer", "@cardinality": "one" } }, + ], + }, + }, + ], + }, + }; + const appFile = { + "metadata.root": { + package: "app", + children: [ + { + "object.entity": { + name: "Customer", + children: [{ "source.rdb": { "@table": "app_customers" } }, field("long", "id"), field("string", "nickname"), primary()], + }, + }, + { + "object.entity": { + name: "BigOrder", + extends: "base::Order", + children: [ + { "source.rdb": { "@table": "big_orders" } }, + { "dimension.attribute": { name: "customerName", "@of": "Customer.name", "@via": "BigOrder.customer" } }, + ], + }, + }, + ], + }, + }; + + test("the hop binds base::Customer, so @of resolves there and loads clean", async () => { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(appFile), { id: "meta.app.json" }), + new InMemoryStringSource(JSON.stringify(baseFile), { id: "meta.base.json" }), + ]); + expect(errors).toEqual([]); + const bigOrder = root.objects().find((o) => o.resolutionKey() === "app::BigOrder")!; + const dim = bigOrder.children().find((c) => c.name === "customerName")!; + expect(dim.attr("of")).toBe("base::Customer.name"); + }); +}); diff --git a/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts index d6f634e9d..2b4858fce 100644 --- a/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts +++ b/server/typescript/packages/metadata/test/resolve-relationship-reference.test.ts @@ -6,6 +6,7 @@ import { resolveRelationshipReference, } from "../src/core/relationship/resolve-relationship-reference.js"; import type { MetaObject } from "../src/core/object/meta-object.js"; +import type { MetaRoot } from "../src/shared/meta-root.js"; const MATCH_MODEL = { "metadata.root": { @@ -47,22 +48,28 @@ async function loadMatch(): Promise { return root.findObject("Match")!; } +/** The `Team` entity in `match`'s model: the ladder takes the bound target node, never a name. */ +function teamOf(match: MetaObject): MetaObject { + return (match.root() as MetaRoot).findObject("Team")!; +} + describe("resolveRelationshipReference", () => { test("enumerates every candidate reference for the target", async () => { - const candidates = referenceCandidatesFor(await loadMatch(), "Team"); + const match = await loadMatch(); + const candidates = referenceCandidatesFor(match, teamOf(match)); expect(candidates.map((r) => r.name)).toEqual(["homeTeamRef", "awayTeamRef"]); }); test("name pairing resolves each association to its own reference", async () => { const match = await loadMatch(); - expect(resolveRelationshipReference(match, "homeTeam", "Team")?.name).toBe("homeTeamRef"); - expect(resolveRelationshipReference(match, "awayTeam", "Team")?.name).toBe("awayTeamRef"); + expect(resolveRelationshipReference(match, "homeTeam", teamOf(match))?.name).toBe("homeTeamRef"); + expect(resolveRelationshipReference(match, "awayTeam", teamOf(match))?.name).toBe("awayTeamRef"); }); test("@sourceRefField wins over name pairing", async () => { const match = await loadMatch(); expect( - resolveRelationshipReference(match, "homeTeam", "Team", "awayTeamId")?.name, + resolveRelationshipReference(match, "homeTeam", teamOf(match), "awayTeamId")?.name, ).toBe("awayTeamRef"); }); @@ -92,7 +99,7 @@ describe("resolveRelationshipReference", () => { ]); expect(errors).toEqual([]); const match = root.findObject("Match")!; - expect(resolveRelationshipReference(match, "champion", "Team")?.name).toBe("anythingAtAll"); + expect(resolveRelationshipReference(match, "champion", teamOf(match))?.name).toBe("anythingAtAll"); }); test("unpairable names return undefined rather than guessing", async () => { @@ -127,7 +134,7 @@ describe("resolveRelationshipReference", () => { // value directly against the merged root rather than requiring a clean load. expect(errors.map((e) => (e as { code?: string }).code)).toEqual(["ERR_INVALID_RELATIONSHIP"]); const match = root.findObject("Match")!; - expect(resolveRelationshipReference(match, "winner", "Team")).toBeUndefined(); + expect(resolveRelationshipReference(match, "winner", teamOf(match))).toBeUndefined(); }); test("suffix stripping never applies to the relationship name", async () => { @@ -162,6 +169,6 @@ describe("resolveRelationshipReference", () => { // rather than guessing" above. expect(errors.map((e) => (e as { code?: string }).code)).toEqual(["ERR_INVALID_RELATIONSHIP"]); const match = root.findObject("Match")!; - expect(resolveRelationshipReference(match, "valid", "Team")).toBeUndefined(); + expect(resolveRelationshipReference(match, "valid", teamOf(match))).toBeUndefined(); }); }); diff --git a/server/typescript/packages/metadata/test/round-trip.test.ts b/server/typescript/packages/metadata/test/round-trip.test.ts index 678481767..a2c7fdae8 100644 --- a/server/typescript/packages/metadata/test/round-trip.test.ts +++ b/server/typescript/packages/metadata/test/round-trip.test.ts @@ -62,6 +62,19 @@ function fixturePath(name: string): string { // canonical merged form where this flag is always false. // --------------------------------------------------------------------------- +/** + * ADR-0059 R8: the serializer may move a package between the root and its top-level + * nodes (one root package, or one per node), so the root's own package and a + * top-level node's own package are not round-trip invariants. What a package decides + * is: each top-level node's effective package. Deeper nodes keep their own. + */ +function sameEffectivePackage(a: MetaData, b: MetaData, path: string): boolean { + if (path === "root") return true; + if (!/^root\.children\[\d+\]$/.test(path)) return a.package === b.package; + const eff = (n: MetaData): string => n.package ?? n.fileDefaultPackage ?? n.parent?.package ?? ""; + return eff(a) === eff(b); +} + function assertModelsEqual(a: MetaData, b: MetaData, path = "root"): void { // Type identity if (a.type !== b.type) throw new Error(`${path}: type mismatch — expected "${a.type}", got "${b.type}"`); @@ -69,7 +82,9 @@ function assertModelsEqual(a: MetaData, b: MetaData, path = "root"): void { // Name and package if (a.name !== b.name) throw new Error(`${path}: name mismatch — expected "${a.name}", got "${b.name}"`); - if (a.package !== b.package) throw new Error(`${path}: package mismatch — expected "${String(a.package)}", got "${String(b.package)}"`); + // ADR-0059 R8: the serializer may move a package between the root and its + // top-level nodes, so compare what a package decides — each node's resolution key. + if (!sameEffectivePackage(a, b, path)) throw new Error(`${path}: package mismatch — expected "${String(a.package)}", got "${String(b.package)}"`); // Super ref (raw string, not resolved pointer) if (a.superRef !== b.superRef) throw new Error(`${path}: superRef mismatch — expected "${String(a.superRef)}", got "${String(b.superRef)}"`); @@ -557,7 +572,8 @@ describe("Super resolution sanity: fruitbasket-metadata.json", () => { const { root } = await loadFixture("fruitbasket-metadata.json"); const apple = root.ownChildByTypeAndName(TYPE_OBJECT, "Apple")!; expect(apple).toBeDefined(); - expect(apple.superRef).toBe("Fruit"); + expect(apple.superRef).toBe("simple::fruitbasket::Fruit"); + expect(apple.authoredSuperRef).toBe("Fruit"); expect(apple.superResolved).toBeDefined(); expect(apple.superResolved!.name).toBe("Fruit"); }); @@ -566,7 +582,8 @@ describe("Super resolution sanity: fruitbasket-metadata.json", () => { const { root } = await loadFixture("fruitbasket-metadata.json"); const macintosh = root.ownChildByTypeAndName(TYPE_OBJECT, "Macintosh")!; expect(macintosh).toBeDefined(); - expect(macintosh.superRef).toBe("Apple"); + expect(macintosh.superRef).toBe("simple::fruitbasket::Apple"); + expect(macintosh.authoredSuperRef).toBe("Apple"); expect(macintosh.superResolved).toBeDefined(); expect(macintosh.superResolved!.name).toBe("Apple"); }); diff --git a/server/typescript/packages/metadata/test/serializer-json.test.ts b/server/typescript/packages/metadata/test/serializer-json.test.ts index 8c2769b75..8663d87bb 100644 --- a/server/typescript/packages/metadata/test/serializer-json.test.ts +++ b/server/typescript/packages/metadata/test/serializer-json.test.ts @@ -88,6 +88,19 @@ function makeModel(type: string, subType: string, name: string): MetaData { } } +/** + * ADR-0059 R8: the serializer may move a package between the root and its top-level + * nodes (one root package, or one per node), so the root's own package and a + * top-level node's own package are not round-trip invariants. What a package decides + * is: each top-level node's effective package. Deeper nodes keep their own. + */ +function sameEffectivePackage(a: MetaData, b: MetaData, path: string): boolean { + if (path === "root") return true; + if (!/^root\.children\[\d+\]$/.test(path)) return a.package === b.package; + const eff = (n: MetaData): string => n.package ?? n.fileDefaultPackage ?? n.parent?.package ?? ""; + return eff(a) === eff(b); +} + /** * Recursively assert two MetaData trees are structurally equivalent. * Compares: typeId, name, package, superRef, isAbstract, isArray, attrs, children (recursive). @@ -97,7 +110,7 @@ function assertModelsEqual(a: MetaData, b: MetaData, path = "root"): void { expect(a.type).toBe(b.type); expect(a.subType).toBe(b.subType); expect(a.name).toBe(b.name); - expect(a.package).toBe(b.package); + expect(sameEffectivePackage(a, b, path)).toBe(true); expect(a.superRef).toBe(b.superRef); expect(a.isAbstract).toBe(b.isAbstract); expect(a.isArray).toBe(b.isArray); diff --git a/server/typescript/packages/metadata/test/template-response-ref.test.ts b/server/typescript/packages/metadata/test/template-response-ref.test.ts index 47a687faa..60267d768 100644 --- a/server/typescript/packages/metadata/test/template-response-ref.test.ts +++ b/server/typescript/packages/metadata/test/template-response-ref.test.ts @@ -16,7 +16,7 @@ describe("template.prompt @responseRef", () => { const r = await MetaDataLoader.fromString(meta("ResVO"), "json"); expect(r.errors).toEqual([]); const prompt = r.root.ownChildren().find((c: { name: string }) => c.name === "P")!; - expect(prompt.ownAttr("responseRef")).toBe("ResVO"); + expect(prompt.ownAttr("responseRef")).toBe("t::ai::ResVO"); }); test("unresolved @responseRef is a loader error", async () => { const r = await MetaDataLoader.fromString(meta("NoSuchVO"), "json"); diff --git a/server/typescript/packages/migrate-ts/src/expected-schema.ts b/server/typescript/packages/migrate-ts/src/expected-schema.ts index 6542572ec..da72637b8 100644 --- a/server/typescript/packages/migrate-ts/src/expected-schema.ts +++ b/server/typescript/packages/migrate-ts/src/expected-schema.ts @@ -13,6 +13,9 @@ import { OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE, isWritableSource, + isMetaObject, + refTarget, + IDENTITY_REFERENCE_ATTR_REFERENCES, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE, @@ -229,15 +232,10 @@ export function buildExpectedSchemaWithProvenance( } entities.push({ entity: child as MetaObject, tableName }); } - // FK targets resolve by FULLY-QUALIFIED name, not bare name. The loader qualifies a - // reference's targetEntity to its canonical FQN (e.g. "acme::a::Foo"), and each entity's - // `resolutionKey()` is that same "::" form — so an FK target binds the - // EXACT entity in the EXACT package. Keying by bare name (the prior behavior) silently - // mis-resolved when two packages declared a same-named entity: the last one loaded won - // the map, so a cross-package FK could point at the wrong table. - const AMBIGUOUS = Symbol("ambiguous-bare-name"); - const byFqn = new Map(); - const byBare = new Map(); + // FK targets resolve to the entity the loader bound for `@references` (ADR-0059 R7), + // keyed by that entity's resolution key, so an FK binds the EXACT entity in the EXACT + // package and never a same-named entity elsewhere. + const byKey = new Map(); const tableOwners = [...entities, ...fkTargetOnly]; const baseTables = new Map(tableOwners.map((e) => [e.entity.resolutionKey(), e.tableName])); const subtypeTargets = tphMembers.flatMap(({ entity, base }) => { @@ -246,33 +244,9 @@ export function buildExpectedSchemaWithProvenance( return tableName === undefined ? [] : [{ entity, tableName }]; }); for (const e of [...tableOwners, ...subtypeTargets]) { - byFqn.set(e.entity.resolutionKey(), e.tableName); - const bare = e.entity.name; - byBare.set(bare, byBare.has(bare) ? AMBIGUOUS : e.tableName); + byKey.set(e.entity.resolutionKey(), e.tableName); } - // Resolve an FK target to its physical table, mirroring the metadata package's - // `resolutionKey()` contract so migrate binds the SAME entity the loader/codegen would. - // A reference's `targetEntity` is the raw `@references` value (usually BARE), so a bare - // ref is qualified with the REFERRER's package first (`::`), then the - // root-level (empty-package) object, then an UNAMBIGUOUS bare name. Keying by bare name - // alone (the prior behavior) let the last-loaded of two same-named entities win the map, - // so a cross-package FK could silently point at the wrong table. - const resolveTargetTable = (targetRef: string, referrerKey: string): string | undefined => { - const direct = byFqn.get(targetRef); - if (direct !== undefined) return direct; - if (!targetRef.includes("::")) { - const sep = referrerKey.lastIndexOf("::"); - if (sep >= 0) { - const pkgLocal = byFqn.get(`${referrerKey.slice(0, sep)}::${targetRef}`); - if (pkgLocal !== undefined) return pkgLocal; - } - const rootLevel = byFqn.get(`::${targetRef}`); - if (rootLevel !== undefined) return rootLevel; - } - const bare = targetRef.includes("::") ? targetRef.slice(targetRef.lastIndexOf("::") + 2) : targetRef; - const byBareHit = byBare.get(bare); - return byBareHit === AMBIGUOUS ? undefined : byBareHit; - }; + const resolveTargetTable = (target: MetaObject): string | undefined => byKey.get(target.resolutionKey()); // Provenance: qualified physical name → declaring object's FQN. Recorded as the // descriptors are built, never re-derived from a SQL name (lossy) and never by a @@ -540,7 +514,7 @@ function tphConcreteSubtypes(base: MetaObject, root: MetaData): MetaObject[] { function buildTable( entity: MetaObject, tableName: string, - resolveTargetTable: (targetRef: string, referrerKey: string) => string | undefined, + resolveTargetTable: (target: MetaObject) => string | undefined, root: MetaRoot, strategy: ColumnNamingStrategy, dialect: Dialect | undefined, @@ -577,7 +551,7 @@ function buildTable( ) { // Flattened storage: expand nested value-object fields as prefixed columns. // The parent field.object itself does NOT produce its own column. - columns.push(...flattenObjectField(field, root, strategy)); + columns.push(...flattenObjectField(field, strategy)); } else { columns.push(buildColumn(field, isPk, isPk ? pkGeneration : undefined, strategy)); } @@ -998,7 +972,7 @@ function crossFieldCheck( function buildForeignKeys( entity: MetaObject, tableName: string, - resolveTargetTable: (targetRef: string, referrerKey: string) => string | undefined, + resolveTargetTable: (target: MetaObject) => string | undefined, root: MetaRoot, strategy: ColumnNamingStrategy, onConflict?: (conflict: ReferentialActionConflict) => void, @@ -1025,9 +999,9 @@ function buildForeignKeys( for (const { holder, refChild } of refs) { // @enforce: false → logical-only reference; not a physical FK constraint. if (!refChild.enforce) continue; - const targetEntity = refChild.targetEntity; - if (targetEntity === undefined) continue; - const refTable = resolveTargetTable(targetEntity, holder.resolutionKey()); + const targetObj = refTarget(refChild, IDENTITY_REFERENCE_ATTR_REFERENCES); + if (targetObj === undefined || !isMetaObject(targetObj)) continue; + const refTable = resolveTargetTable(targetObj); if (!refTable) continue; const fkFieldJsNames = readIdentityFields(refChild); @@ -1045,18 +1019,12 @@ function buildForeignKeys( // PK field `id` with `@column: "Id"`), exactly like fkCols above — the raw // naming strategy alone would emit the logical name and phantom-diff every // FK into that table (expected ["id"] vs actual ["Id"]). - // targetEntity may be package-qualified (FQN); findObject is keyed by bare - // name — same fallback as resolvedTargetPkField/resolveTargetTable. - const targetObj = root.findObject(targetEntity) - ?? (targetEntity.includes("::") - ? root.findObject(targetEntity.slice(targetEntity.lastIndexOf("::") + 2)) - : undefined); const explicitTargetFields = refChild.targetFields; const targetFieldNames = explicitTargetFields.length > 1 ? explicitTargetFields : [refChild.resolvedTargetPkField(root) ?? "id"]; const refColumns = targetFieldNames.map((jsName) => { - const targetField = targetObj ? findField(targetObj, jsName) : undefined; + const targetField = findField(targetObj, jsName); return targetField ? resolveColumnName(targetField, strategy) : applyColumnNamingStrategy(jsName, strategy); @@ -1103,13 +1071,11 @@ function buildForeignKeys( * becomes `_` in the owning entity's table. */ function flattenObjectField( - field: MetaData, root: MetaRoot, strategy: ColumnNamingStrategy, + field: MetaData, strategy: ColumnNamingStrategy, ): ColumnDescriptor[] { // ADR-0039: resolving — @objectRef may be inherited via extends. - const ref = field.attr(FIELD_ATTR_OBJECT_REF); - if (typeof ref !== "string" || ref.length === 0) return []; - const targetObject = root.findObject(ref); - if (targetObject === undefined) return []; + const targetObject = refTarget(field, FIELD_ATTR_OBJECT_REF); + if (targetObject === undefined || !isMetaObject(targetObject)) return []; const prefix = resolveColumnName(field, strategy) + "_"; const cols: ColumnDescriptor[] = []; for (const nested of targetObject.fields()) { diff --git a/server/typescript/packages/migrate-ts/src/referential-actions.ts b/server/typescript/packages/migrate-ts/src/referential-actions.ts index 55d3c04d3..4fe6fd40f 100644 --- a/server/typescript/packages/migrate-ts/src/referential-actions.ts +++ b/server/typescript/packages/migrate-ts/src/referential-actions.ts @@ -5,8 +5,9 @@ import { IDENTITY_ATTR_FIELDS, TYPE_VALIDATOR, VALIDATOR_SUBTYPE_REQUIRED, - refMatchesObject, - resolveObjectRef, + IDENTITY_REFERENCE_ATTR_REFERENCES, + RELATIONSHIP_ATTR_OBJECT_REF, + refTarget, resolveRelationshipReference, type MetaObject, type MetaRelationship, @@ -53,8 +54,8 @@ export function isRequired(field: MetaData): boolean { * 1. @onDelete / @onUpdate declared DIRECTLY on the identity.reference — the * reference IS the FK, so the action may be declared right where the FK is. * 2. A correlated sibling relationship on the same entity — matched - * package-aware against the resolved @references target (refMatchesObject - * / ADR-0042, so bare and FQN forms pair correctly); an M:N relationship + * by bound target against the resolved @references target (refTarget + * / ADR-0059, so bare and FQN forms pair correctly); an M:N relationship * (@through) never correlates with a direct FK. Its explicit @onDelete, * else its subtype default (composition→cascade, aggregation→set-null, * association→restrict); onUpdate defaults to "cascade". @@ -102,13 +103,13 @@ export function resolveReferentialActions( const refOnDelete = ref.onDelete; const refOnUpdate = ref.onUpdate; - // Resolve the reference's target ONCE, package-aware (ADR-0042: a bare - // @references resolves in the DECLARING owner's package). Both relationship - // tiers then correlate against the resolved node with refMatchesObject, so a + // Resolve the reference's target ONCE, as bound (ADR-0059: a bare @references + // resolves in the DECLARING node's package). Both relationship tiers then + // correlate against the resolved node by identity, so a // bare @references pairs correctly with an FQN @objectRef (and vice versa) — // an exact-string tier 2 used to miss the FQN form, letting the parent-side // tier override a child-side declaration. - const targetObj = resolveReferenceTarget(entity, ref, target); + const targetObj = resolveReferenceTarget(ref); // (2) Otherwise correlate with a sibling relationship and use its action / // subtype default. onUpdate's "cascade" default only applies when a @@ -178,19 +179,11 @@ export function resolveReferentialActions( } /** - * The reference's target entity, resolved package-aware (ADR-0042: a bare @references - * resolves in the DECLARING owner's package). Undefined for a dangling @references. + * The reference's target entity: the object its @references is bound to (ADR-0059 R7), + * resolved in the package of the node that declared it. Undefined for a dangling @references. */ -function resolveReferenceTarget( - entity: MetaObject, - ref: MetaReferenceIdentity, - target: string, -): MetaObject | undefined { - const root = entity.parent; - if (root === undefined) return undefined; - const refOwner = ref.parent ?? entity; - const refOwnerPkg = refOwner.package ?? refOwner.fileDefaultPackage ?? ""; - return resolveObjectRef(root, target, refOwnerPkg).node as MetaObject | undefined; +function resolveReferenceTarget(ref: MetaReferenceIdentity): MetaObject | undefined { + return refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES) as MetaObject | undefined; } /** @@ -208,10 +201,8 @@ function findSiblingRelationship( const objectRef = r.objectRef; if (objectRef === undefined) return false; if (targetObj === undefined) return objectRef === target; - const relOwner = r.parent ?? entity; - const relOwnerPkg = relOwner.package ?? relOwner.fileDefaultPackage ?? ""; - if (!refMatchesObject(targetObj, objectRef, relOwnerPkg)) return false; - return resolveRelationshipReference(entity, r.name, objectRef, r.sourceRefField) === ref; + if (refTarget(r, RELATIONSHIP_ATTR_OBJECT_REF) !== targetObj) return false; + return resolveRelationshipReference(entity, r.name, targetObj, r.sourceRefField) === ref; }); } @@ -253,7 +244,7 @@ export function findReferentialActionConflict( ): ReferentialActionConflict | undefined { const target = ref.targetEntity; if (target === undefined) return undefined; - const targetObj = resolveReferenceTarget(entity, ref, target); + const targetObj = resolveReferenceTarget(ref); if (targetObj === undefined) return undefined; const governing = findSiblingRelationship(entity, ref, target, targetObj); if (governing === undefined) return undefined; @@ -331,18 +322,11 @@ function findReverseRelationship( ref: MetaReferenceIdentity, targetObj: MetaObject, ): MetaRelationship | undefined { - const root = entity.parent; - if (root === undefined) return undefined; - // Ambiguity guard: exactly one enforced reference from `entity` to this // target, and it must be `ref` itself. const refsToTarget = entity.referenceIdentities().filter((r) => { if (!r.enforce) return false; - const t = r.targetEntity; - if (t === undefined) return false; - const owner = r.parent ?? entity; - const ownerPkg = owner.package ?? owner.fileDefaultPackage ?? ""; - return resolveObjectRef(root, t, ownerPkg).node === targetObj; + return refTarget(r, IDENTITY_REFERENCE_ATTR_REFERENCES) === targetObj; }); if (refsToTarget.length !== 1 || refsToTarget[0] !== ref) return undefined; @@ -354,11 +338,7 @@ function findReverseRelationship( // none of them is preferred (see the class doc above). const reverseCandidates = targetObj.relationships().filter((r) => { if (r.through !== undefined) return false; // M:N — junction path, not this FK - const objectRef = r.objectRef; - if (objectRef === undefined) return false; - const relOwner = r.parent ?? targetObj; - const relOwnerPkg = relOwner.package ?? relOwner.fileDefaultPackage ?? ""; - return refMatchesObject(entity, objectRef, relOwnerPkg); + return refTarget(r, RELATIONSHIP_ATTR_OBJECT_REF) === entity; }); return reverseCandidates.length === 1 ? reverseCandidates[0] : undefined; } diff --git a/server/typescript/packages/migrate-ts/test/expected-schema-root-canonical-binding.test.ts b/server/typescript/packages/migrate-ts/test/expected-schema-root-canonical-binding.test.ts new file mode 100644 index 000000000..a6932384f --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/expected-schema-root-canonical-binding.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect, beforeAll } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import type { MetaData } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../src/expected-schema.js"; + +// ADR-0059 Decision 6: a bare canonical value names the ROOT-level object. A root-level +// Customer's `@objectRef: "Address"` and `@references: "Owner"` therefore bind the root +// Address and Owner even when `acme::Address` / `acme::Owner` exist and load first. A +// short-name lookup (MetaRoot.findObject) takes the first-loaded `acme::` object instead, +// which put acme::Address's columns into the DDL and named the FK's target column from +// acme::Owner. +describe("buildExpectedSchema — a bare canonical reference binds the root-level object", () => { + let root: MetaData; + beforeAll(async () => { + const acme = JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.value": { name: "Address", children: [{ "field.string": { name: "acmeLine" } }] } }, + { "object.entity": { name: "Owner", children: [ + { "source.rdb": { "@table": "acme_owners" } }, + { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ] } }, + ] } }); + const rootDoc = JSON.stringify({ "metadata.root": { children: [ + { "object.value": { name: "Address", children: [{ "field.string": { name: "street" } }] } }, + { "object.entity": { name: "Owner", children: [ + { "source.rdb": { "@table": "owners" } }, + { "field.long": { name: "ownerId", "@column": "owner_pk" } }, + { "identity.primary": { name: "pk", "@fields": "ownerId" } }, + ] } }, + { "object.entity": { name: "Customer", children: [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "ownerRef" } }, + { "field.object": { name: "home", "@objectRef": "Address", "@storage": "flattened" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.reference": { name: "owner", "@fields": "ownerRef", "@references": "Owner" } }, + ] } }, + ] } }); + const result = await new MetaDataLoader().load([new InMemoryStringSource(acme), new InMemoryStringSource(rootDoc)]); + expect(result.errors).toEqual([]); + root = result.root; + }); + + test("flattened storage expands the root Address", () => { + const customers = buildExpectedSchema(root, { dialect: "postgres" }).tables.find((t) => t.name === "customers"); + const cols = customers?.columns.map((c) => c.name) ?? []; + expect(cols).toContain("home_street"); + expect(cols).not.toContain("home_acme_line"); + }); + + test("the FK targets the root Owner's table and its physical PK column", () => { + const customers = buildExpectedSchema(root, { dialect: "postgres" }).tables.find((t) => t.name === "customers"); + expect(customers?.foreignKeys).toHaveLength(1); + expect(customers?.foreignKeys[0]).toMatchObject({ columns: ["owner_ref"], refTable: "owners", refColumns: ["owner_pk"] }); + }); +}); diff --git a/server/typescript/packages/runtime-ts/src/extract-object.ts b/server/typescript/packages/runtime-ts/src/extract-object.ts index 3e01ffa09..36da75dbe 100644 --- a/server/typescript/packages/runtime-ts/src/extract-object.ts +++ b/server/typescript/packages/runtime-ts/src/extract-object.ts @@ -30,7 +30,7 @@ import { isMetaRoot, type MetaData, TYPE_OBJECT, - resolveObjectRef, + refTarget, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_STRING, @@ -378,10 +378,9 @@ function attrString(node: MetaData, attr: string): string | null { } /** - * Resolve a field's `@objectRef` to its MetaObject via the canonical ADR-0042 package-local - * resolver (`resolveObjectRef` from `@metaobjectsdev/metadata`): an FQN ref (contains `::`) - * matches EXACTLY on `resolutionKey()`; a bare ref resolves in the field's OWN declaring - * package (`field.parent?.package ?? field.parent?.fileDefaultPackage`), else root-level. + * Resolve a field's `@objectRef` to its MetaObject: the target the loader bound + * (`refTarget` from `@metaobjectsdev/metadata`, ADR-0059 R7), resolved in the package of + * the node that declared the attribute, never by short name. * * #228: this previously called `MetaRoot.findObject(name)` directly — a bare-`name`-only * lookup — and, for an FQN ref (which never matches any object's bare `.name`), fell back to @@ -404,8 +403,7 @@ function resolveFieldObjectRef(field: MetaField): MetaObject | undefined { // class check fails for a real root, so every field.object @objectRef falls to // the opaque-leaf guard and extraction silently stops descending. if (!isMetaRoot(root)) return undefined; - const referrerPkg = field.parent?.package ?? field.parent?.fileDefaultPackage ?? ""; - const { node } = resolveObjectRef(root, ref, referrerPkg); + const node = refTarget(field, FIELD_ATTR_OBJECT_REF); return node !== undefined && node.type === TYPE_OBJECT ? (node as MetaObject) : undefined; } diff --git a/server/typescript/packages/runtime-ts/src/n2m-resolver.ts b/server/typescript/packages/runtime-ts/src/n2m-resolver.ts index 668576f92..25d1cf412 100644 --- a/server/typescript/packages/runtime-ts/src/n2m-resolver.ts +++ b/server/typescript/packages/runtime-ts/src/n2m-resolver.ts @@ -22,6 +22,7 @@ import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot } from "@meta import { TYPE_OBJECT, TYPE_FIELD, TYPE_RELATIONSHIP, RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH, + refTarget, CARDINALITY_MANY, DEFAULT_COLUMN_NAMING_STRATEGY, resolveColumnName, @@ -68,8 +69,9 @@ export function resolveN2mDescriptor( if (child.attr(RELATIONSHIP_ATTR_THROUGH) === undefined) continue; // 1:N many — not M:N. const rel = child as MetaRelationship; - const targetEntityName = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined; - const joinEntityName = rel.attr(RELATIONSHIP_ATTR_THROUGH) as string | undefined; + // ADR-0059 R7: the loader bound @objectRef/@through once; read those targets. + const targetEntityName = refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF)?.name; + const joinEntityName = refTarget(rel, RELATIONSHIP_ATTR_THROUGH)?.name; if (!targetEntityName || !joinEntityName) { throw new MetadataError( `M:N relationship '${relationName}' on '${sourceEntity.name}' requires @objectRef + @through`, diff --git a/server/typescript/packages/runtime-ts/src/relation-resolver.ts b/server/typescript/packages/runtime-ts/src/relation-resolver.ts index 8c5a7f5c4..2953bb883 100644 --- a/server/typescript/packages/runtime-ts/src/relation-resolver.ts +++ b/server/typescript/packages/runtime-ts/src/relation-resolver.ts @@ -6,6 +6,7 @@ import { CARDINALITY_ONE, CARDINALITY_MANY, DEFAULT_COLUMN_NAMING_STRATEGY, resolveRelationshipReference, + refTarget, } from "@metaobjectsdev/metadata"; import { MetadataError } from "./errors.js"; import { @@ -31,7 +32,7 @@ export interface RelationDescriptor { */ function findReferenceFkField( holder: MetaData, - targetName: string, + targetName: MetaData, relationshipName: string, sourceRefField?: string, ): string | undefined { @@ -68,26 +69,26 @@ export function resolveRelationDescriptor( { entity: sourceEntity.name }, ); } - // ADR-0039: resolving — @sourceRefField may be inherited via extends. - const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; - const fkField = findReferenceFkField(sourceEntity, targetEntityName, child.name, declaredRefField); - if (!fkField) { + // ADR-0059 R7: the target the loader bound, never a short-name match. + const target = refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF); + if (!target || target.type !== TYPE_OBJECT) { throw new MetadataError( - `Relationship '${relationName}' on '${sourceEntity.name}' has no identity.reference targeting '${targetEntityName}'`, + `Target entity '${targetEntityName}' not found for relation '${relationName}' on '${sourceEntity.name}'`, { entity: sourceEntity.name }, ); } - // ADR-0039: effective children — resolve rather than rely on root being unextended. - const target = root.children().find((c) => c.type === TYPE_OBJECT && c.name === targetEntityName); - if (!target) { + // ADR-0039: resolving — @sourceRefField may be inherited via extends. + const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; + const fkField = findReferenceFkField(sourceEntity, target, child.name, declaredRefField); + if (!fkField) { throw new MetadataError( - `Target entity '${targetEntityName}' not found for relation '${relationName}' on '${sourceEntity.name}'`, + `Relationship '${relationName}' on '${sourceEntity.name}' has no identity.reference targeting '${targetEntityName}'`, { entity: sourceEntity.name }, ); } return { cardinality: "one", - targetEntityName, + targetEntityName: target.name, sourceField: fkField, targetField: resolvePkFields(target)[0]!, }; @@ -103,15 +104,15 @@ export function resolveRelationDescriptor( // ADR-0039: effective attrs — @cardinality/@objectRef may be inherited. const card = child.attr(RELATIONSHIP_ATTR_CARDINALITY); if (card !== CARDINALITY_ONE) continue; - const targetEntityName = child.attr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined; - if (targetEntityName !== sourceEntity.name) continue; + // ADR-0059 R7: compare the bound target, not a name. + if (refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF) !== sourceEntity) continue; const inverseName = inversePluralName(other.name); if (inverseName !== relationName) continue; // ADR-0039: resolving — @sourceRefField may be inherited via extends. This // FK lives on `other` and belongs to `other`'s own relationship `child` — // not to anything declared on sourceEntity (see #368 module comment). const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined; - const fkField = findReferenceFkField(other, sourceEntity.name, child.name, declaredRefField); + const fkField = findReferenceFkField(other, sourceEntity, child.name, declaredRefField); if (!fkField) { throw new MetadataError( `Inverse relationship for '${relationName}' on '${sourceEntity.name}': entity '${other.name}' has no identity.reference targeting '${sourceEntity.name}'`, diff --git a/server/typescript/packages/runtime-ts/src/validator-runner.ts b/server/typescript/packages/runtime-ts/src/validator-runner.ts index fc929de3f..71037de4a 100644 --- a/server/typescript/packages/runtime-ts/src/validator-runner.ts +++ b/server/typescript/packages/runtime-ts/src/validator-runner.ts @@ -1,7 +1,7 @@ // Pure function: NEVER throws. ObjectManager wraps a non-ok result in a ValidationError on writes; // om.validate() returns the result directly. -import { isMetaObject, isMetaRoot, resolveObjectRef, type MetaData } from "@metaobjectsdev/metadata"; +import { isMetaObject, isMetaRoot, refTarget, type MetaData } from "@metaobjectsdev/metadata"; import { TYPE_FIELD, TYPE_VALIDATOR, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, @@ -190,13 +190,9 @@ function resolveVoRef(field: MetaData): MetaData | undefined { // class check fails for a real root and every VO reference silently stops // resolving, skipping nested value-object validation with no error. if (!isMetaRoot(root)) return undefined; - // ADR-0042 — the SINGLE object-ref resolver every ref site shares: an FQN matches - // its resolution key exactly; a bare ref resolves in the DECLARING owner's package - // (an inherited field resolves in the package that declared it), then a root-level - // object — never a same-named object in some other package. - const owner = field.parent ?? root; - const referrerPkg = owner.package ?? owner.fileDefaultPackage ?? ""; - const target = resolveObjectRef(root, ref, referrerPkg).node; + // ADR-0059 R7 — the object the loader bound `@objectRef` to, resolved in the package + // of the node that declared it — never a same-named object in some other package. + const target = refTarget(field, FIELD_ATTR_OBJECT_REF); return target?.subType === OBJECT_SUBTYPE_VALUE ? target : undefined; } diff --git a/server/typescript/packages/sdk/test/agent-context/codegen-skill-overwrite-claim.test.ts b/server/typescript/packages/sdk/test/agent-context/codegen-skill-overwrite-claim.test.ts index 2bd5b0ace..05c78b413 100644 --- a/server/typescript/packages/sdk/test/agent-context/codegen-skill-overwrite-claim.test.ts +++ b/server/typescript/packages/sdk/test/agent-context/codegen-skill-overwrite-claim.test.ts @@ -9,7 +9,10 @@ // 2026-08-17, and it was never true for C#/Python. It remains true for Java/Kotlin // only. Stating it unqualified told an adopter that deleting the header takes // ownership of a TypeScript/C#/Python file — backwards, since editing the content -// (which breaks the hash) is what takes ownership there. +// (which breaks the hash) is what takes ownership there. From Maven 8.1.0 (ADR-0060) +// Java and Kotlin use the same manifest, so the skill now states one rule for every +// port and names the JVM's one difference: a first-run adoption of files carrying the +// `GENERATED` token. // 2. The skill's only cross-reference to `docs/features/codegen-concepts.md` pointed at // §3/§10 (authoring mechanisms), never at §5-§7, which is where the write-once / // base-plus-extension question is actually answered. @@ -22,18 +25,13 @@ function readSkill(name: string): string { return readFileSync(join(resolveAgentContextRoot(), "skills", name, "SKILL.md"), "utf8"); } -test("the skill states the overwrite policy PER PORT, not as one universal rule", () => { +test("the skill does not state the retired header-ownership overwrite claim", () => { const skill = readSkill("metaobjects-codegen"); - // The retired claim. It was true for TS until 2026-08-17 and is still true for the - // JVM, which is exactly why stating it unqualified is worse than stating nothing. + // The retired claim. Stating it unqualified told an adopter that deleting the header + // takes ownership of a file, which is backwards on every port now. expect(skill).not.toMatch(/Refuses to overwrite any file that does NOT carry the `@generated` header/); - // The hash manifest is what actually decides in three of five ports; an adopter who - // does not know that will delete the header and believe the file is theirs. - expect(skill).toMatch(/hash manifest/i); - // The per-port split has to name both halves, or a reader still walks away thinking - // one rule covers every port. - expect(skill).toMatch(/TypeScript,\s*C#,\s*Python/); - expect(skill).toMatch(/Java,\s*Kotlin/); + // The 8.0.x JVM rule (the marker decides, removing it takes ownership) is retired too. + expect(skill).not.toMatch(/Remove that token and\s+regeneration never touches the file again/); }); test("the skill points at the sections that answer the write-once question", () => { diff --git a/server/typescript/packages/test-generators/src/generators/routes.ts b/server/typescript/packages/test-generators/src/generators/routes.ts index 1c0960d4f..c6f8a3d89 100644 --- a/server/typescript/packages/test-generators/src/generators/routes.ts +++ b/server/typescript/packages/test-generators/src/generators/routes.ts @@ -85,6 +85,7 @@ import { GENERATED_EDIT_NOTE, sidecarLine, DEFAULT_ID_FIELD, + pkInfoOf, isTphSubtype, tphDiscriminatorPin, tphStorageObject, @@ -338,9 +339,10 @@ ${m2mMountsFlat}} function m2mEntriesOf( ctx: RenderContext, name: string, -): Array { +): Array { return (ctx.relationMap.get(name) ?? []).filter( - (e): e is RelationEntry & { junctionEntity: string } => e.junctionEntity !== undefined, + (e): e is RelationEntry & { junctionEntity: string; junction: MetaObject } => + e.junctionEntity !== undefined && e.junction !== undefined, ); } @@ -353,7 +355,7 @@ function m2mEntriesOf( * has no M:N relationships — CRUD-only output stays byte-identical to before. */ function renderM2mMounts( - entries: ReadonlyArray, + entries: ReadonlyArray, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -397,7 +399,7 @@ interface TphM2mSource { * column). mountM2mRoute appends `/:id/` to the source $path. */ function renderM2mMount( - entry: RelationEntry & { junctionEntity: string }, + entry: RelationEntry & { junctionEntity: string; junction: MetaObject }, source: MetaObject, ctx: RenderContext, fastifyVar: string, @@ -412,7 +414,7 @@ function renderM2mMount( `${ctx.collectionName(entry.junctionEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, )}`, @@ -421,26 +423,25 @@ function renderM2mMount( // subtype has no table const, and the junction FK can only point at the base table — // and filters the rows to the subtype, because a Broker id in that FK column is not a // Carrier. Every other target binds to itself, and emits no filter. - const declaredTarget = ctx.loadedRoot.findObject(entry.targetEntity); - const target = declaredTarget === undefined ? undefined : tphStorageObject(declaredTarget); - const targetTableEntity = target?.name ?? entry.targetEntity; - const pin = declaredTarget === undefined ? undefined : tphDiscriminatorPin(declaredTarget); + const target = tphStorageObject(entry.target); + const targetTableEntity = target.name; + const pin = tphDiscriminatorPin(entry.target); // A self-join's target table IS the source table, which this file already imports from // the entity module; a second import of the same binding is TS2300, and a SyntaxError // when Node loads the module. - const targetVarSym = targetTableEntity === source.name + const targetVarSym = target === source ? ctx.collectionName(source.name) : imp( `${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, sourcePkg, - ctx.packageOf.get(targetTableEntity), + effectivePackage(target), targetTableEntity, ctx.extStyle, )}`, ); const mountM2mRouteSym = imp(`mountM2mRoute@${httpRuntimeSpecifier("drizzle-fastify", ctx, sourcePkg)}`); - const junction = ctx.loadedRoot.findObject(entry.junctionEntity); + const junction = entry.junction; // fromPackage = source.package: this routes file is SOURCE's own module, never the // junction's or the target's — see resolveJunctionColumn's doc comment (B1). // The relation-resolver pre-pass derives both join fields for every M:N entry. @@ -448,16 +449,10 @@ function renderM2mMount( if (sourceJoinField === undefined || targetJoinField === undefined) { throw new Error(`routes-file: M:N "${entry.name}" on ${source.name} has no derived join fields`); } - const sourceColumn: Code = junction - ? resolveJunctionColumn(junction, sourceJoinField, ctx, sourcePkg) - : code`${JSON.stringify(sourceJoinField)}`; - const targetColumn: Code = junction - ? resolveJunctionColumn(junction, targetJoinField, ctx, sourcePkg) - : code`${JSON.stringify(targetJoinField)}`; - const targetPkColumn: Code = target - ? resolveJunctionColumn(target, ctx.pkMap.get(targetTableEntity)?.fieldName ?? "id", ctx, sourcePkg) - : code`${JSON.stringify("id")}`; - const discriminatorLine: Code | string = pin !== undefined && target !== undefined + const sourceColumn: Code = resolveJunctionColumn(junction, sourceJoinField, ctx, sourcePkg); + const targetColumn: Code = resolveJunctionColumn(junction, targetJoinField, ctx, sourcePkg); + const targetPkColumn: Code = resolveJunctionColumn(target, pkInfoOf(target)?.fieldName ?? "id", ctx, sourcePkg); + const discriminatorLine: Code | string = pin !== undefined ? code` targetDiscriminator: { column: ${resolveJunctionColumn(target, pin.fieldName, ctx, sourcePkg)}, value: ${JSON.stringify(pin.value)} },` : ""; @@ -595,7 +590,7 @@ function renderTphRoutes( // compile gate stayed green while the endpoint 404'd. // // The physical columns stage 0 needs, resolved once against the base's own table. - const basePkField = ctx.pkMap.get(baseName)?.fieldName ?? "id"; + const basePkField = pkInfoOf(base)?.fieldName ?? "id"; const basePkColumn = resolveJunctionColumn(base, basePkField, ctx, basePkg); const baseDiscColumn = resolveJunctionColumn(base, discField, ctx, basePkg); const baseM2mMounts = renderM2mMounts(m2mEntriesOf(ctx, baseName), base, ctx, fastifyRef); diff --git a/server/typescript/packages/test-generators/src/templates/drizzle-schema.ts b/server/typescript/packages/test-generators/src/templates/drizzle-schema.ts index 4c9418ad9..93e85d56a 100644 --- a/server/typescript/packages/test-generators/src/templates/drizzle-schema.ts +++ b/server/typescript/packages/test-generators/src/templates/drizzle-schema.ts @@ -3,7 +3,7 @@ // plus the relations() block auto-emitted at the end. import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts/engine"; -import { MetaObject, MetaField, MetaIndex, isMetaObject, stripPackage } from "@metaobjectsdev/metadata"; +import { MetaObject, MetaField, MetaIndex, isMetaObject } from "@metaobjectsdev/metadata"; import { IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_EXPR, IDENTITY_ATTR_USING, IDENTITY_ATTR_ORDERS, IDENTITY_ATTR_WHERE, @@ -12,8 +12,10 @@ import { FIELD_ATTR_OBJECT_REF, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_DATE, + IDENTITY_REFERENCE_ATTR_REFERENCES, + refTarget, } from "@metaobjectsdev/metadata"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { mapColumnType, PG_IDENTITY_CAPABLE_FNS, @@ -411,6 +413,7 @@ ${joinCode(columnLines, { on: ",\n", trim: false })} interface FkInfo { targetVarName: string; // e.g., "users" targetEntityName: string; // e.g., "User" — used for the import path + targetPackage: string | undefined; // the target's effective package, for the import path targetPkField: string; // e.g., "id" } @@ -439,20 +442,16 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map { const refRaw = field.attr(FIELD_ATTR_OBJECT_REF); const emitted = typeof refRaw === "string" - ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage)) + ? ctx.resolveValueObjectName(field) : name; return imp(`t:${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`); }; diff --git a/server/typescript/packages/test-generators/src/templates/entity-file.ts b/server/typescript/packages/test-generators/src/templates/entity-file.ts index 91357b080..b448ae1a5 100644 --- a/server/typescript/packages/test-generators/src/templates/entity-file.ts +++ b/server/typescript/packages/test-generators/src/templates/entity-file.ts @@ -8,8 +8,8 @@ import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts/engine"; import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; -import { FIELD_ATTR_OBJECT_REF, SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import { SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { renderDrizzleSchema } from "./drizzle-schema.js"; import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js"; import { renderZodValidators, primaryIdentityFieldNames } from "./zod-validators.js"; @@ -153,8 +153,7 @@ export function renderEntityFile( // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`) // under a cross-package short-name collision. const voRef = (field: MetaField): { name: string; module: string } => { - const ref = field.attr(FIELD_ATTR_OBJECT_REF); - const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entityPkg)); + const name = ctx.resolveValueObjectName(field); const module = valueObjectModuleSpecifier(name, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle); return { name, module }; }; diff --git a/server/typescript/packages/test-generators/src/templates/inferred-types.ts b/server/typescript/packages/test-generators/src/templates/inferred-types.ts index 93cd39981..588ec5fde 100644 --- a/server/typescript/packages/test-generators/src/templates/inferred-types.ts +++ b/server/typescript/packages/test-generators/src/templates/inferred-types.ts @@ -42,7 +42,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { isTphReadNullTolerant } from "./zod-validators.js"; import { sharedEnumForField } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "@metaobjectsdev/codegen-ts/engine"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine"; /** @@ -311,7 +311,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render // Drizzle .$type<> use) so all three agree. Without a ctx (bare unit-test // calls) fall back to the bare name + flat same-dir specifier. const entityPkg = effectivePackage(entity); - const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -330,7 +330,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const entityPkg = effectivePackage(entity); - const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entityPkg)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entityPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/server/typescript/packages/test-generators/src/templates/projection-decl.ts b/server/typescript/packages/test-generators/src/templates/projection-decl.ts index 5d81ec24d..e9ab1e45d 100644 --- a/server/typescript/packages/test-generators/src/templates/projection-decl.ts +++ b/server/typescript/packages/test-generators/src/templates/projection-decl.ts @@ -18,7 +18,7 @@ import { import { projectionViewName } from "@metaobjectsdev/codegen-ts/engine"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "@metaobjectsdev/codegen-ts/engine"; import type { ColumnNamingStrategy } from "@metaobjectsdev/codegen-ts/engine"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import type { Dialect } from "@metaobjectsdev/codegen-ts/engine"; import { physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; @@ -113,7 +113,7 @@ export function renderProjectionDecl( const ref = field.attr(FIELD_ATTR_OBJECT_REF); const rawRef = typeof ref === "string" ? ref : ""; const name = ctx - ? ctx.resolveValueObjectName(rawRef, fieldDeclaringPackage(field, projectionPkg)) + ? ctx.resolveValueObjectName(field) : stripPackage(rawRef); const module = ctx ? valueObjectModuleSpecifier(name, ctx.packageOf, projectionPkg, ctx.outputLayout, ctx.extStyle) diff --git a/server/typescript/packages/test-generators/src/templates/queries.ts b/server/typescript/packages/test-generators/src/templates/queries.ts index 485daf65e..95f427fd6 100644 --- a/server/typescript/packages/test-generators/src/templates/queries.ts +++ b/server/typescript/packages/test-generators/src/templates/queries.ts @@ -4,10 +4,13 @@ import { code, imp, type Code } from "@metaobjectsdev/codegen-ts/engine"; import { type MetaObject, + IDENTITY_REFERENCE_ATTR_REFERENCES, + isMetaObject, + refTarget, stripPackage, } from "@metaobjectsdev/metadata"; import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; -import { getPkInfo, subTypeToTsType } from "@metaobjectsdev/codegen-ts/engine"; +import { getPkInfo, pkInfoOf, subTypeToTsType } from "@metaobjectsdev/codegen-ts/engine"; import { supportsReturning } from "@metaobjectsdev/codegen-ts/engine"; import { findByIdFnName, @@ -218,8 +221,10 @@ export async function ${fnName}(db: Db, ${pkField}: ${pkType}): Promise interface ReverseFk { /** FK FIELD name on this entity (logical), e.g. `currentSceneId`. */ fkField: string; - /** Target entity (the `T` referenced), e.g. `Scene`. Drives the value TS type. */ + /** Target entity name (the `T` referenced), e.g. `Scene`. An identifier, never a lookup key. */ targetEntity: string; + /** The entity `@references` binds to (ADR-0059 R7). Its PK type drives the value TS type. */ + target?: MetaObject | undefined; } /** Collect this entity's OWN reverse FK targets, in declaration order. */ @@ -227,9 +232,11 @@ export function reverseFksFor(entity: MetaObject): ReverseFk[] { const out: ReverseFk[] = []; for (const ref of entity.referenceIdentities()) { const fkField = ref.fields[0]; - const target = ref.targetEntity; - if (!fkField || !target) continue; - out.push({ fkField, targetEntity: stripPackage(target) }); + const targetRef = ref.targetEntity; + if (!fkField || !targetRef) continue; + const bound = refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES); + const target = isMetaObject(bound) ? bound : undefined; + out.push({ fkField, targetEntity: target?.name ?? stripPackage(targetRef), target }); } return out; } @@ -244,7 +251,7 @@ export function renderReverseFinderFns(entity: MetaObject, fk: ReverseFk, ctx: R // name is the argument to integer()/text()), so column access uses fk.fkField. // The FK value type is the target entity's PK type (the FK references it). Fall // back to the FK field's own subType, then to number (long-shaped key default). - const targetPk = ctx.pkMap.get(fk.targetEntity); + const targetPk = fk.target === undefined ? undefined : pkInfoOf(fk.target); const ownField = entity.findField(fk.fkField); const valueType = subTypeToTsType(targetPk?.fieldSubType ?? ownField?.subType ?? "long"); diff --git a/server/typescript/packages/test-generators/src/templates/relations-block.ts b/server/typescript/packages/test-generators/src/templates/relations-block.ts index f345c27a0..5b1cbbc64 100644 --- a/server/typescript/packages/test-generators/src/templates/relations-block.ts +++ b/server/typescript/packages/test-generators/src/templates/relations-block.ts @@ -8,7 +8,8 @@ import { type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { crossEntitySpecifier } from "@metaobjectsdev/codegen-ts/engine"; import type { RelationEntry } from "@metaobjectsdev/codegen-ts/engine"; import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine"; -import { tphStorageName } from "@metaobjectsdev/codegen-ts/engine"; +import { tphStorageObject } from "@metaobjectsdev/codegen-ts/engine"; +import { pkInfoOf } from "@metaobjectsdev/codegen-ts/engine"; /** * Render the relations() block for one entity. @@ -32,7 +33,7 @@ export function renderRelationsBlock(entity: MetaObject, ctx: RenderContext): Co const thisEntityPackage = effectivePackage(entity); const lines: Code[] = entries.map((entry) => - renderRelationEntry(entry, ctx, entity.name, varName, thisEntityPackage), + renderRelationEntry(entry, ctx, entity, varName, thisEntityPackage), ); return code`export const ${relationsVarName} = ${relationsFn}(${varName}, (${params}) => ({ @@ -44,7 +45,7 @@ ${joinCode(lines, { on: ",\n", trim: false })} function renderRelationEntry( entry: RelationEntry, ctx: RenderContext, - thisEntityName: string, + thisEntity: MetaObject, thisVarName: string, thisEntityPackage: string | undefined, ): Code { @@ -53,11 +54,11 @@ function renderRelationEntry( // then hops junction → target via the junction's one() sides). The // mountM2mRoute the routes generator emits performs the flattened two-stage // traversal for the REST contract. - if (entry.cardinality === CARDINALITY_MANY && entry.junctionEntity !== undefined) { + if (entry.cardinality === CARDINALITY_MANY && entry.junctionEntity !== undefined && entry.junction !== undefined) { const junctionSpec = crossEntitySpecifier( ctx.outputLayout, thisEntityPackage, - ctx.packageOf.get(entry.junctionEntity), + effectivePackage(entry.junction), entry.junctionEntity, ctx.extStyle, ); @@ -67,22 +68,23 @@ function renderRelationEntry( // Bind to the table that stores the target's rows: a TPH subtype's module has no // table const, so a navigation onto `Carrier` binds `parties` from `Party`. - const targetTableEntity = tphStorageName(entry.targetEntity, ctx.loadedRoot); + const targetTable = tphStorageObject(entry.target); + const targetTableEntity = targetTable.name; // A navigation onto this entity's OWN table (`Order.parent`, or a TPH base onto one of // its subtypes) names the local const: importing it from this very module clashes with // its declaration (TS2440). Otherwise imp() tracks and emits the cross-entity import. - const targetVarSym = targetTableEntity === thisEntityName + const targetVarSym = targetTable === thisEntity ? thisVarName : imp(`${ctx.collectionName(targetTableEntity)}@${crossEntitySpecifier( ctx.outputLayout, thisEntityPackage, - ctx.packageOf.get(targetTableEntity), + effectivePackage(targetTable), targetTableEntity, ctx.extStyle, )}`); if (entry.cardinality === CARDINALITY_ONE) { - const pkInfo = ctx.pkMap.get(targetTableEntity); + const pkInfo = pkInfoOf(targetTable); const targetPkField = pkInfo?.fieldName ?? "id"; return code` ${entry.name}: one(${targetVarSym}, { fields: [${thisVarName}.${entry.fkField ?? "id"}], references: [${targetVarSym}.${targetPkField}] })`; } diff --git a/server/typescript/packages/test-generators/src/templates/zod-validators.ts b/server/typescript/packages/test-generators/src/templates/zod-validators.ts index 2b5ce2908..113186b89 100644 --- a/server/typescript/packages/test-generators/src/templates/zod-validators.ts +++ b/server/typescript/packages/test-generators/src/templates/zod-validators.ts @@ -46,7 +46,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { sharedEnumForField } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumImportSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumZodConstName } from "./enums-file.js"; -import { fieldDeclaringPackage, type RenderContext } from "@metaobjectsdev/codegen-ts/engine"; +import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; // FR-035: the SAME required-predicate that drives the Drizzle column's .notNull() // drives the UpdateSchema's .nullable() exclusion — shared so they cannot drift. @@ -626,7 +626,7 @@ function zodFieldExpr( // Drizzle .$type<> use) so all three agree. Without owner/ctx (bare // unit-test calls) fall back to the bare name + flat same-dir. const ownerPkg = (ctx && owner) ? effectivePackage(owner) : undefined; - const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, ownerPkg)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, ownerPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -648,7 +648,7 @@ function zodFieldExpr( const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const ownerPkg = (ctx && owner) ? effectivePackage(owner) : undefined; - const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, ownerPkg)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(field) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, ownerPkg, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/spec/cross-language-porting-guide.md b/spec/cross-language-porting-guide.md index 4ecbf6985..7423b53e7 100644 --- a/spec/cross-language-porting-guide.md +++ b/spec/cross-language-porting-guide.md @@ -61,9 +61,12 @@ warning lists, through these ordered stages. Mirror the order; the mechanism is children **accumulate** (append, not re-sort). An `@overlay` node with no existing target → `ERR_OVERLAY_NO_TARGET`. 4. **Deferred super-resolution** over the *fully merged* tree (so a source can extend a base - defined in a later file). Package-aware reference forms: bare `Name` (context package, - then root), absolute `::pkg::Name`, relative `..::pkg::Name` (walk up N package levels). - Unresolved → `ERR_UNRESOLVED_SUPER`. Idempotent walk; skip already-resolved nodes. + defined in a later file). Package-aware reference forms (ADR-0059): bare `Name` (the + declaring file's package, then root), root-absolute `::pkg::Name` (exact, no package-local + retry), parent-relative `..::Name` (drop one package segment per `..::`; over-drop → + `ERR_BAD_ATTR_VALUE`). Every source hands these strings over as written — the YAML desugar + does not rewrite them. Unresolved → `ERR_UNRESOLVED_SUPER`. Idempotent walk; skip + already-resolved nodes. 5. **Validation passes**, in this order: 1. **Subtype rules** — a value object must not carry a primary identity (error `ERR_SUBTYPE_RULE_VIOLATION`); a concrete (non-abstract) entity should have a primary @@ -89,8 +92,13 @@ warning lists, through these ordered stages. Mirror the order; the mechanism is 6. **DataGrid filter values** — a dataGrid `@filter` must reference a `@filterable` sibling field and use an operator allowed for that field's subtype (`ERR_BAD_ATTR_FILTER`). This is the canonical *legitimately-central* cross-node pass. -6. **Freeze** the tree recursively (immutable thereafter; enables per-node read caching). -7. **Return** the root + errors + warnings. +6. **Resolve references once** (ADR-0059): for every own reference attribute (`extends`, + `@objectRef`, `@through`, `@references`, `@from`, `@via`, `@of`, `@parameterRef`, + `@payloadRef`, `@responseRef`, `@spine`) that resolves, record the target, keep the + authored string, and rewrite the value to its canonical spelling (`pkg::Name`; `::Name` + for a root-level target from a packaged referrer). Unresolved references stay as written. +7. **Freeze** the tree recursively (immutable thereafter; enables per-node read caching). +8. **Return** the root + errors + warnings. ## 4. The extensibility model (ADR-0002/0003/0004 in practice) diff --git a/spec/decisions/ADR-0006-ai-first-yaml-authoring.md b/spec/decisions/ADR-0006-ai-first-yaml-authoring.md index fb45253db..588bee2ab 100644 --- a/spec/decisions/ADR-0006-ai-first-yaml-authoring.md +++ b/spec/decisions/ADR-0006-ai-first-yaml-authoring.md @@ -76,6 +76,10 @@ silent footguns and "multiple ways to say the same thing." Four problems with YA | Port | Status | |----------------|-----------------------------------------------------------------------------------------| | **TypeScript** | D1 + D2 + D3 + D4 shipped (`core/yaml-desugar.ts`, `ERR_YAML_COERCION`). | -| **Python** | _pending_ — YAML loader port to follow. | -| **Java** | _pending_ — YAML loader port to follow. | -| **C#** | _pending_ — YAML loader follows the source-v2 cutover (currently held on v1). | +| **Python** | D1 + D2 + D3 + D4 shipped (2026-05-24). | +| **Java** | D1 + D2 + D3 + D4 shipped (2026-05-24). | +| **C#** | D1 + D2 + D3 + D4 shipped (2026-05-24). | + +The desugar is **syntax-only** ([ADR-0059](ADR-0059-references-resolve-once-after-the-tree.md)): +it lowers keys, scalar bodies and sigils, and leaves every reference value exactly as written. +One resolver per port binds references after the whole tree loads, the same for YAML and JSON. diff --git a/spec/decisions/ADR-0032-canonical-fqn-refs.md b/spec/decisions/ADR-0032-canonical-fqn-refs.md index 1240986cc..92508b379 100644 --- a/spec/decisions/ADR-0032-canonical-fqn-refs.md +++ b/spec/decisions/ADR-0032-canonical-fqn-refs.md @@ -2,7 +2,7 @@ ## Status -Accepted (2026-06-13). Pre-1.0 blocker. Defined by FR-032 +Accepted (2026-06-13). **Partly superseded by [ADR-0059](ADR-0059-references-resolve-once-after-the-tree.md) (§3, §4)**: canonical JSON now accepts every reference form and the YAML desugar no longer expands references; §2's grammar stands and applies to every source. Pre-1.0 blocker. Defined by FR-032 (`docs/superpowers/specs/2026-06-13-fr-026-canonical-fqn-refs-design.md`). ## Context diff --git a/spec/decisions/ADR-0042-bare-references-are-package-local.md b/spec/decisions/ADR-0042-bare-references-are-package-local.md index 5888e04d6..1d4cb7bd1 100644 --- a/spec/decisions/ADR-0042-bare-references-are-package-local.md +++ b/spec/decisions/ADR-0042-bare-references-are-package-local.md @@ -4,7 +4,7 @@ **Accepted** (2026-07-12). **Amends [ADR-0041](ADR-0041-cross-package-reference-resolution.md)** — supersedes its Decision §2(b) "unique elsewhere" and §2(c) `ERR_AMBIGUOUS_REF`. ADR-0041 §1 (FQN-exact) and §2(a) (same-package preference) stand. Aligns with **[ADR-0032](ADR-0032-canonical-fqn-refs.md)**. -**Implementation status: in progress** (as of 2026-07-12). The decision is accepted; the coordinated 5-port implementation is not yet merged. The TS reference-port core (the source-agnostic bare→FQN fold at load + package-local resolvers) is validated (2100/2125 metadata tests; the remainder are the expected golden/accessor updates). Fixes #191, closes #174 (superseded). See the implementation plan for the remaining work (TS golden regen + the other four ports + release). +**Implementation status: implemented** (`3a2944f21`, `c20ccefbb`); **amended by [ADR-0059](ADR-0059-references-resolve-once-after-the-tree.md)**, which replaces §3's desugar sentence (sources no longer interpret references; one resolver binds them after the tree loads) and corrects §4's `extends` claim. §2 stands. ## Context diff --git a/spec/decisions/ADR-0059-references-resolve-once-after-the-tree.md b/spec/decisions/ADR-0059-references-resolve-once-after-the-tree.md new file mode 100644 index 000000000..96a472a8b --- /dev/null +++ b/spec/decisions/ADR-0059-references-resolve-once-after-the-tree.md @@ -0,0 +1,151 @@ +# ADR-0059: References resolve once, after the tree + +## Status + +**Accepted** (2026-10-17), for 1.1.0. Rides the `metamodelVersion` `1.1` bump already in this +release: the canonical grammar change in Decision 9 is a metadata-contract change +(`docs/compatibility-policy.md` places the `extends` / `@via` grammar and the package `::` +syntax under the metamodel version). + +- **Supersedes [ADR-0032](ADR-0032-canonical-fqn-refs.md) §3** (canonical JSON is FQN-only by + input contract, enforced by a guard) **and §4** (the YAML desugar expands references; the JSON + loader does a pure FQN match). ADR-0032 §2's reference grammar (`::Name` root-absolute, + `..::Name` parent-relative, `pkg::Name` exact, bare `Name`) stands, and now applies to every + source. +- **Amends [ADR-0042](ADR-0042-bare-references-are-package-local.md).** §2 (a bare name is package-local, + then root-level) stands. §3's sentence that the desugar folds bare names is replaced by + Decision 1 below, and §4's `extends` claim is corrected by Decision 3: a bare `extends` + resolves in its own file's package, then at the root, and never binds an object in another + package. + +## Context + +Before this decision a reference was interpreted in two places. The YAML desugar rewrote +reference values while it lowered YAML to the canonical shape (bare names to the file's package, +`::` and `..::` expanded against the node being lowered). Canonical JSON took values as written +and resolved them later, in each port's loader, and refused `::` and `..::` outright +(`ERR_RELATIVE_REF_IN_CANONICAL`). The two paths disagreed: + +- YAML `::Apple` from package `acme` bound `acme::Apple` when both a root `Apple` and an + `acme::Apple` existed, because the expanded string was then re-resolved package-locally. +- An inherited bare reference resolved in the inheritor's package from JSON and in the + declarer's package from YAML. +- A bare `@of` after `@via` resolved in the package the walk reaches from JSON only. +- A file with no `package` inherited the package of whichever file it merged into, so the same + model loaded differently depending on file order. +- The serializer wrote values as they were stored, so a multi-package model exported to JSON did + not always load back to itself. + +A load of each conformance fixture as JSON and as YAML (the "twin" comparison) found differences +in 17 fixtures in TypeScript, Python and C#, and 13 in Java. Every port also carried several +private re-implementations of reference matching, some of them by short name. + +## Decision + +**1. Sources do not interpret references.** YAML, canonical JSON and any later source produce the +canonical-shaped tree with every reference string exactly as written. The YAML desugar keeps all +of its syntactic work (fused keys, scalar bodies, `[]`, sigil-free attributes, the coercion +guard) and stops rewriting reference values. + +**2. One resolver per port.** A reference splits into an owner and a dotted child tail (the tail +starts at the first `.` after the owner's last `::`). The owner resolves by its form: + +- `::Rest` — root-absolute: the node whose resolution key is exactly `Rest`. No package-local + retry. +- `..::Rest` — parent-relative: drop one trailing segment of the referrer's package per `..::`, + then look up `::Rest` (or `Rest` when nothing remains) exactly. More `..::` than + segments is `ERR_BAD_ATTR_VALUE`. +- `pkg::Name` — exact. +- bare `Name` — exact `::Name`, else exact root-level `Name` (ADR-0042 §2). + +The tail then walks child names (ADR-0029). No resolver matches by short name or by bare tail. + +**3. The referrer package.** The package a reference resolves in is: + +- by default, the effective package (`package`, else the file's package, else root) of the + top-level node that **physically declares the attribute**, so an inherited reference means what + it meant where it was written; +- for `@of` on a dimension that also has `@via`, the package of the entity the `@via` reaches; +- for `@spine` on a report, the package of the report's `@from` entity; +- for `extends`, the declaring node's own file package. + +**4. One set of reference attributes, the same in every port:** `extends`, `@objectRef`, +`@through`, `@references`, `@from`, `@via`, `@of`, `@parameterRef`, `@payloadRef`, +`@responseRef` and `@spine`. `@implementedBy` is not in the set: its grammar names members as +well as objects, and the requirement gate resolves it separately. + +**5. Resolve once, before freeze.** After the last validation pass and before the tree is frozen, +a `resolveReferences` pass walks the physical tree. For every own reference attribute that +resolves it stores the target on the attribute node (`resolvedTarget`), keeps the authored +string (`authoredValue`) and rewrites the value to its canonical spelling. `extends` keeps its +super pointer and stores the canonical spelling plus the authored one. A reference that does not +resolve is left as written; validation has already reported it with its own code, so error codes, +order and positions do not change. + +**6. Canonical spelling.** A packaged target is written `pkg::Name`. A root-level target is +written `::Name` from a packaged referrer and `Name` from a root-level one. The dotted tail is +kept. A canonical value is absolute: `pkg::Name` and `::Name` name exactly one node, and a bare +canonical `Name` means the root-level object. It keeps that meaning wherever it is read, so a +root-level declarer's bare `Name` inherited by a node in package `acme` still names the root +`Name` when `acme::Name` exists. + +**7. Consumers read the resolved model.** Every consumer after load reads the bound target +(`refTarget(node, attr)` in TypeScript) or resolves the canonical value exactly +(`lookupCanonical`). It never resolves a canonical value again with a referrer package, because +the package of the node that inherits an attribute is not the package it was written in, and +applying it would turn a bare root-level `Name` into `::Name`. A +validation pass, which runs before `resolveReferences` and reads authored values, resolves in +the package of the node that physically declares the attribute (Decision 3). Code that +compared a reference value to an object's short name compares the target node instead, and +no consumer binds by short name. + +**8. The serializer writes canonical values.** `canonicalSerialize` writes the canonical +attribute values and the canonical `extends`. Document package rule: + +- every named top-level node has the same non-empty effective package `P`: write + `metadata.root.package: P`, and keep any package a node states itself (so single-package + documents do not move). When the root states no package and every node names `P` itself, the + root package stays absent; +- otherwise: no root package, and each named top-level node carries its own effective package. + +A `refs: "authored"` option writes the authored spellings instead. Only `meta fmt` uses it, so +`fmt` stays a layout and order formatter and does not rewrite an adopter's references. Its +equivalence check (that a formatted file loads to the same model) compares canonical output, +because it is checking meaning. + +**9. Canonical JSON accepts every form.** The relative-reference guard is deleted and +`ERR_RELATIVE_REF_IN_CANONICAL` is retired. The serializer never writes `..::`. + +**10. The default package is per file.** A file without `package` declares root-level nodes +whatever order the files load in. + +**Human-facing text keeps quoting what it quoted.** Documentation pages, describe text and +refusal messages that printed a reference as the author wrote it keep doing so (`authoredAttr` in +TypeScript reads the authored spelling through `extends`), so a reader can find the line they +wrote and existing documentation does not churn. Text that printed the value the YAML desugar had +expanded (the docs site's origins table) now prints the canonical value, which is the same +string. Generated code that names a type uses the +target's own name, never the reference string (a reference is no identifier). The neutral +template data (`targetRef`) carries the canonical value, so it does not depend on how a reference +was spelled. + +## Two gates, in every port + +- **YAML twin.** Every conformance fixture's input loads once as JSON and once with the same texts + read as YAML, in the same order. Both loads report the same ordered error codes and, when clean, + serialize identically (effective serialization too, where the fixture has one). Exclusions live + in `fixtures/conformance/yaml-twin-exclusions.json`, one reason per entry. +- **Round trip.** Every fixture's `expected.json` loads as the only input and serializes back to + itself. Exclusions live in `fixtures/conformance/round-trip-exclusions.json`. + +## Consequences + +- A reference means the same thing whatever source it was written in, and exported JSON loads + back to the same model. +- Canonical output changes: references are fully qualified, a root-level target from a package is + `::Name`, and a multi-package document carries a package on each top-level node. The shared + goldens moved once, from the TypeScript oracle. +- A bare `extends` that named an object in another package now fails with + `ERR_UNRESOLVED_SUPER` in every port, as ADR-0042 already required and Java already did. +- Generated output that printed a reference string may now print the canonical form. The + CHANGELOG lists the diffs to expect. diff --git a/spec/decisions/ADR-0060-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0060-jvm-codegen-hash-manifest.md new file mode 100644 index 000000000..5616f7cc0 --- /dev/null +++ b/spec/decisions/ADR-0060-jvm-codegen-hash-manifest.md @@ -0,0 +1,134 @@ +# ADR-0060: Java and Kotlin codegen decide overwrites from the committed hash manifest + +## Status + +**Accepted** (2026-10-17), for 1.1.0 (Maven Central 8.1.0). Registers no vocabulary: +`metamodelVersion` stays `1.1`. It changes generated-file handling on the JVM only. TypeScript, +Python and C# already worked this way. + +## Context + +TypeScript, Python and C# record every file `gen` writes in a committed manifest, +`.metaobjects/.gen-state/.hashes.json`: a project-relative path mapped to the SHA-256 of the +content written. A file that still hashes to its record is generated output and safe to +overwrite. An edited file, or a file with no record, is refused by name. + +Java and Kotlin had no manifest. `GeneratedFileWriter` decided from the `GENERATED` token in a +file's header: overwrite a file that carries it, refuse one that does not. That rule failed in +two ways: + +1. **A hand edit was overwritten silently.** An edited generated file keeps its header, so the + one case worth protecting was the one the rule could not see. +2. **A file that was no longer generated stayed forever.** With no record of what a run wrote, + nothing could tell a stale generated file from someone's own source. The 1.1.0-rc.5 re-check + found this gap in every port. Renaming a value object left the old file, and the drift gate + reported it but could not say which files `gen` would delete. Every port now runs an orphan + sweep that deletes an untouched recorded file no generator emits. The sweep needs the + manifest as evidence, so the JVM needed one. + +The earlier position, recorded in `GeneratedFileWriter`'s javadoc, was that the JVM did not +need edit detection: these ports customize through build configuration and template specs, not +by editing emitted files. That position is still true of how most JVM adopters work. But it +does not cover deletion, and deletion without evidence is not safe on any port. + +## Decision + +1. **The JVM keeps the same manifest as the other ports.** It is + `/.metaobjects/.gen-state/.hashes.json`, with project-relative keys and + SHA-256 hex values, sorted, pretty-printed, with a trailing newline. Two executions in one + module share the file, and may share an output directory, so each entry the JVM writes + also names its **owner**, what emitted it: the generator's registry id + (`:`, or the class name of a generator of your own) and its output + directory relative to the module basedir, + `"": { "hash": "", "generator": "java:dto", "outputDir": "target/generated-sources/java" }`. + The owner names the configuration, not the Maven execution, so `default-cli`, a renamed + execution and the lifecycle execution with the same configuration agree on it. The + sweep knows every generator/output-directory pair the module configures from the + `metaobjects:generate` executions and plugin-level configuration of several models of + the module, and takes their union: the module as this build sees it (its active + profiles), the module with no profile, and the module with each profile the module and + its parent POMs declare switched on alone. Maven builds each of them with this build's + request settings, so profile-only properties, inherited profiles and basedirs resolve + exactly as on a build with that profile on. A pair declared only in a profile that is + off for this build is therefore still configured, and so is a pair two mutually + exclusive profiles resolve differently (each sets the same property or overrides the + same execution's `` its own way); its files are never treated as stranded. + A combination of two or more inactive profiles is not modelled: a pair that exists only + when two profiles are on together counts only on a build that activates both. When a + model cannot be built, the sweep judges only this run's own entries. A recorded file is an orphan when no generator emitted it in this + build and either its owner is one of this run's own generators, or its owner is + configured in no execution of the module (a removed generator, wherever its files + live). An entry with no owner (the plain `"": ""` form other ports write) + counts as configured nowhere only inside this run's output directories. A file another + configured generator owns is never this run's orphan, even in a shared output + directory. Every owner has exactly one execution: two executions that configure the + same generator into the same output directory, in any of the models above, fail + `generate` and `verify` before anything is written, naming both executions and the + profile each comes from. The plugin-level configuration is no execution of its own, so + `default-cli` beside the execution that inherits it is one configuration. An execution + with `none` (an inherited one switched off, or an on-demand one) is left + out of this check but its pairs still count as configured, so its output is never + stranded by a lifecycle build or `verify`. Invoked explicitly (`generate@`), it is refused when an enabled execution + owns one of its pairs. One path emitted with different content by two generator/output-directory + pairs in the same build is an error naming both; byte-identical re-emission is one file, + and a path the adopter hand-owns is refused for each generator, not a collision. The first build on a manifest that names no generator owner + (an 8.1.0 pre-release recorded the execution id instead) records the owners and deletes + nothing. Two modules have two manifests. The TypeScript, Python and C# readers take an + object entry's `hash` as its recorded hash and keep its other fields when they save, so + a manifest a Maven module shares with another port's project keeps every entry. +2. **The overwrite decision is the hash, not the marker.** For a file at a generated path: + - absent: write it and record it; + - same content as fresh output: unchanged, and its record is confirmed; + - recorded, and it still hashes to its record: overwrite it and record the new content; + - recorded but edited: refuse it by name (`handEdits: keep`, the default), or replace it + and record the new content (`handEdits: overwrite`, one log line per file); + - not recorded: a file that carries the `GENERATED` token is adopted (see 3); one that does + not is refused, whatever `handEdits` says. +3. **The first run adopts.** A project upgrading from 8.0.x has no manifest, and its generated + files carry the token. The first run takes each such file as generated output: it writes + fresh output, records it and prints one notice to commit the manifest. It deletes nothing, + because a file has to be recorded before the sweep may delete it. This is the old behaviour + for those files (the token meant overwrite), so the upgrade run changes nothing an adopter + did not already get. +4. **The marker stays, for humans and for that one moment.** Generators still emit it. It + decides only for a file with no record, and when no manifest is configured: an embedder + driving a generator outside `mvn metaobjects:generate`, or a file written outside the + module basedir. +5. **The orphan sweep is the cross-port rule.** After a full run, a recorded file under the + execution's output directories that no generator emitted is deleted when untouched and + refused by name when edited. Under `handEdits: overwrite` an edited orphan is also deleted + when every generator writing into its directory overwrites. The TypeScript, Python and C# + manifests do not record which generator wrote a file, which is why the rule is + directory-wide there; the JVM manifest records the generator but applies the same + directory-wide rule, so every port behaves the same. `mvn metaobjects:verify` judges + extra files on the same evidence. The rule is written once in + `docs/features/own-your-codegen.md`, "Files that are no longer generated". +6. **A refusal stays a warning on the JVM.** The build does not fail over a refused file. + `mvn metaobjects:generate` runs inside the reactor, and failing every build over a file the + user chose to own punishes the person the refusal protects. The other ports exit 1. + `mvn metaobjects:verify` is the gate that fails. +7. **Write paths that bypass `GeneratedFileWriter` stay outside the manifest.** User templates + (`TemplateScopeGenerator`, `MustacheTemplateGenerator`), the api-docs pages and the + `META-INF/services` registration are not recorded. So the sweep never deletes them: a file + with no record is not ours. + +## Consequences + +- Renaming a generator's output directory, or moving it to a generator of another + registry id, changes its owner: the old pair is configured nowhere, so the next build + deletes its untouched files and regenerates them under the new owner. +- Adopters commit one new file per module, `.metaobjects/.gen-state/.hashes.json`. A clone + without it adopts again on its first run, which is harmless, but it cannot delete a stale + file or protect an edit until a manifest exists. +- **Editing a generated file now protects it.** On 8.0.x the next run overwrote the edit. A + team that edited generated files and relied on regeneration to discard the edits now gets + a refusal per file. Set `overwrite` on the generator to keep the old + outcome on purpose. +- Deleting the `GENERATED` line still takes a file out of regeneration: it is an edit, so the + file is refused under `keep`. +- A renamed or deleted object's generated files are removed by the next full run. +- The JVM matches the other four ports on the overwrite rule and the orphan rule. It differs + only in item 6 (a refusal is a warning) and in having no `--generators` narrowing: an + execution's `` list is always complete, so every run is a full run. + +Migration: [jvm-codegen-hash-manifest.md](../../docs/features/migrations/jvm-codegen-hash-manifest.md). diff --git a/spec/decisions/README.md b/spec/decisions/README.md index 2c1cd859a..27b2f4547 100644 --- a/spec/decisions/README.md +++ b/spec/decisions/README.md @@ -55,6 +55,8 @@ Write one when a decision is **cross-cutting** (affects more than one language p - [ADR-0056 — A value object's type is generated once — the template tier references it](ADR-0056-value-object-types-are-generated-once.md) — *Accepted* (supersedes the payload-tier half of ADR-0044) - [ADR-0057 — Requirement checks and requirement tests run in every port](ADR-0057-requirement-checks-and-tests-in-every-port.md) — *Accepted* (reverses the "Checks — TypeScript only" split recorded in `docs/CONFORMANCE.md`) - [ADR-0058 — One physical-naming setting, read by every port](ADR-0058-one-physical-naming-setting.md) — *Accepted* (one `naming` block in `.metaobjects/config.json` replaces the per-port column-naming flags, pom args, config keys and runtime setters) +- [ADR-0059 — References resolve once, after the tree](ADR-0059-references-resolve-once-after-the-tree.md) — *Accepted* (sources keep references as written; one resolver per port binds them before freeze and the serializer writes them canonical; supersedes ADR-0032 §3–§4, amends ADR-0042) +- [ADR-0060 — Java and Kotlin codegen decide overwrites from the committed hash manifest](ADR-0060-jvm-codegen-hash-manifest.md) — *Accepted* (the JVM plugin records each file it writes in `.metaobjects/.gen-state/.hashes.json`, refuses an edited one, and deletes an untouched orphan, owned per generator and output directory) > **Index gap — ADR-0031 through ADR-0051 are on disk but not listed above.** The index stopped > being maintained after ADR-0030; the files are authoritative, this list is not. Read