From 52a18841b5507e3ecd7cf64ba7b4155a779274ff Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 08:41:20 -0400 Subject: [PATCH 01/22] feat(codegen): orphan sweep in every port, JVM hash manifest, per-generator handEdits - gen deletes a recorded, untouched file no generator emits any more (TS, Python, C#, Java, Kotlin) - Java/Kotlin decide overwrites from the committed hash manifest (ADR-0059) - handEdits: keep|overwrite per generator in all five ports --- CHANGELOG.md | 50 +++ .../skills/metaobjects-codegen/SKILL.md | 60 ++-- docs/features/cli.md | 18 ++ docs/features/codegen-concepts.md | 21 +- .../migrations/jvm-codegen-hash-manifest.md | 94 ++++++ docs/features/own-your-codegen.md | 176 ++++++++--- docs/ports/java.md | 7 +- .../skills/metaobjects-codegen/SKILL.md | 60 ++-- .../skills/metaobjects-codegen/SKILL.md | 60 ++-- .../skills/metaobjects-codegen/SKILL.md | 60 ++-- .../skills/metaobjects-codegen/SKILL.md | 60 ++-- .../skills/metaobjects-codegen/SKILL.md | 60 ++-- server/csharp/MetaObjects.Cli/Program.cs | 2 +- .../csharp/MetaObjects.Cli/VerifyCommand.cs | 3 + .../EjectContractTests.cs | 3 + .../OrphanSweepTests.cs | 269 ++++++++++++++++ .../csharp/MetaObjects.Codegen/CodegenCli.cs | 33 +- .../MetaObjects.Codegen/CodegenDrift.cs | 93 +++++- .../MetaObjects.Codegen/CodegenRunner.cs | 70 ++++- .../csharp/MetaObjects.Codegen/Generator.cs | 58 ++++ .../MetaObjects.Codegen/HashManifest.cs | 4 + .../csharp/MetaObjects.Codegen/OrphanSweep.cs | 160 ++++++++++ .../generator/util/GeneratedFileWriter.java | 243 +++++++++++++-- .../generator/util/HashManifest.java | 126 ++++++++ .../generator/util/OrphanSweep.java | 245 +++++++++++++++ .../util/GeneratedFileWriterTest.java | 166 ++++++++++ .../generator/util/HashManifestTest.java | 89 ++++++ .../generator/util/OrphanSweepTest.java | 233 ++++++++++++++ .../com/metaobjects/mojo/GeneratorParam.java | 43 +++ .../mojo/MetaDataGeneratorMojo.java | 98 +++++- .../metaobjects/mojo/MetaDataVerifyMojo.java | 95 +++++- .../DeclaresNothingWritesNothingTest.java | 7 +- .../MetaDataGeneratorMojoManifestTest.java | 287 ++++++++++++++++++ .../mojo/MetaDataVerifyMojoManifestTest.java | 232 ++++++++++++++ server/python/src/metaobjects/cli.py | 232 ++++++++++++-- .../python/src/metaobjects/codegen/config.py | 3 + .../codegen/metaobjects-config.schema.json | 21 +- .../src/metaobjects/codegen/orphan_sweep.py | 167 ++++++++++ .../metaobjects/codegen/overwrite_policy.py | 16 + .../src/metaobjects/codegen/project_config.py | 50 ++- .../python/src/metaobjects/codegen/runner.py | 13 +- .../python/tests/codegen/test_hand_edits.py | 167 ++++++++++ .../python/tests/codegen/test_orphan_sweep.py | 173 +++++++++++ .../test_requirement_tests_generator.py | 20 +- .../packages/cli/src/commands/verify.ts | 11 +- .../packages/cli/src/lib/codegen-drift.ts | 45 ++- .../verify-codegen-foreign-files.test.ts | 96 ++++++ .../packages/codegen-ts/src/generator.ts | 25 +- .../src/generators/requirement-tests.ts | 45 +-- .../packages/codegen-ts/src/index.ts | 1 + .../packages/codegen-ts/src/orphan-sweep.ts | 36 ++- .../codegen-ts/src/overwrite-policy.ts | 36 +++ .../codegen-ts/src/reconcile-orphans.ts | 16 +- .../src/reference/requirement-tests.ts | 41 +-- .../packages/codegen-ts/src/runner.ts | 107 ++++++- .../test/cube/cube-model-orphans.test.ts | 4 +- .../codegen-ts/test/orphan-sweep.test.ts | 2 +- .../test/reference-byte-identical.test.ts | 28 +- .../test/requirement-orphans-e2e.test.ts | 25 +- .../test/run-wide-orphan-sweep.test.ts | 168 ++++++++++ .../codegen-skill-overwrite-claim.test.ts | 25 +- .../ADR-0059-jvm-codegen-hash-manifest.md | 90 ++++++ 62 files changed, 4531 insertions(+), 417 deletions(-) create mode 100644 docs/features/migrations/jvm-codegen-hash-manifest.md create mode 100644 server/csharp/MetaObjects.Codegen.Tests/OrphanSweepTests.cs create mode 100644 server/csharp/MetaObjects.Codegen/OrphanSweep.cs create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/util/HashManifest.java create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/util/OrphanSweep.java create mode 100644 server/java/codegen-base/src/test/java/com/metaobjects/generator/util/HashManifestTest.java create mode 100644 server/java/codegen-base/src/test/java/com/metaobjects/generator/util/OrphanSweepTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java create mode 100644 server/python/src/metaobjects/codegen/orphan_sweep.py create mode 100644 server/python/tests/codegen/test_hand_edits.py create mode 100644 server/python/tests/codegen/test_orphan_sweep.py create mode 100644 server/typescript/packages/codegen-ts/test/run-wide-orphan-sweep.test.ts create mode 100644 spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8374bd3a7..563f426fe 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 @@ -111,6 +120,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 +861,21 @@ generator catalog and the Atlas evaluation are 1.2 work. ### Changed +**Java and Kotlin: codegen decides overwrites from the committed hash manifest (ADR-0059).** +**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 and shape 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; two modules +have two. 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 +1306,20 @@ until you regenerate. ### Fixed +- **`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/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/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..81e77ca3d 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-0059): + `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). 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..bc8bb8bd5 --- /dev/null +++ b/docs/features/migrations/jvm-codegen-hash-manifest.md @@ -0,0 +1,94 @@ +# 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-0059](https://github.com/metaobjectsdev/metaobjects/blob/main/spec/decisions/ADR-0059-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. 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. + +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..8af877863 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,41 @@ 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-0059).** +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 and shape as the other +ports. Two executions in one module share it, and each touches only the keys under its own +output directories; 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 +549,106 @@ 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. +- **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. Each execution sweeps only the output directories its +own generators write to, so two executions in one module never delete each other's files. A +refused orphan is a warning there, as every JVM refusal is (ADR-0059). + +`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. + +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. + +`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 +1130,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/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/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-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-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-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-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/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..eb1be0284 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); } diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs index c627ae4ad..a0e72851d 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs @@ -52,6 +52,9 @@ public void Census_every_generator_type_belongs_to_a_registry_entry() // (ADR-0034 Amendment 9). It writes nothing of its own: it runs the spec's `template` // generators, which the registry's `template` entry builds. registered.Add(typeof(MetaObjects.Codegen.TemplateCodegen.TemplateSpecGenerator)); + // WithHandEdits wraps an entry to carry its handEdits setting. It emits nothing of its own: + // every file comes from the entry it wraps. + registered.Add(typeof(IGenerator).Assembly.GetType("MetaObjects.Codegen.HandEditsGenerator", throwOnError: true)!); var shipped = ShippedGeneratorTypes(); Assert.NotEmpty(shipped); var unregistered = shipped.Where(t => !registered.Contains(t)).Select(t => t.FullName).OrderBy(n => n).ToList(); diff --git a/server/csharp/MetaObjects.Codegen.Tests/OrphanSweepTests.cs b/server/csharp/MetaObjects.Codegen.Tests/OrphanSweepTests.cs new file mode 100644 index 000000000..8c3c1b618 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/OrphanSweepTests.cs @@ -0,0 +1,269 @@ +// `dotnet meta gen` deletes a file it wrote earlier and no longer generates. +// +// The rc.5 re-check found the gap in every port: renaming the value object Telemetry to +// TelemetryReading (or a release moving api-docs pages to another package) left the old file on +// disk, `verify --codegen` reported it as extra and said "regenerate", and regenerating never +// removed anything, so an agent following the remedy looped. The rule every port shares +// (OrphanSweep): 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 run narrowed by --generators sweeps nothing. + +using System.Text.Json; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +[Collection("Console")] +public sealed class OrphanSweepTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "mo-orphans-" + Guid.NewGuid().ToString("N")); + private string Meta => Path.Combine(_root, "metaobjects"); + private string Out => Path.Combine(_root, "gen"); + private string Docs => Path.Combine(_root, "docs"); + private string ManifestPath => Path.Combine(_root, ".metaobjects", ".gen-state", ".hashes.json"); + + public OrphanSweepTests() => Directory.CreateDirectory(Meta); + public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { } } + + /// One file per value object, the shape of a value-object POCO generator. + private sealed class PerValue : IGenerator + { + public string Name => "values"; + public IEnumerable Generate(GenContext ctx) => + ctx.Root.Objects().Where(o => o.SubType == "value") + .Select(o => new EmittedFile($"{o.Name}.g.cs", $"// \npublic sealed class {o.Name} {{ }}\n")); + } + + /// One page per object under its package, in a directory of its own, the shape of api-docs. + private sealed class PerPackageDocs(string outDir) : IGenerator + { + public string Name => "pages"; + public string? OutDir => outDir; + public IEnumerable Generate(GenContext ctx) => + ctx.Root.Objects().Select(o => new EmittedFile( + $"{o.ResolutionKey().Replace("::", "/")}.md", $"# {o.Name}\n")); + } + + private IReadOnlyList? _suite; + private IReadOnlyList Suite => _suite ?? [new PerValue(), new PerPackageDocs(Docs)]; + + private void Model(string package, params string[] values) => + File.WriteAllText(Path.Combine(Meta, "meta.acme.json"), JsonSerializer.Serialize(new Dictionary + { + ["metadata.root"] = new Dictionary + { + ["package"] = package, + ["children"] = values.Select(v => new Dictionary + { + ["object.value"] = new Dictionary + { + ["name"] = v, + ["children"] = new[] { new Dictionary { ["field.string"] = new Dictionary { ["name"] = "reading" } } }, + }, + }).ToArray(), + }, + })); + + private (int Code, string Out, string Err) Run(params string[] args) + { + var (prevOut, prevErr) = (Console.Out, Console.Error); + var (o, e) = (new StringWriter(), new StringWriter()); + Console.SetOut(o); + Console.SetError(e); + try { return (CodegenCli.Run(args, Suite), o.ToString(), e.ToString()); } + finally { Console.SetOut(prevOut); Console.SetError(prevErr); } + } + + private (int Code, string Out, string Err) Gen(params string[] extra) => Run(["gen", Meta, "--out", Out, .. extra]); + private (int Code, string Out, string Err) Verify() => Run("verify", "--codegen", Meta, "--out", Out); + + private Dictionary Manifest() => + JsonSerializer.Deserialize>(File.ReadAllText(ManifestPath))!; + + [Fact] + public void A_renamed_value_object_is_deleted_and_verify_goes_green() + { + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var old = Path.Combine(Out, "Telemetry.g.cs"); + Assert.True(File.Exists(old)); + + Model("acme", "TelemetryReading"); + var drift = Verify(); + Assert.Equal(1, drift.Code); + Assert.Contains("Telemetry.g.cs (committed but a fresh regen would not emit it; 'dotnet meta gen' deletes it)", drift.Err); + Assert.Contains("Run 'dotnet meta gen' to regenerate", drift.Err); + + var gen = Gen(); + Assert.Equal(0, gen.Code); + Assert.False(File.Exists(old)); + Assert.Contains("removed: Telemetry.g.cs", gen.Out); + Assert.DoesNotContain(Manifest().Keys, k => k.EndsWith("Telemetry.g.cs", StringComparison.Ordinal)); + Assert.True(File.Exists(Path.Combine(Out, "TelemetryReading.g.cs"))); + + Assert.Equal(0, Verify().Code); + } + + [Fact] + public void A_hand_edited_orphan_is_refused_and_verify_says_delete_it() + { + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var old = Path.Combine(Out, "Telemetry.g.cs"); + File.AppendAllText(old, "// mine\n"); + + Model("acme", "TelemetryReading"); + var gen = Gen(); + Assert.True(File.Exists(old)); + Assert.Contains("NOT deleted", gen.Err); + Assert.Contains("gen/Telemetry.g.cs", gen.Err); + // Still recorded, so the refusal repeats until someone resolves it. + Assert.Contains(Manifest().Keys, k => k.EndsWith("Telemetry.g.cs", StringComparison.Ordinal)); + + var drift = Verify(); + Assert.Equal(1, drift.Code); + Assert.Contains("edited by hand", drift.Err); + Assert.Contains("will NOT delete them", drift.Err); + Assert.DoesNotContain("to regenerate", drift.Err); + } + + [Fact] + public void Pages_that_move_package_in_their_own_directory_leave_no_stale_copy() + { + Model("acme::iam", "Leg"); + Assert.Equal(0, Gen().Code); + var before = Path.Combine(Docs, "acme", "iam", "Leg.md"); + Assert.True(File.Exists(before)); + + Model("acme::ops", "Leg"); + Assert.Equal(0, Gen().Code); + Assert.True(File.Exists(Path.Combine(Docs, "acme", "ops", "Leg.md"))); + Assert.False(File.Exists(before)); + Assert.Equal(0, Verify().Code); + } + + [Fact] + public void A_file_nobody_generated_is_never_touched_nor_convicted() + { + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var mine = Path.Combine(Out, "Helpers.cs"); + File.WriteAllText(mine, "public static class Helpers { }\n"); + + Assert.Equal(0, Verify().Code); + Model("acme", "TelemetryReading"); + Assert.Equal(0, Gen().Code); + Assert.True(File.Exists(mine)); + Assert.Equal(0, Verify().Code); + } + + [Fact] + public void A_generators_narrowed_run_deletes_nothing() + { + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var page = Path.Combine(Docs, "acme", "Telemetry.md"); + var old = Path.Combine(Out, "Telemetry.g.cs"); + + Model("acme", "TelemetryReading"); + Assert.Equal(0, Gen("--generators", "values").Code); + Assert.True(File.Exists(old)); + Assert.True(File.Exists(page)); + } + + // handEdits: Keep (the default) refuses to replace or delete a hand edit; Overwrite replaces + // it with fresh output, deletes the edited orphan, and names each file it discards. + + private void Overwriting() => _suite = [new PerValue().WithHandEdits(HandEdits.Overwrite)]; + + [Fact] + public void Keep_refuses_to_replace_an_edited_file() + { + _suite = [new PerValue()]; + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var file = Path.Combine(Out, "Telemetry.g.cs"); + File.AppendAllText(file, "// mine\n"); + + var gen = Gen(); + Assert.EndsWith("// mine\n", File.ReadAllText(file)); + Assert.Contains("Refused to overwrite", gen.Out + gen.Err); + Assert.DoesNotContain("handEdits", gen.Out + gen.Err); + } + + [Fact] + public void Overwrite_replaces_an_edited_file_and_rebaselines_it() + { + Overwriting(); + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var file = Path.Combine(Out, "Telemetry.g.cs"); + var fresh = File.ReadAllText(file); + File.AppendAllText(file, "// mine\n"); + + var drift = Verify(); + Assert.Equal(1, drift.Code); + Assert.Contains("edited by hand; its generator sets handEdits: Overwrite", drift.Err); + Assert.Contains("to regenerate", drift.Err); + + var gen = Gen(); + Assert.Equal(0, gen.Code); + Assert.Equal(fresh, File.ReadAllText(file)); + Assert.Contains("overwrote hand-edited Telemetry.g.cs", gen.Out + gen.Err); + var again = Gen(); + Assert.DoesNotContain("overwrote", again.Out + again.Err); + Assert.Equal(0, Verify().Code); + } + + [Fact] + public void Overwrite_deletes_an_edited_orphan() + { + Overwriting(); + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var old = Path.Combine(Out, "Telemetry.g.cs"); + File.AppendAllText(old, "// mine\n"); + + Model("acme", "TelemetryReading"); + var drift = Verify(); + Assert.Equal(1, drift.Code); + Assert.Contains("handEdits is Overwrite, so 'dotnet meta gen' deletes it", drift.Err); + Assert.DoesNotContain("will NOT delete them", drift.Err); + + var gen = Gen(); + Assert.Equal(0, gen.Code); + Assert.False(File.Exists(old)); + Assert.Contains("deleted hand-edited gen/Telemetry.g.cs", gen.Out + gen.Err); + Assert.DoesNotContain(Manifest().Keys, k => k.EndsWith("Telemetry.g.cs", StringComparison.Ordinal)); + Assert.Equal(0, Verify().Code); + } + + [Fact] + public void Overwrite_never_replaces_a_file_gen_has_no_record_of_writing() + { + Overwriting(); + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var file = Path.Combine(Out, "Telemetry.g.cs"); + File.WriteAllText(ManifestPath, "{}"); + File.WriteAllText(file, "// not generated\n"); + + Gen(); + Assert.Equal("// not generated\n", File.ReadAllText(file)); + } + + [Fact] + public void One_keep_entry_in_a_shared_directory_protects_its_edited_orphans() + { + _suite = [new PerValue().WithHandEdits(HandEdits.Overwrite), new PerPackageDocs(Out)]; + Model("acme", "Telemetry"); + Assert.Equal(0, Gen().Code); + var old = Path.Combine(Out, "Telemetry.g.cs"); + File.AppendAllText(old, "// mine\n"); + + Model("acme", "TelemetryReading"); + Gen(); + Assert.True(File.Exists(old)); + } +} diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index cb6e00f2f..99dde9d98 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -205,11 +205,16 @@ public static GenOutcome RunGen( /// to the current directory for a caller that names no project (mirrors /// GenConfig.ProjectRoot's own default). /// + /// + /// is the project's WHOLE list, so a recorded file none of + /// them emitted is stale output and is deleted when untouched (). + /// public static GenOutcome RunGen( LoadResult load, string outDir, string ns, bool emitAbstractShapes, IReadOnlyList generators, string? projectRoot = null, string baseline = "default", - bool? includeNames = null) + bool? includeNames = null, + bool sweepOrphans = false) { var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList(); if (loadErrors.Count > 0) return new GenOutcome(loadErrors, null); @@ -235,6 +240,7 @@ public static GenOutcome RunGen( // `is NamesGenerator`, so an OWNED copy of NamesGenerator — a different CLR // type, in a different assembly, once ejected — still satisfies the gate. IncludeNames = includeNames ?? generators.Any(g => g.Name == "names"), + SweepOrphans = sweepOrphans, }; CodegenRunner.RunResult result; @@ -385,9 +391,14 @@ public static IReadOnlyList SelectDeclared( /// , with the run's given to each /// template-spec entry that names none of its own. private static List WithTemplateRoot(IReadOnlyList selected, string templateRoot) => - [.. selected.Select(g => g is TemplateSpecGenerator { TemplateRoot: null } spec - ? new TemplateSpecGenerator { Spec = spec.Spec, TemplateRoot = templateRoot, OutDir = spec.OutDir } - : g)]; + [.. selected.Select(g => g switch + { + TemplateSpecGenerator { TemplateRoot: null } spec => + new TemplateSpecGenerator { Spec = spec.Spec, TemplateRoot = templateRoot, OutDir = spec.OutDir }, + HandEditsGenerator { Inner: TemplateSpecGenerator { TemplateRoot: null } } wrapped => + WithTemplateRoot([wrapped.Inner], templateRoot)[0].WithHandEdits(wrapped.HandEdits), + _ => g, + })]; /// The registry names of the entries in that a registry /// generator builds, for the advisory requires check. @@ -684,7 +695,10 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) // ADR-0023: gen loads leniently; name what `verify` would reject instead of staying silent. WarnUnknownAttrs(meta); var outcome = RunGen(meta.Load(), a.OutDir, a.Namespace, a.EmitAbstractShapes, suite, projectRoot, a.Baseline, - includeNames: declared.Any(g => g.Name == "names")); + includeNames: declared.Any(g => g.Name == "names"), + // Only a run of the whole declared list can tell a stale file from one the + // narrowing left out. + sweepOrphans: a.Selection is not { Count: > 0 }); if (!outcome.Ok) { ReportFailure(outcome, GenCommandName); @@ -693,7 +707,9 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) foreach (var f in outcome.Result!.Files) Console.WriteLine($" {f.Status}: {f.Path}"); foreach (var w in outcome.Result!.Warnings) Console.Error.WriteLine($" warning: {w}"); var writtenCount = outcome.Result!.Files.Count(f => f.Status == "written"); - Console.WriteLine($"{GenCommandName}: {writtenCount} file(s) written"); + var removedCount = outcome.Result!.Files.Count(f => f.Status == "removed"); + Console.WriteLine($"{GenCommandName}: {writtenCount} file(s) written" + + (removedCount > 0 ? $", {removedCount} file(s) no generator emits any more removed" : "")); // A refusal is a FAILED gate, matching TypeScript's `meta gen` (exit 1): an adopter // wiring gen into CI as a drift gate must not be green while codegen refused to write. @@ -847,6 +863,9 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar Namespace = effectiveNs, ColumnNamingStrategy = columnNaming, IncludeNames = declared.Any(g => g.Name == "names"), + // The manifest gen keeps: an extra file is classified by what gen recorded writing. + GenStateDir = Path.Combine(projectRoot, ".metaobjects", ".gen-state"), + ProjectRoot = projectRoot, }; var result = CodegenDrift.Compute(config, load.Root, suite, narrowed: a.Selection is { Count: > 0 }); if (result.Error is not null) @@ -870,7 +889,7 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar Console.Error.WriteLine(NamingDiffers(committed, naming)); return 1; } - Console.Error.WriteLine("Run 'dotnet meta gen' to regenerate, then commit the result."); + foreach (var line in CodegenDrift.Remedy(result)) Console.Error.WriteLine(line); return 1; } } diff --git a/server/csharp/MetaObjects.Codegen/CodegenDrift.cs b/server/csharp/MetaObjects.Codegen/CodegenDrift.cs index 9d135c53e..8e7ebc0b1 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenDrift.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenDrift.cs @@ -65,6 +65,27 @@ public sealed record Result public IReadOnlyList Lines { get; init; } = []; /// Set when the gate could not run (e.g. the regen failed). public string? Error { get; init; } + /// The subset of that no generator emits any more AND that + /// was edited since gen wrote it, sorted. dotnet meta gen deletes an untouched orphan but + /// refuses one of these, so "regenerate" would loop: the remedy is to delete the file. + public IReadOnlyList EditedOrphans { get; init; } = []; + } + + /// + /// What to do about a drifted : "regenerate" when regenerating fixes + /// at least one file, and, for orphans edited by hand, that gen will not delete them. Every + /// drift report prints these, so the advice cannot loop: gen deletes an untouched orphan and + /// refuses an edited one. + /// + public static IReadOnlyList Remedy(Result result) + { + var lines = new List(); + if (result.DriftedFiles.Count > result.EditedOrphans.Count) + lines.Add("Run 'dotnet meta gen' to regenerate, then commit the result."); + if (result.EditedOrphans.Count > 0) + lines.Add("For the file(s) above that no generator emits any more and that were edited by hand, " + + "'dotnet meta gen' will NOT delete them. Delete each one, or move your edits into a file of your own first."); + return lines; } /// @@ -104,18 +125,31 @@ public static Result Compute(GenConfig config, MetaRoot root, IReadOnlyList Path.Combine(tempRoot, i.ToString())).ToList(); - var tempConfig = config with { OutDir = temps[0] }; - CodegenRunner.Run(tempConfig, root, generators, + // No manifest and no sweep for the throwaway regen: it records nothing and deletes + // nothing, and a fresh temp tree writes every file either way. + var tempConfig = config with { OutDir = temps[0], GenStateDir = null, ProjectRoot = null, SweepOrphans = false }; + var regen = CodegenRunner.Run(tempConfig, root, generators, g => temps[roots.FindIndex(r => r.Committed == Path.GetFullPath(realRootFor(g)))]); + var overwriteDirs = CodegenRunner.OverwriteDirs(generators, realRootFor, config.OutDir); var drifted = new SortedSet(StringComparer.Ordinal); var lines = new List(); + var editedOrphans = new SortedSet(StringComparer.Ordinal); + // The manifest gen wrote, when the caller names the project. With write records, an + // extra file is convicted only when gen recorded writing it, the jurisdiction gen's + // orphan sweep deletes in; with none, a stale file cannot be told from a stranger's, + // and every extra file is reported as before. + var manifest = config.GenStateDir is { } gs && config.ProjectRoot is not null + ? HashManifest.Load(gs) : null; + var orphans = manifest is { Count: > 0 } ? new OrphanContext(manifest, config.ProjectRoot!) : null; + var handEdits = new HandEditsContext(regen.OverwritePaths, overwriteDirs); for (var i = 0; i < roots.Count; i++) { // A directory nested in this one is another entry's, and is compared as its own. var nested = roots.Where((r, j) => j != i && IsUnder(r.Committed, roots[i].Committed)) .Select(r => r.Committed).ToList(); - Diff(roots[i].Committed, nested, temps[i], roots[i].Label, !narrowed, drifted, lines); + Diff(roots[i].Committed, nested, temps[i], roots[i].Label, !narrowed, orphans, handEdits, + drifted, lines, editedOrphans); } return new Result @@ -123,6 +157,7 @@ public static Result Compute(GenConfig config, MetaRoot root, IReadOnlyList. private static void Diff( string committed, IReadOnlyList nested, string fresh, string label, bool reportExtra, - SortedSet drifted, List lines) + OrphanContext? orphans, HandEditsContext handEdits, + SortedSet drifted, List lines, SortedSet editedOrphans) { var committedFiles = ListFiles(committed); committedFiles.RemoveWhere(rel => nested.Any(n => IsUnder(Path.Combine(committed, rel), n))); @@ -150,8 +186,36 @@ private static void Diff( if (inCommitted && !inFresh) { if (!reportExtra) continue; - drifted.Add(shown); - lines.Add($"- {shown} (committed but a fresh regen would not emit it)"); + if (orphans is null) + { + drifted.Add(shown); + lines.Add($"- {shown} (committed but a fresh regen would not emit it)"); + continue; + } + // Asked of the same evidence gen's sweep asks (OrphanSweep.Classify), so gen + // removes exactly what is reported here as removable. + var full = Path.Combine(committed, rel); + var dirRel = rel.Replace('/', Path.DirectorySeparatorChar); + switch (OrphanSweep.Classify(orphans.Hashes, orphans.ProjectRoot, full, dirRel)) + { + case OrphanSweep.Verdict.Stranger: + continue; // never ours: gen never touches it, so neither does the gate + case OrphanSweep.Verdict.Pristine: + drifted.Add(shown); + lines.Add($"- {shown} (committed but a fresh regen would not emit it; 'dotnet meta gen' deletes it)"); + break; + case OrphanSweep.Verdict.Edited when handEdits.OverwriteDirs.Contains(Path.GetFullPath(committed)): + drifted.Add(shown); + lines.Add($"- {shown} (no generator emits it any more, and it was edited by hand; " + + "handEdits is Overwrite, so 'dotnet meta gen' deletes it)"); + break; + default: + drifted.Add(shown); + editedOrphans.Add(shown); + lines.Add($"- {shown} (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)"); + break; + } } else if (!inCommitted && inFresh) { @@ -165,12 +229,27 @@ private static void Diff( if (!string.Equals(a, b, StringComparison.Ordinal)) { drifted.Add(shown); - lines.Add($"~ {shown} (committed content differs from a fresh regen)"); + var full = Path.Combine(committed, rel); + var edited = orphans is not null + && handEdits.OverwritePaths.Contains(Path.GetFullPath(Path.Combine(fresh, rel))) + && OrphanSweep.Classify(orphans.Hashes, orphans.ProjectRoot, full, + rel.Replace('/', Path.DirectorySeparatorChar)) == OrphanSweep.Verdict.Edited; + lines.Add(edited + ? $"~ {shown} (edited by hand; its generator sets handEdits: Overwrite, so 'dotnet meta gen' " + + "replaces it with fresh output)" + : $"~ {shown} (committed content differs from a fresh regen)"); } } } } + /// The manifest and project an extra file is classified against. + private sealed record OrphanContext(IReadOnlyDictionary Hashes, string ProjectRoot); + + /// The regen's paths (temp tree) and the committed + /// directories whose edited orphans gen deletes. + private sealed record HandEditsContext(IReadOnlySet OverwritePaths, IReadOnlySet OverwriteDirs); + /// True when lies inside the directory . private static bool IsUnder(string path, string dir) => Path.GetFullPath(path).StartsWith( diff --git a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs index d70014fa0..19b8e0e0f 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs @@ -22,7 +22,12 @@ public static class CodegenRunner public const string GeneratedMarker = ""; public sealed record WriteResult(string Path, string Status); - public sealed record RunResult(IReadOnlyList Files, IReadOnlyList Warnings); + public sealed record RunResult(IReadOnlyList Files, IReadOnlyList Warnings) + { + /// Absolute paths emitted by an entry declaring : + /// verify --codegen reads it to tell that gen replaces an edit to one of them. + public IReadOnlySet OverwritePaths { get; init; } = new HashSet(); + } public static RunResult Run(GenConfig config, MetaRoot root, IReadOnlyList generators) => Run(config, root, generators, OutRootFor(config)); @@ -73,6 +78,7 @@ internal static RunResult Run( var emitted = new Dictionary(StringComparer.Ordinal); var refused = new List(); var adopted = new List(); + var overwritePaths = new HashSet(StringComparer.Ordinal); var untracked = 0; // Captured BEFORE any write, because the first write creates the manifest — // read it afterwards and every project looks migrated. @@ -91,6 +97,7 @@ internal static RunResult Run( throw new InvalidOperationException( $"duplicate generated output path \"{file.Path}\" — emitted by both " + $"\"{emitted[full]}\" and \"{generator.Name}\"."); + if (generator.HandEdits == HandEdits.Overwrite) overwritePaths.Add(full); // The manifest key. Project-root-relative when the caller names a project, // so two runs with different --out cannot collide on one entry inside the // single manifest that is anchored on that project. `file.Path` rides @@ -115,14 +122,37 @@ internal static RunResult Run( if (outside) untracked++; var status = WriteOne( outside ? config with { GenStateDir = null } : config, - full, file.Path, key, file.Content, warnings); + full, file.Path, key, file.Content, warnings, + overwriteEdits: generator.HandEdits == HandEdits.Overwrite); var reported = shown + file.Path; + if (status == OverwroteEdit) + { + warnings.Add( + $"overwrote hand-edited {reported} with fresh output (handEdits: Overwrite on {generator.Name})"); + status = "written"; + } results.Add(new WriteResult(reported, status)); if (status == "refused") refused.Add(reported); else if (status == "adopted") adopted.Add(reported); } } + if (config.SweepOrphans && config.GenStateDir is { } genState && config.ProjectRoot is { } project + && config.Baseline != "adopt") + { + // Every directory a generator of this run wrote into, labelled as its files are + // reported. The run's own --out is always one of them. + var dirs = new List<(string Dir, string Label)> { (config.OutDir, "") }; + foreach (var g in generators) + dirs.Add((outRoot(g), g.OutDir is { Length: > 0 } own ? own.TrimEnd('/', '\\') + "/" : "")); + var swept = OrphanSweep.Sweep(genState, project, dirs, emitted.Keys.ToHashSet(StringComparer.Ordinal), + OverwriteDirs(generators, outRoot, config.OutDir)); + foreach (var path in swept.Removed) results.Add(new WriteResult(path, "removed")); + foreach (var path in swept.Discarded) + warnings.Add($"deleted hand-edited {path}, which no generator emits any more (handEdits: Overwrite)"); + if (swept.Refused.Count > 0) warnings.Add(OrphanSweep.RefusedMessage(swept.Refused)); + } + if (untracked > 0) warnings.Add( $"{untracked} generated file(s) were written outside the project root and are " + @@ -130,7 +160,7 @@ internal static RunResult Run( "does not apply to them beyond the marker rule."); ReportRefusals(refused, hadManifest, warnings); ReportAdoptions(adopted, warnings); - return new RunResult(results, warnings); + return new RunResult(results, warnings) { OverwritePaths = overwritePaths }; } /// @@ -150,7 +180,7 @@ internal static RunResult Run( /// private static string WriteOne( GenConfig config, string full, string relPath, string key, string content, - List warnings) + List warnings, bool overwriteEdits = false) { // The out-dir-relative spelling this file may already be recorded under, from // before keys became project-root-relative. `null` once the two agree, so an @@ -198,6 +228,15 @@ private static string WriteOne( return "written"; } + // Edited since we wrote it, and the entry says to discard hand edits. A file we have + // no record of writing is never ours to replace, whatever the setting. + if (overwriteEdits && HashManifest.IsRecorded(config.GenStateDir, key, legacyKey)) + { + WriteFile(full, content); + HashManifest.Record(config.GenStateDir, key, content, legacyKey); + return OverwroteEdit; + } + // …unless the caller explicitly adopted what is on disk. Placed HERE, at the // refusal, rather than earlier: adopting can then only ever convert a refusal into // a recorded baseline, leaving "unchanged" and the pristine overwrite exactly as @@ -214,6 +253,29 @@ private static string WriteOne( return "refused"; } + /// The internal status of a hand edit replaced under ; + /// reported as written with a warning naming the file. + private const string OverwroteEdit = "overwrote-edit"; + + /// + /// The output directories whose edited orphans the sweep may delete: those every generator of + /// the run writing into them declares . The manifest does not + /// record which generator wrote a file, so one entry that keeps hand edits protects the whole + /// directory. + /// + internal static IReadOnlySet OverwriteDirs( + IReadOnlyList generators, Func outRoot, string runOutDir) + { + var verdict = new Dictionary(StringComparer.Ordinal); + foreach (var g in generators) + { + var dir = Path.GetFullPath(outRoot(g)); + var overwrites = g.HandEdits == HandEdits.Overwrite; + verdict[dir] = verdict.TryGetValue(dir, out var prior) ? prior && overwrites : overwrites; + } + return verdict.Where(kv => kv.Value).Select(kv => kv.Key).ToHashSet(StringComparer.Ordinal); + } + private static void WriteFile(string full, string content) { var dir = Path.GetDirectoryName(full); diff --git a/server/csharp/MetaObjects.Codegen/Generator.cs b/server/csharp/MetaObjects.Codegen/Generator.cs index 5d5658903..fda57f6a3 100644 --- a/server/csharp/MetaObjects.Codegen/Generator.cs +++ b/server/csharp/MetaObjects.Codegen/Generator.cs @@ -138,6 +138,21 @@ public sealed record GenConfig /// 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/HashManifest.cs b/server/csharp/MetaObjects.Codegen/HashManifest.cs index 7b32ef213..ae8c52cf4 100644 --- a/server/csharp/MetaObjects.Codegen/HashManifest.cs +++ b/server/csharp/MetaObjects.Codegen/HashManifest.cs @@ -121,4 +121,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..c046d90b9 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen/OrphanSweep.cs @@ -0,0 +1,160 @@ +// 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; + } + + /// + /// 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); + + foreach (var (dir, label) in dirs.DistinctBy(d => Path.GetFullPath(d.Dir))) + { + var full = Path.GetFullPath(dir); + 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; + 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; + forget.Add(key); + } + } + + if (forget.Count > 0) + { + foreach (var key in forget) hashes.Remove(key); + HashManifest.Save(genStateDir, hashes); + } + // A directory nested in another is walked twice; each file is reported once. + 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/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..b01bb9193 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,12 @@ 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.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; /** @@ -21,20 +25,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,15 +131,79 @@ 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; + + // 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; + + // 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 HashSet<>(); + // 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); + } + + /** + * Name the generator whose writes follow and its {@code handEdits} policy + * ({@link #HAND_EDITS_KEEP} or {@link #HAND_EDITS_OVERWRITE}). Called once per + * generator by the runner, before that generator executes. + */ + public Run attributeTo(String generatorName, String handEdits) { this.current = generatorName; + this.currentHandEdits = handEdits; + return this; + } + + /** + * Turn on manifest-aware writes for this run: {@code genStateDir} is the directory + * holding {@code .hashes.json} (typically {@code /.metaobjects/.gen-state}), + * {@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; return this; } + /** The files written, left in place, or adopted this run — the orphan sweep's + * "do not touch" set. */ + public Set emittedPaths() { + return emittedPaths; + } + + /** 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 claim(Path outFile, String content) { Path key = outFile.toAbsolutePath().normalize(); String previous = contentByPath.get(key); @@ -173,27 +248,128 @@ public static Run currentRun() { return ACTIVE_RUN.get(); } + /** {@link Run#attributeTo(String, String)}'s default: refuse a hand-edited file by name. */ + public static final String HAND_EDITS_KEEP = "keep"; + /** {@link Run#attributeTo(String, String)}: 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); + HashManifest.record(run.genStateDir, key, content); + return Outcome.WRITTEN; + } + + String current = Files.readString(outFile, StandardCharsets.UTF_8); + String recorded = HashManifest.recordedHash(run.genStateDir, 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); + HashManifest.record(run.genStateDir, key, 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. + HashManifest.record(run.genStateDir, key, 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); + HashManifest.record(run.genStateDir, key, 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); + HashManifest.record(run.genStateDir, key, content); + run.overwrittenHandEditedPaths.add(outFile.toString()); + LOG.warn("overwrote hand-edited " + outFile + " (handEdits: overwrite)"); + return Outcome.WRITTEN; + } + 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 +381,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 +398,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 +412,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..ba5081d56 --- /dev/null +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/HashManifest.java @@ -0,0 +1,126 @@ +package com.metaobjects.generator.util; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +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? + */ +public final class HashManifest { + + private HashManifest() {} + + 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. 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) { + Path path = pathFor(genStateDir); + if (!Files.isRegularFile(path)) return new LinkedHashMap<>(); + try { + String json = Files.readString(path, StandardCharsets.UTF_8); + Map parsed = GSON.fromJson(json, new TypeToken>() {}.getType()); + return parsed == null ? new LinkedHashMap<>() : new LinkedHashMap<>(parsed); + } catch (IOException | JsonSyntaxException e) { + return new LinkedHashMap<>(); + } + } + + /** + * Persist the manifest with SORTED keys, pretty-printed, one trailing newline. + * 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) { + try { + Files.createDirectories(genStateDir); + Map ordered = new TreeMap<>(hashes); + String json = GSON.toJson(ordered, new TypeToken>() {}.getType()); + Files.writeString(pathFor(genStateDir), json + "\n", StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Could not write codegen hash manifest at " + genStateDir, e); + } + } + + /** Record that {@code key} was written with {@code content}: load, mutate, save. */ + public static void record(Path genStateDir, String key, String content) { + Map hashes = load(genStateDir); + hashes.put(key, contentHash(content)); + save(genStateDir, hashes); + } + + /** 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..dd3288d4a --- /dev/null +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/OrphanSweep.java @@ -0,0 +1,245 @@ +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.stream.Collectors; +import java.util.stream.Stream; + +/** + * 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}).
  • + *
  • 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; + } + + /** + * Delete the untouched files under {@code dirs} that the manifest records and + * {@code emitted} (normalized absolute paths this run produced, refused hand-edits + * included) does not hold, and forget every recorded path under those directories + * 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) { + if (!HashManifest.exists(genStateDir)) { + return new Result(List.of(), List.of(), List.of()); + } + Map hashes = HashManifest.load(genStateDir); + Set forget = new LinkedHashSet<>(); + List removed = new ArrayList<>(); + List overwrittenDeleted = new ArrayList<>(); + List refused = new ArrayList<>(); + Path root = projectRoot.toAbsolutePath().normalize(); + + // De-dupe by normalized directory — two generators sharing an outputDir supply + // the same Entry twice; only its first (any, since dirs sharing a path carry the + // same allowOverwrite — callers build Entry per unique dir) is needed. + Map byDir = new LinkedHashMap<>(); + for (Entry e : dirs) { + byDir.putIfAbsent(e.dir.toAbsolutePath().normalize(), e); + } + + for (Entry e : byDir.values()) { + Path full = e.dir.toAbsolutePath().normalize(); + String prefix = keyFor(root, full); + if (prefix == null) continue; // nothing outside the project is ours to delete + + if (Files.isDirectory(full)) { + List files; + try (Stream walk = Files.walk(full)) { + files = walk.filter(Files::isRegularFile).collect(Collectors.toList()); + } catch (IOException ex) { + throw new UncheckedIOException("Could not walk generated output dir [" + full + "]", ex); + } + for (Path file : files) { + Path normFile = file.toAbsolutePath().normalize(); + if (emitted.contains(normFile)) continue; + String key = keyFor(root, normFile); + if (key == null || !hashes.containsKey(key)) continue; // stranger — not ours + + String relDisplay = full.relativize(normFile).toString().replace(java.io.File.separatorChar, '/'); + Verdict verdict = classify(hashes, root, normFile); + switch (verdict) { + case PRISTINE: + delete(normFile); + forget.add(key); + removed.add(e.label + relDisplay); + break; + case EDITED: + if (e.allowOverwrite) { + delete(normFile); + forget.add(key); + overwrittenDeleted.add(e.label + relDisplay); + } else { + refused.add(key); + } + break; + case STRANGER: + default: + // Already excluded above by the hashes.containsKey() check. + 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. + String under = prefix.isEmpty() ? "" : prefix + "/"; + for (String key : hashes.keySet()) { + if (!key.startsWith(under)) continue; + Path path = root.resolve(key).normalize(); + if (emitted.contains(path) || Files.exists(path)) continue; + forget.add(key); + } + } + + if (!forget.isEmpty()) { + Map updated = new LinkedHashMap<>(hashes); + for (String key : forget) updated.remove(key); + HashManifest.save(genStateDir, updated); + } + + removed.sort(String::compareTo); + overwrittenDeleted.sort(String::compareTo); + List distinctRefused = refused.stream().distinct().sorted().collect(Collectors.toList()); + return new Result(removed, overwrittenDeleted, distinctRefused); + } + + 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/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java index 46661051e..b30322bb7 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,6 +7,7 @@ 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.assertNull; @@ -243,4 +244,169 @@ 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 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 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); + 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); + 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); + 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..a8fd4d023 --- /dev/null +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/HashManifestTest.java @@ -0,0 +1,89 @@ +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 { + + @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); + + 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"); + HashManifest.record(genState, "src/B.java", "b"); + + 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"); + HashManifest.record(genState, "src/A.java", "a"); + + 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"); + + 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()); + } +} 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..71f7e1879 --- /dev/null +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/OrphanSweepTest.java @@ -0,0 +1,233 @@ +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 { + + @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); + + 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"); + 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); + + 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); + + 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"); + // 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()); + + 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"); + 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()); + + 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"); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of()); + + 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()); + + 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"); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of()); + + 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.record(genStateDir, "genA/A.java", "a"); + HashManifest.record(genStateDir, "genB/B.java", "b"); + + // 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()); + + 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")); + } + + // === 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/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..3921de0df 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) { @@ -52,22 +57,111 @@ 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 — each run only + // touches the keys under its own generators' output dirs) 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); + // 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 — the orphan sweep's jurisdiction + // is scoped to these dirs only, so a sibling writing elsewhere (even + // sharing this same manifest file) is never touched. + Map> handEditsByDir = new LinkedHashMap<>(); + 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; + run.attributeTo( gen.getClass().getSimpleName(), handEdits ); 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); + } + } + } + + if (genStateDir != null) { + sweepOrphans(genStateDir, projectRoot, handEditsByDir, run.emittedPaths()); } + reportManifestNotices(run, genStateDir, hadManifestAtStart); } registerGeneratedSourceRoots(); } + /** + * Delete the files under this execution's output dirs that the manifest records and + * this run did not emit — the files no generator produces any more. Scoped to exactly + * the dirs {@code handEditsByDir} names, so a sibling {@code } writing + * elsewhere is untouched even though it shares this same manifest file. + */ + private void sweepOrphans( + Path genStateDir, Path projectRoot, Map> handEditsByDir, Set emitted) { + List dirs = new ArrayList<>(); + for (Map.Entry> e : handEditsByDir.entrySet()) { + boolean allOverwrite = !e.getValue().isEmpty() && e.getValue().stream() + .allMatch(GeneratorParam.HAND_EDITS_OVERWRITE::equals); + dirs.add(new OrphanSweep.Entry(e.getKey(), e.getKey() + File.separator, allOverwrite)); + } + OrphanSweep.Result swept = OrphanSweep.sweep(genStateDir, projectRoot, dirs, emitted); + 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)); + } + } + + /** 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..f5ab340f7 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; @@ -333,6 +335,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 +352,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 +366,22 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException } } + for (GenTarget t : targets) { + List handEdits = handEditsByOutputDir.get(t.realDir.toAbsolutePath().normalize()); + t.allOverwrite = handEdits != null && !handEdits.isEmpty() + && handEdits.stream().allMatch(GeneratorParam.HAND_EDITS_OVERWRITE::equals); + } + + // 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 { @@ -367,17 +392,32 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException // Compare each output directory's temp tree against its committed tree. List drift = new ArrayList<>(); + List editedOrphans = new ArrayList<>(); for (GenTarget t : targets) { - drift.addAll(compareTrees(t)); + drift.addAll(compareTrees(t, hashes, 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()); } @@ -394,8 +434,19 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException * 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); @@ -417,7 +468,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,9 +573,11 @@ 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 has {@code handEdits=overwrite}. */ + boolean allOverwrite; GenTarget(Path realDir, Path tempDir) { this.realDir = realDir; 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/MetaDataGeneratorMojoManifestTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java new file mode 100644 index 000000000..254aeea9b --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java @@ -0,0 +1,287 @@ +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.loader.MetaDataLoader; +import org.apache.maven.project.MavenProject; +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; + +/** + * {@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 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: deleted under handEdits=overwrite ================== + + @Test + public void handEditedOrphan_overwritePolicy_isDeleted() 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))); + + assertFalse("handEdits=overwrite deletes a hand-edited orphan too", Files.exists(oldFile)); + assertFalse(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")); + } + + // === 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..0fb2dd571 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyMojoManifestTest.java @@ -0,0 +1,232 @@ +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.plugin.MojoFailureException; +import org.apache.maven.project.MavenProject; +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")); + } + } +} diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index dc24416c3..00023da8b 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,6 +125,7 @@ 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 ( @@ -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,6 +1677,74 @@ 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) + ) + 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) + 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)), + ) + 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"} @@ -1749,6 +1857,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). @@ -1846,9 +1968,16 @@ 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)])``.""" + 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 +1995,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 +2004,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,7 +2021,7 @@ 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: @@ -1905,6 +2036,8 @@ def compare(naming: str) -> tuple[list[str], list[tuple[str, int, list[str], lis 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 +2048,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 +2083,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 +2132,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 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/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..8feb873c0 --- /dev/null +++ b/server/python/src/metaobjects/codegen/orphan_sweep.py @@ -0,0 +1,167 @@ +"""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 _walk(out_dir: Path) -> list[Path]: + if not out_dir.is_dir(): + return [] + files: list[Path] = [] + for p in sorted(out_dir.rglob("*")): + if not p.is_file(): + 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(), +) -> 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``. ``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() + + for out in dict.fromkeys(out_dirs): + out_path = Path(out).resolve() + # 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): + 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 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..bca643934 100644 --- a/server/python/src/metaobjects/codegen/overwrite_policy.py +++ b/server/python/src/metaobjects/codegen/overwrite_policy.py @@ -151,6 +151,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 +164,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 +222,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/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..9dc94252a --- /dev/null +++ b/server/python/tests/codegen/test_orphan_sweep.py @@ -0,0 +1,173 @@ +"""``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) 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/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index 45af16392..48b6ee284 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -1674,9 +1674,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..f9dbfda88 100644 --- a/server/typescript/packages/cli/src/lib/codegen-drift.ts +++ b/server/typescript/packages/cli/src/lib/codegen-drift.ts @@ -75,6 +75,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 +202,7 @@ export async function computeCodegenDrift( driftedFiles: [], lines: [], handEditedNames: [], + editedOrphans: [], handEdited: [], docsIgnored: 0, docsIgnoredDirs: [], @@ -254,7 +261,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 +279,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 @@ -334,7 +345,28 @@ export async function computeCodegenDrift( 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 (overwrite?.outDirs.has(fresh) === true) { + // Every generator writing here says handEdits: "overwrite", so gen deletes it. + lines.push( + `- ${relKey} (no generator emits it any more, and it was edited by hand; ` + + `handEdits is "overwrite", 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 +416,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 +440,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/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/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/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/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index ea16d453e..2b4899940 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 diff --git a/server/typescript/packages/codegen-ts/src/orphan-sweep.ts b/server/typescript/packages/codegen-ts/src/orphan-sweep.ts index e03b5a6c3..decc711a3 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 @@ -76,12 +78,12 @@ function toPosix(p: string): string { 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 +100,29 @@ 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 => { + 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)); + }; + // A `keep` policy decides nothing itself; it takes its namespace out of every other + // job's reach, the run-wide jobs included. + const keepJobs = args.jobs.filter((job) => job.policy.keep === true); + const kept = (relPath: string): boolean => keepJobs.some((job) => jobOwns(job, relPath)); 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 +157,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 +185,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..16ec956a8 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"; @@ -483,6 +500,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 +536,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 +641,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 +690,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/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/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/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index ce6eadc2f..22c0d3462 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,10 @@ 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"; + export interface RunGenOpts { config: MetaobjectsGenConfig; metadata: MetaData; @@ -186,6 +191,13 @@ 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, and the output directories where every + * generator writing into them says so (the only places an edited orphan is deleted). + * `verify --codegen` reads it to tell a hand edit `meta gen` keeps from one it replaces. + */ + handEditsOverwrite?: { paths: ReadonlySet; outDirs: ReadonlySet }; } /** @@ -343,7 +355,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 +365,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 +771,25 @@ 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)), + outDirs: new Set([...outDirOverwrites].filter(([, all]) => all).map(([dir]) => dir)), + }); // 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 +806,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 +942,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 +1071,29 @@ 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. + const sweepJobs: OrphanJob[] = + opts.generatorNames === undefined && baseline !== "adopt" + ? [ + ...orphanJobs, + ...[...runOutDirs].map((writeOutDir) => ({ + generatorName: RUN_WIDE_SWEEP, + writeOutDir, + policy: outDirOverwrites.get(writeOutDir) === true + ? { owns: () => true, force: true } + : { owns: () => true }, + })), + ] + : 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 +1136,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 +1160,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( - `Deleted ${result.forced.length} hand-edited generated file(s) because a ` + - `generator's orphanPolicy sets force: ${result.forced.join(", ")}. ` + + `${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 ${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 +1192,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 +1204,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() }; } for (const file of emitted) { @@ -1137,8 +1212,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 +1246,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() }; } /** 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/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/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..252ee6c4c --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/run-wide-orphan-sweep.test.ts @@ -0,0 +1,168 @@ +// 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")); + }); +}); 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..d8143ef81 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-0059) +// 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,18 @@ 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 states one hash-manifest overwrite policy for every port", () => { 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/); + // The hash manifest decides on every port, the JVM included since 8.1.0. + expect(skill).toMatch(/hash manifest\*\*\s+\(`\.metaobjects\/\.gen-state\/\.hashes\.json`\) on every port/); + expect(skill).toMatch(/Java and Kotlin have used it since 8\.1\.0/); + // The habit an 8.0.x JVM adopter has to drop is named, or they keep deleting headers. + expect(skill).toMatch(/Do not carry that habit forward/); }); test("the skill points at the sections that answer the write-once question", () => { diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md new file mode 100644 index 000000000..d25ddf9f2 --- /dev/null +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -0,0 +1,90 @@ +# ADR-0059: 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; each touches only the keys under its own generators' output + directories. Two modules have two manifests. +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. `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 + +- 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). From f6ecf59a2bcebb569a281db5718677d20a91f5a5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 09:23:47 -0400 Subject: [PATCH 02/22] no-mistakes(review): Add JVM manifest owners and fix nested-dir and verify sweeps --- CHANGELOG.md | 8 +- .../migrations/jvm-codegen-hash-manifest.md | 22 +++- docs/features/own-your-codegen.md | 19 +++- .../OrphanSweepTests.cs | 42 +++++++ .../WrappedTemplateSpecTests.cs | 55 +++++++++ .../csharp/MetaObjects.Codegen/CodegenCli.cs | 6 +- .../csharp/MetaObjects.Codegen/OrphanSweep.cs | 13 ++- .../generator/util/GeneratedFileWriter.java | 21 ++-- .../generator/util/HashManifest.java | 88 +++++++++++---- .../generator/util/OrphanSweep.java | 41 +++++-- .../util/GeneratedFileWriterTest.java | 52 ++++++--- .../generator/util/HashManifestTest.java | 38 ++++++- .../generator/util/OrphanSweepTest.java | 104 +++++++++++++++--- .../mojo/AbstractMetaDataMojo.java | 12 ++ .../mojo/MetaDataGeneratorMojo.java | 53 +++++++-- .../metaobjects/mojo/MetaDataVerifyMojo.java | 44 +++++++- .../MetaDataGeneratorMojoManifestTest.java | 99 +++++++++++++++++ .../mojo/MetaDataVerifyMojoManifestTest.java | 31 ++++++ server/python/src/metaobjects/cli.py | 12 +- .../src/metaobjects/codegen/orphan_sweep.py | 17 ++- .../python/tests/codegen/test_orphan_sweep.py | 49 +++++++++ .../packages/cli/src/lib/codegen-drift.ts | 11 +- .../test/codegen-drift-orphan-policy.test.ts | 93 ++++++++++++++++ .../packages/codegen-ts/src/index.ts | 2 +- .../packages/codegen-ts/src/orphan-sweep.ts | 48 +++++--- .../packages/codegen-ts/src/runner.ts | 41 ++++--- .../test/run-wide-orphan-sweep.test.ts | 50 +++++++++ .../codegen-skill-overwrite-claim.test.ts | 7 +- .../ADR-0059-jvm-codegen-hash-manifest.md | 15 ++- 29 files changed, 945 insertions(+), 148 deletions(-) create mode 100644 server/csharp/MetaObjects.Codegen.Tests/WrappedTemplateSpecTests.cs create mode 100644 server/typescript/packages/cli/test/codegen-drift-orphan-policy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 563f426fe..4e58e53a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -864,14 +864,16 @@ generator catalog and the Atlas evaluation are 1.2 work. **Java and Kotlin: codegen decides overwrites from the committed hash manifest (ADR-0059).** **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 and shape as TypeScript, Python and C#. A file that still matches its record is +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; two modules -have two. User templates, api-docs pages and the `META-INF/services` registration are not +executions in one module share the manifest without touching each other's files: each entry +names its owner (`:`), an execution sweeps only its own entries even +in a shared output directory, and one file emitted by two executions fails the goal; two +modules have two. 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). diff --git a/docs/features/migrations/jvm-codegen-hash-manifest.md b/docs/features/migrations/jvm-codegen-hash-manifest.md index bc8bb8bd5..18e8fb5ac 100644 --- a/docs/features/migrations/jvm-codegen-hash-manifest.md +++ b/docs/features/migrations/jvm-codegen-hash-manifest.md @@ -19,8 +19,19 @@ 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. With -it: +It maps each generated file's module-relative path to the SHA-256 of what was written and +to the execution that wrote it, its owner (`:`): + +```json +{ + "target/generated-sources/java/com/acme/CustomerDto.java": { + "hash": "3f1c…", + "owner": "app:codegen" + } +} +``` + +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); @@ -59,6 +70,13 @@ it: 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, each one deletes only the +files it recorded as their owner, so executions that share an output directory never delete +each other's output. A file emitted by two executions in one build fails the goal, naming +both: declare that generator in one execution only. Run a declared execution by id with +`mvn metaobjects:generate@`; a bare `mvn metaobjects:generate` runs as `default-cli` +and records that as the owner. + 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. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 8af877863..31ebd5e7f 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -493,9 +493,10 @@ forbids renaming it, so the body has to stay where it is. **Java and Kotlin decide from the same committed hash manifest (since 8.1.0, ADR-0059).** 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 and shape as the other -ports. Two executions in one module share it, and each touches only the keys under its own -output directories; two modules have two manifests. A file that still hashes to its record is +`/.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 execution +that wrote it, and an execution touches only its own entries, even in an output directory it +shares with another; 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.** @@ -588,8 +589,16 @@ handles those files by one rule: On **Java and Kotlin** every `mvn metaobjects:generate` execution is a full run, since its `` list is always complete. Each execution sweeps only the output directories its -own generators write to, so two executions in one module never delete each other's files. A -refused orphan is a warning there, as every JVM refusal is (ADR-0059). +own generators write to, and in them only the files it recorded: every manifest entry names +its owner, `:`. So two executions in one module never delete each +other's files, even when they share an output directory. One file emitted by two executions +in the same build fails the goal, naming both. An entry with no owner (written before owners +existed, or by another port) is taken by the first execution that emits it, and is never +deleted otherwise. Running the goal from the command line runs it as execution +`default-cli`; run `mvn metaobjects:generate@` to run a declared execution under its own +id. A refused orphan is a warning there, as every JVM refusal is (ADR-0059). +`mvn metaobjects:verify` leaves out a file owned by an execution whose generators it did not +run, since `generate` never deletes it for them. `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 diff --git a/server/csharp/MetaObjects.Codegen.Tests/OrphanSweepTests.cs b/server/csharp/MetaObjects.Codegen.Tests/OrphanSweepTests.cs index 8c3c1b618..91fdd6b2f 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/OrphanSweepTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/OrphanSweepTests.cs @@ -128,6 +128,48 @@ public void A_hand_edited_orphan_is_refused_and_verify_says_delete_it() Assert.DoesNotContain("to regenerate", drift.Err); } + private sealed class OneEdited(string outDir, HandEdits edits) : IGenerator + { + public string Name => "child"; + public string? OutDir => outDir; + public HandEdits HandEdits => edits; + public IEnumerable Generate(GenContext ctx) => + ctx.Root.Objects().Select(o => new EmittedFile($"{o.Name}.md", $"# {o.Name}\n")); + } + + private (int Code, string Out, string Err) NestedRun(HandEdits parent, HandEdits child) + { + var nested = Path.Combine(Out, "docs"); + _suite = [new PerValue().WithHandEdits(parent), new OneEdited(nested, child)]; + Model("acme", "Old"); + Assert.Equal(0, Gen().Code); + var orphan = Path.Combine(nested, "Old.md"); + Assert.True(File.Exists(orphan)); + File.AppendAllText(orphan, "mine\n"); + Model("acme", "New"); + return Gen(); + } + + [Fact] + public void An_edited_orphan_in_a_nested_overwrite_dir_is_judged_once_by_that_dir() + { + var run = NestedRun(HandEdits.Keep, HandEdits.Overwrite); + Assert.Equal(0, run.Code); + Assert.False(File.Exists(Path.Combine(Out, "docs", "Old.md"))); + Assert.Contains("deleted hand-edited gen/docs/Old.md", run.Err); + Assert.DoesNotContain("NOT deleted", run.Err); + } + + [Fact] + public void An_edited_orphan_in_a_nested_keep_dir_is_refused_not_deleted_by_an_overwrite_parent() + { + var run = NestedRun(HandEdits.Overwrite, HandEdits.Keep); + Assert.True(File.Exists(Path.Combine(Out, "docs", "Old.md"))); + Assert.Contains("NOT deleted", run.Err); + Assert.Contains("gen/docs/Old.md", run.Err); + Assert.DoesNotContain("deleted hand-edited", run.Err); + } + [Fact] public void Pages_that_move_package_in_their_own_directory_leave_no_stale_copy() { diff --git a/server/csharp/MetaObjects.Codegen.Tests/WrappedTemplateSpecTests.cs b/server/csharp/MetaObjects.Codegen.Tests/WrappedTemplateSpecTests.cs new file mode 100644 index 000000000..64f7d0bd2 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/WrappedTemplateSpecTests.cs @@ -0,0 +1,55 @@ +using MetaObjects.Codegen.TemplateCodegen; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +[Collection("Console")] +public sealed class WrappedTemplateSpecTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "mo-wrapped-spec-" + Guid.NewGuid().ToString("N")); + private string Meta => Path.Combine(_root, "metaobjects"); + private string Out => Path.Combine(_root, "gen"); + private string Spec => Path.Combine(_root, "template-spec.json"); + + public WrappedTemplateSpecTests() + { + Directory.CreateDirectory(Meta); + File.WriteAllText(Path.Combine(Meta, "meta.acme.json"), + """{ "metadata.root": { "package": "acme", "children": [] } }"""); + } + + public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { } } + + private IReadOnlyList Suite() => + [new TemplateSpecGenerator { Spec = Spec }.WithHandEdits(HandEdits.Overwrite)]; + + private (int Code, string Err) Run(params string[] args) + { + var prevErr = Console.Error; + var (o, e) = (new StringWriter(), new StringWriter()); + var prevOut = Console.Out; + Console.SetOut(o); + Console.SetError(e); + try { return (CodegenCli.Run(args, Suite()), e.ToString()); } + finally { Console.SetOut(prevOut); Console.SetError(prevErr); } + } + + [Fact] + public void A_wrapped_declared_spec_is_not_legacy_and_an_invalid_one_is_validated_by_gen() + { + File.WriteAllText(Spec, "{ not json"); + var gen = Run("gen", Meta, "--out", Out); + Assert.DoesNotContain("1.0 template-spec configuration", gen.Err); + Assert.Equal(1, gen.Code); + Assert.Contains("template spec", gen.Err); + } + + [Fact] + public void A_wrapped_declared_spec_is_not_legacy_and_an_invalid_one_is_validated_by_verify() + { + File.WriteAllText(Spec, "{ not json"); + var verify = Run("verify", "--codegen", Meta, "--out", Out); + Assert.DoesNotContain("1.0 template-spec configuration", verify.Err); + Assert.Contains("template spec", verify.Err); + } +} diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index 99dde9d98..c8eb15437 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -329,15 +329,17 @@ public static string LegacyTemplateSpec(string command, string what) => IReadOnlyList declared, string? templateSpecFlag, string projectRoot) { if (templateSpecFlag is not null) return "--template-spec"; - if (declared.Any(g => g is TemplateSpecGenerator)) return null; + if (declared.Any(g => Unwrapped(g) is TemplateSpecGenerator)) return null; return File.Exists(Path.Combine(projectRoot, TemplateSpecFileName)) ? TemplateSpecFileName : null; } + private static IGenerator Unwrapped(IGenerator g) => g is HandEditsGenerator h ? h.Inner : g; + ///

Read every declared template spec once, so a missing or malformed file is a clean /// error before anything is written. null when they all read. private static string? CheckTemplateSpecs(IReadOnlyList suite) { - foreach (var spec in suite.OfType()) + foreach (var spec in suite.Select(Unwrapped).OfType()) { try { _ = spec.Generators(); } catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or JsonException) diff --git a/server/csharp/MetaObjects.Codegen/OrphanSweep.cs b/server/csharp/MetaObjects.Codegen/OrphanSweep.cs index c046d90b9..737b0617e 100644 --- a/server/csharp/MetaObjects.Codegen/OrphanSweep.cs +++ b/server/csharp/MetaObjects.Codegen/OrphanSweep.cs @@ -71,6 +71,11 @@ public static Verdict Classify( 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 @@ -91,9 +96,12 @@ public static Result Sweep( var forget = new HashSet(StringComparer.Ordinal); var root = Path.GetFullPath(projectRoot); - foreach (var (dir, label) in dirs.DistinctBy(d => Path.GetFullPath(d.Dir))) + 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; @@ -103,6 +111,7 @@ public static Result Sweep( .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)!; @@ -135,6 +144,7 @@ public static Result Sweep( 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); } } @@ -144,7 +154,6 @@ public static Result Sweep( foreach (var key in forget) hashes.Remove(key); HashManifest.Save(genStateDir, hashes); } - // A directory nested in another is walked twice; each file is reported once. return new Result(removed, refused.Distinct().Order(StringComparer.Ordinal).ToList(), discarded.Distinct().Order(StringComparer.Ordinal).ToList()); } 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 b01bb9193..7095909a9 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 @@ -138,6 +138,7 @@ public static final class Run implements AutoCloseable { // by the Mojo, right after beginRun(). private Path genStateDir; private Path projectRoot; + private String owner; // 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 @@ -171,12 +172,14 @@ public Run attributeTo(String generatorName, String handEdits) { * Turn on manifest-aware writes for this run: {@code genStateDir} is the directory * holding {@code .hashes.json} (typically {@code /.metaobjects/.gen-state}), * {@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. + * basedir), and {@code owner} names the execution every path this run emits is + * recorded against. 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) { + public Run configureManifest(Path genStateDir, Path projectRoot, String owner) { this.genStateDir = genStateDir; this.projectRoot = projectRoot; + this.owner = owner; return this; } @@ -319,7 +322,7 @@ public static Outcome write(Path outFile, String content) throws IOException { if (!Files.exists(outFile)) { performWrite(outFile, content, run); - HashManifest.record(run.genStateDir, key, content); + HashManifest.record(run.genStateDir, key, content, run.owner); return Outcome.WRITTEN; } @@ -334,7 +337,7 @@ public static Outcome write(Path outFile, String content) throws IOException { // replace under any policy. if (looksGenerated(current)) { performWrite(outFile, content, run); - HashManifest.record(run.genStateDir, key, content); + HashManifest.record(run.genStateDir, key, content, run.owner); run.adoptedPaths.add(outFile.toString()); return Outcome.WRITTEN; } @@ -345,25 +348,27 @@ public static Outcome write(Path outFile, String content) throws IOException { 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. - HashManifest.record(run.genStateDir, key, content); + run.claim(outFile, content); + HashManifest.record(run.genStateDir, key, content, run.owner); 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); - HashManifest.record(run.genStateDir, key, content); + HashManifest.record(run.genStateDir, key, content, run.owner); 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); - HashManifest.record(run.genStateDir, key, content); + HashManifest.record(run.genStateDir, key, content, run.owner); run.overwrittenHandEditedPaths.add(outFile.toString()); LOG.warn("overwrote hand-edited " + outFile + " (handEdits: overwrite)"); return Outcome.WRITTEN; } + HashManifest.recordHash(run.genStateDir, key, recorded, run.owner); LOG.warn(refusedHandEditedMessage(outFile)); return Outcome.REFUSED; } 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 index ba5081d56..6cf63f66f 100644 --- 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 @@ -2,8 +2,10 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonSyntaxException; -import com.google.gson.reflect.TypeToken; +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; @@ -33,6 +35,13 @@ * 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 OWNER, the Maven execution that emitted + * the file ({@code :}): {@code "": { "hash": "", "owner": "" }}. Two {@code }s of one module share this file, + * and may share an output directory, so the owner is what keeps one execution's orphan + * sweep off the other's files. An entry in the plain {@code "": ""} form, + * the form the other ports write, reads as recorded with no owner. */ public final class HashManifest { @@ -73,43 +82,84 @@ public static boolean exists(Path genStateDir) { } /** - * Load the manifest. An absent or corrupt one reads as EMPTY, which fails closed — - * every path then reads as unrecorded rather than being guessed at. + * 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) { + return read(genStateDir, "hash"); + } + + /** Load the owner of every entry that names one; an entry with no owner is absent. */ + public static Map loadOwners(Path genStateDir) { + return read(genStateDir, "owner"); + } + + private static Map read(Path genStateDir, String field) { + Map out = new LinkedHashMap<>(); Path path = pathFor(genStateDir); - if (!Files.isRegularFile(path)) return new LinkedHashMap<>(); + if (!Files.isRegularFile(path)) return out; + JsonElement parsed; try { - String json = Files.readString(path, StandardCharsets.UTF_8); - Map parsed = GSON.fromJson(json, new TypeToken>() {}.getType()); - return parsed == null ? new LinkedHashMap<>() : new LinkedHashMap<>(parsed); - } catch (IOException | JsonSyntaxException e) { - return new LinkedHashMap<>(); + 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()) { + JsonElement value = e.getValue(); + if ("hash".equals(field) && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + out.put(e.getKey(), value.getAsString()); + } else if (value.isJsonObject()) { + JsonElement v = value.getAsJsonObject().get(field); + if (v != null && v.isJsonPrimitive() && v.getAsJsonPrimitive().isString()) { + out.put(e.getKey(), v.getAsString()); + } + } + } + return out; } /** - * Persist the manifest with SORTED keys, pretty-printed, one trailing newline. + * Persist the manifest with SORTED keys, pretty-printed, one trailing newline. A key + * with an owner is written as {@code { "hash", "owner" }}, 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) { + public static void save(Path genStateDir, Map hashes, Map owners) { try { Files.createDirectories(genStateDir); - Map ordered = new TreeMap<>(hashes); - String json = GSON.toJson(ordered, new TypeToken>() {}.getType()); - Files.writeString(pathFor(genStateDir), json + "\n", StandardCharsets.UTF_8); + JsonObject root = new JsonObject(); + for (Map.Entry e : new TreeMap<>(hashes).entrySet()) { + String 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("owner", owner); + 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 key} was written with {@code content}: load, mutate, save. */ - public static void record(Path genStateDir, String key, String content) { + /** Record that {@code owner} wrote {@code key} with {@code content}: load, mutate, save. */ + public static void record(Path genStateDir, String key, String content, String 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, String owner) { Map hashes = load(genStateDir); - hashes.put(key, contentHash(content)); - save(genStateDir, hashes); + 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. */ 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 index dd3288d4a..8cdb5092b 100644 --- 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 @@ -26,6 +26,12 @@ * 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 a candidate must also be OWNED by the sweeping execution: two + * {@code }s of one module share the manifest and may share an output + * directory, so a file another execution recorded, or one recorded with no owner + * at all, is never this execution's orphan.
  • + *
  • 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.
  • *
  • 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 @@ -125,18 +131,20 @@ public static Verdict classify(Map hashes, Path projectRoot, Pat } /** - * Delete the untouched files under {@code dirs} that the manifest records and - * {@code emitted} (normalized absolute paths this run produced, refused hand-edits - * included) does not hold, and forget every recorded path under those directories - * 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. + * Delete the untouched files under {@code dirs} that the manifest records as written + * by {@code owner} and {@code emitted} (normalized absolute paths this run produced, + * refused hand-edits included) does not hold, and forget every path recorded for + * {@code owner} under those directories 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) { + public static Result sweep(Path genStateDir, Path projectRoot, List dirs, Set emitted, + String owner) { 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<>(); @@ -155,6 +163,9 @@ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, Path full = e.dir.toAbsolutePath().normalize(); String prefix = keyFor(root, full); if (prefix == null) continue; // nothing outside the project is ours to delete + List nested = byDir.keySet().stream() + .filter(d -> !d.equals(full) && d.startsWith(full)) + .collect(Collectors.toList()); if (Files.isDirectory(full)) { List files; @@ -165,9 +176,10 @@ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, } for (Path file : files) { Path normFile = file.toAbsolutePath().normalize(); - if (emitted.contains(normFile)) continue; + if (emitted.contains(normFile) || isUnder(normFile, nested)) continue; String key = keyFor(root, normFile); if (key == null || !hashes.containsKey(key)) continue; // stranger — not ours + if (!owner.equals(owners.get(key))) continue; // another execution's, or unowned String relDisplay = full.relativize(normFile).toString().replace(java.io.File.separatorChar, '/'); Verdict verdict = classify(hashes, root, normFile); @@ -198,9 +210,9 @@ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, // stranger's file created later under that name is not mistaken for ours. String under = prefix.isEmpty() ? "" : prefix + "/"; for (String key : hashes.keySet()) { - if (!key.startsWith(under)) continue; + if (!key.startsWith(under) || !owner.equals(owners.get(key))) continue; Path path = root.resolve(key).normalize(); - if (emitted.contains(path) || Files.exists(path)) continue; + if (emitted.contains(path) || isUnder(path, nested) || Files.exists(path)) continue; forget.add(key); } } @@ -208,7 +220,7 @@ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, if (!forget.isEmpty()) { Map updated = new LinkedHashMap<>(hashes); for (String key : forget) updated.remove(key); - HashManifest.save(genStateDir, updated); + HashManifest.save(genStateDir, updated, owners); } removed.sort(String::compareTo); @@ -217,6 +229,13 @@ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, return new Result(removed, overwrittenDeleted, distinctRefused); } + private static boolean isUnder(Path path, List dirs) { + for (Path d : dirs) { + if (path.startsWith(d)) return true; + } + return false; + } + private static void delete(Path path) { try { Files.delete(path); 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 b30322bb7..ec9d05829 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 @@ -254,7 +254,7 @@ public void absentFile_writesAndRecords() throws Exception { Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, GENERATED_BODY)); } assertEquals(GENERATED_BODY, Files.readString(out)); @@ -270,7 +270,7 @@ public void firstRunAdoption_markerFilePresentNoManifest_isAdoptedNotRefused() t Files.writeString(out, GENERATED_BODY); // pre-existing, never recorded try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, GENERATED_BODY)); assertEquals(List.of(out.toString()), run.adoptedPaths()); } @@ -287,7 +287,7 @@ public void firstRunAdoption_noMarkerNoManifest_isRefusedNotAdopted() throws Exc Files.writeString(out, mine); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, GENERATED_BODY)); assertTrue(run.adoptedPaths().isEmpty()); } @@ -302,12 +302,12 @@ public void pristineRecordedFile_isOverwrittenAndReRecorded() throws Exception { Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); 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); + run.configureManifest(genState, project, "test"); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, fresh)); } assertEquals(fresh, Files.readString(out)); @@ -321,15 +321,41 @@ public void unchangedContent_isReaffirmedNotRewritten() throws Exception { Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); GeneratedFileWriter.write(out, GENERATED_BODY); } try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); 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, "test"); + GeneratedFileWriter.write(out, GENERATED_BODY); + } + try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { + run.configureManifest(genState, project, "test"); + 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(); @@ -337,7 +363,7 @@ public void editedRecordedFile_handEditsKeep_isRefusedByNameAndStaysRecorded() t Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); GeneratedFileWriter.write(out, GENERATED_BODY); } String edited = GENERATED_BODY + "// mine\n"; @@ -345,7 +371,7 @@ public void editedRecordedFile_handEditsKeep_isRefusedByNameAndStaysRecorded() t String fresh = "/** GENERATED — REST controller for Council. */\npublic class X { int z; }\n"; try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_KEEP); assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, fresh)); } @@ -361,14 +387,14 @@ public void editedRecordedFile_handEditsOverwrite_isReplacedAndReBaselined() thr Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); 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.configureManifest(genState, project, "test"); run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_OVERWRITE); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, fresh)); assertEquals(List.of(out.toString()), run.overwrittenHandEditedPaths()); @@ -389,7 +415,7 @@ public void strangerFile_isNeverTouchedByOverwritePolicy() throws Exception { Files.writeString(out, mine); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_OVERWRITE); assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, GENERATED_BODY)); } @@ -403,7 +429,7 @@ public void untrackedPathOutsideProjectRoot_fallsBackToMarkerOnlyRule() throws E Path outside = tmp.newFolder().toPath().resolve("X.java"); // sibling, not under project try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project); + run.configureManifest(genState, project, "test"); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(outside, GENERATED_BODY)); assertEquals(1, run.untrackedCount()); } 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 index a8fd4d023..8a0df3836 100644 --- 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 @@ -16,6 +16,8 @@ /** The committed hash manifest: shape, merge semantics, fail-closed reads. */ public class HashManifestTest { + private static final String OWNER = "app:generate"; + @Rule public TemporaryFolder tmp = new TemporaryFolder(); @@ -31,7 +33,7 @@ public void absentManifestDoesNotExistAndLoadsEmpty() throws Exception { 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); + HashManifest.record(genState, "src/X.java", content, OWNER); assertTrue(HashManifest.exists(genState)); assertEquals(HashManifest.contentHash(content), HashManifest.recordedHash(genState, "src/X.java")); @@ -40,8 +42,8 @@ public void recordThenExistsThenRecordedHash() throws Exception { @Test public void recordMergesRatherThanOverwritingOtherKeys() throws Exception { Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); - HashManifest.record(genState, "src/A.java", "a"); - HashManifest.record(genState, "src/B.java", "b"); + 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()); @@ -52,8 +54,8 @@ public void recordMergesRatherThanOverwritingOtherKeys() throws Exception { @Test public void savedFileIsSortedPrettyAndTrailingNewline() throws Exception { Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); - HashManifest.record(genState, "src/Z.java", "z"); - HashManifest.record(genState, "src/A.java", "a"); + 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")); @@ -72,7 +74,7 @@ public void isPristineFailsClosedWhenNeverRecorded() throws Exception { @Test public void isPristineTrueOnlyForExactRecordedContent() throws Exception { Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state"); - HashManifest.record(genState, "src/X.java", "original"); + HashManifest.record(genState, "src/X.java", "original", OWNER); assertTrue(HashManifest.isPristine(genState, "src/X.java", "original")); assertFalse(HashManifest.isPristine(genState, "src/X.java", "edited")); @@ -86,4 +88,28 @@ public void contentHashIsStableAndLowercaseHex() { assertEquals(64, h1.length()); assertEquals(h1, h1.toLowerCase()); } + + @Test + public void ownedEntryIsWrittenWithItsOwner_plainEntryReadsAsUnowned() 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. + Files.writeString(genState.resolve(".hashes.json"), "{\n \"src/Plain.java\": \"abc\"\n}\n"); + + HashManifest.record(genState, "src/Owned.java", "owned", OWNER); + + Map hashes = HashManifest.load(genState); + assertEquals("abc", hashes.get("src/Plain.java")); + assertEquals(HashManifest.contentHash("owned"), hashes.get("src/Owned.java")); + Map owners = HashManifest.loadOwners(genState); + assertEquals(OWNER, owners.get("src/Owned.java")); + assertFalse(owners.containsKey("src/Plain.java")); + + 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()); + com.google.gson.JsonObject owned = raw.getAsJsonObject("src/Owned.java"); + assertEquals(HashManifest.contentHash("owned"), owned.get("hash").getAsString()); + assertEquals(OWNER, owned.get("owner").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 index 71f7e1879..a5ea25a6c 100644 --- 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 @@ -18,6 +18,8 @@ /** The orphan sweep: classify, delete, refuse, forget — against a real manifest and tree. */ public class OrphanSweepTest { + private static final String OWNER = "app:generate"; + @Rule public TemporaryFolder tmp = new TemporaryFolder(); @@ -53,7 +55,7 @@ public void classifyPristineWhenContentMatchesRecordedHash() throws Exception { Path file = outDir.resolve("Telemetry.java"); String content = "/** GENERATED */\npublic class Telemetry {}\n"; Files.writeString(file, content); - HashManifest.record(genStateDir, "gen/Telemetry.java", content); + HashManifest.record(genStateDir, "gen/Telemetry.java", content, OWNER); assertEquals(OrphanSweep.Verdict.PRISTINE, OrphanSweep.classify(HashManifest.load(genStateDir), projectRoot, file)); @@ -63,7 +65,7 @@ public void classifyPristineWhenContentMatchesRecordedHash() throws Exception { public void classifyEditedWhenContentDiffersFromRecordedHash() throws Exception { setUpProject(); Path file = outDir.resolve("Telemetry.java"); - HashManifest.record(genStateDir, "gen/Telemetry.java", "/** GENERATED */\npublic class Telemetry {}\n"); + 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, @@ -85,14 +87,14 @@ public void renameCase_pristineOrphanIsDeletedAndForgotten() throws Exception { String oldContent = "/** GENERATED */\npublic class Telemetry {}\n"; Path oldFile = outDir.resolve("Telemetry.java"); Files.writeString(oldFile, oldContent); - HashManifest.record(genStateDir, "gen/Telemetry.java", 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), emitted, OWNER); assertFalse("old file should be deleted", Files.exists(oldFile)); assertTrue(result.removed.contains("gen/Telemetry.java")); @@ -108,12 +110,12 @@ 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"); + 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()); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), OWNER); assertTrue("edited orphan must be left in place", Files.exists(oldFile)); assertTrue(result.refused.contains("gen/Telemetry.java")); @@ -128,11 +130,11 @@ 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"); + 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()); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), OWNER); assertFalse("overwrite policy deletes a hand-edited orphan", Files.exists(oldFile)); assertTrue(result.overwrittenDeleted.contains("gen/Telemetry.java")); @@ -148,10 +150,10 @@ public void strangerFile_isNeverTouched() throws Exception { 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"); + 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()); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), OWNER); assertTrue(Files.exists(stray)); assertFalse(result.removed.contains("gen/HandWritten.java")); @@ -165,7 +167,7 @@ public void noManifestAtAll_sweepIsANoOp() throws Exception { 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()); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), OWNER); assertTrue(result.removed.isEmpty()); assertTrue(result.refused.isEmpty()); @@ -178,10 +180,10 @@ public void noManifestAtAll_sweepIsANoOp() throws Exception { @Test public void alreadyGoneRecordedFile_isForgotten() throws Exception { setUpProject(); - HashManifest.record(genStateDir, "gen/Deleted.java", "irrelevant"); + 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()); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), OWNER); assertFalse(HashManifest.load(genStateDir).containsKey("gen/Deleted.java")); } @@ -200,19 +202,89 @@ public void twoDirectories_neitherSweepsTheOthersFiles() throws Exception { Path fileB = dirB.resolve("B.java"); Files.writeString(fileA, "a"); Files.writeString(fileB, "b"); - HashManifest.record(genStateDir, "genA/A.java", "a"); - HashManifest.record(genStateDir, "genB/B.java", "b"); + HashManifest.record(genStateDir, "genA/A.java", "a", OWNER); + HashManifest.record(genStateDir, "genB/B.java", "b", OWNER); // 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()); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entryA), Set.of(), OWNER); 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: only the sweeping execution's own entries are candidates ===== + + @Test + public void sharedDirectory_otherOwnersAndUnownedEntries_areNeverCandidates() throws Exception { + setUpProject(); + Path mine = outDir.resolve("Mine.java"); + Path theirs = outDir.resolve("Theirs.java"); + Path unowned = outDir.resolve("Unowned.java"); + Files.writeString(mine, "m"); + Files.writeString(theirs, "t"); + Files.writeString(unowned, "u"); + HashManifest.record(genStateDir, "gen/Mine.java", "m", OWNER); + HashManifest.record(genStateDir, "gen/Theirs.java", "t", "app:other"); + HashManifest.record(genStateDir, "gen/Unowned.java", "u", null); + HashManifest.record(genStateDir, "gen/TheirsGone.java", "g", "app:other"); + + OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", true); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), OWNER); + + assertEquals(List.of("gen/Mine.java"), result.removed); + assertFalse(Files.exists(mine)); + assertTrue(Files.exists(theirs)); + assertTrue(Files.exists(unowned)); + assertTrue(HashManifest.load(genStateDir).containsKey("gen/Theirs.java")); + assertTrue(HashManifest.load(genStateDir).containsKey("gen/Unowned.java")); + assertTrue("another execution's gone file is that execution's to forget", + HashManifest.load(genStateDir).containsKey("gen/TheirsGone.java")); + assertEquals("app:other", HashManifest.loadOwners(genStateDir).get("gen/Theirs.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(), OWNER); + + 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(), OWNER); + + assertTrue(Files.exists(orphan)); + assertEquals(List.of("gen/inner/Old.java"), result.refused); + assertTrue(result.overwrittenDeleted.isEmpty()); + } + // === messages =============================================================== @Test 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..74df45a5d 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 @@ -57,6 +57,18 @@ public abstract class AbstractMetaDataMojo extends AbstractMojo @Parameter( defaultValue = "${mojoExecution}", readonly = true, required = true ) protected MojoExecution execution; + /** + * The owner a codegen hash manifest entry is recorded against: the module's + * artifactId and the {@code }. {@code default} when no + * execution is injected (a unit test, an embedder). + */ + protected String ownerId() { + String executionId = execution != null && execution.getExecutionId() != null + ? execution.getExecutionId() : "default"; + String module = project != null ? project.getArtifactId() : null; + return module != null ? module + ":" + executionId : executionId; + } + @Parameter(name="loader") private LoaderParam loaderConfig = null; public LoaderParam getLoader() { 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 3921de0df..88405b3a4 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 @@ -58,9 +58,9 @@ protected void executeGenerators(MetaDataLoader loader, List generato } // Where this EXECUTION's codegen hash manifest lives: anchored on the module - // basedir, so two executions in one module share it (merged — each run only - // touches the keys under its own generators' output dirs) and two modules never - // collide (each has its own basedir). `null` in a unit test that sets no project + // basedir, so two executions in one module share it (merged — every entry names + // the execution that emitted it, and a sweep only touches its own) 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 @@ -77,14 +77,16 @@ protected void executeGenerators(MetaDataLoader loader, List generato // 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()) { + String owner = ownerId(); if (genStateDir != null) { - run.configureManifest(genStateDir, projectRoot); + run.configureManifest(genStateDir, projectRoot, owner); } // Every output dir THIS EXECUTION's generators write to, with the handEdits // policy of every generator that writes there — the orphan sweep's jurisdiction - // is scoped to these dirs only, so a sibling writing elsewhere (even - // sharing this same manifest file) is never touched. + // is scoped to these dirs, and within them to the files this execution owns, so + // a sibling sharing the manifest, or even an output dir, is never + // touched. Map> handEditsByDir = new LinkedHashMap<>(); List params = getGenerators(); @@ -105,8 +107,9 @@ protected void executeGenerators(MetaDataLoader loader, List generato } } + refuseSharedPaths(run.emittedPaths(), owner); if (genStateDir != null) { - sweepOrphans(genStateDir, projectRoot, handEditsByDir, run.emittedPaths()); + sweepOrphans(genStateDir, projectRoot, handEditsByDir, run.emittedPaths(), owner); } reportManifestNotices(run, genStateDir, hadManifestAtStart); } @@ -120,15 +123,15 @@ protected void executeGenerators(MetaDataLoader loader, List generato * the dirs {@code handEditsByDir} names, so a sibling {@code } writing * elsewhere is untouched even though it shares this same manifest file. */ - private void sweepOrphans( - Path genStateDir, Path projectRoot, Map> handEditsByDir, Set emitted) { + private void sweepOrphans(Path genStateDir, Path projectRoot, Map> handEditsByDir, + Set emitted, String owner) { List dirs = new ArrayList<>(); for (Map.Entry> e : handEditsByDir.entrySet()) { boolean allOverwrite = !e.getValue().isEmpty() && e.getValue().stream() .allMatch(GeneratorParam.HAND_EDITS_OVERWRITE::equals); dirs.add(new OrphanSweep.Entry(e.getKey(), e.getKey() + File.separator, allOverwrite)); } - OrphanSweep.Result swept = OrphanSweep.sweep(genStateDir, projectRoot, dirs, emitted); + OrphanSweep.Result swept = OrphanSweep.sweep(genStateDir, projectRoot, dirs, emitted, owner); for (String path : swept.removed) { getLog().info("MetaData Mojo > Removed orphaned generated file no longer produced: " + path); } @@ -140,6 +143,36 @@ private void sweepOrphans( } } + /** + * Fail the goal when a path this execution emitted was already emitted by another + * execution of this module in the same build: the two would overwrite each other, and + * the manifest can record only one owner. The record lives in the project's build + * context, so it spans this build's executions and nothing else. + */ + private void refuseSharedPaths(Set emitted, String owner) { + if (project == null) return; + @SuppressWarnings("unchecked") + Map emittedBy = (Map) project.getContextValue(EMITTED_BY_CONTEXT_KEY); + if (emittedBy == null) { + emittedBy = new HashMap<>(); + project.setContextValue(EMITTED_BY_CONTEXT_KEY, emittedBy); + } + List shared = new ArrayList<>(); + for (Path path : new TreeSet<>(emitted)) { + String previous = emittedBy.putIfAbsent(path, owner); + if (previous != null && !previous.equals(owner)) { + shared.add(path + " (executions '" + previous + "' and '" + owner + "')"); + } + } + if (!shared.isEmpty()) { + throw new MetaDataException("Two executions of " + OWNER + " in this module emitted the same " + + "file, so each would overwrite the other's output: " + String.join(", ", shared) + + ". Declare the generator that emits it in only one ."); + } + } + + 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) { 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 f5ab340f7..8a41b22c6 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 @@ -28,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; @@ -367,9 +368,13 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException } for (GenTarget t : targets) { - List handEdits = handEditsByOutputDir.get(t.realDir.toAbsolutePath().normalize()); + 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); + 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 @@ -391,10 +396,12 @@ 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, hashes, projectRoot, editedOrphans)); + drift.addAll(compareTrees(t, ownHashes, projectRoot, editedOrphans)); } if (!drift.isEmpty()) { @@ -430,6 +437,31 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException failOnRequirementErrors(requirementErrors); } + /** + * The manifest entries {@code generate} would sweep for the generators this verify + * runs: those owned by an execution that recorded one of the files just regenerated, + * or by this execution itself. A file another execution owns, or one recorded with no + * owner, is never an orphan to {@code generate}, so it is left out and reads as a + * stranger here too. + */ + private Map ownedBy(Map hashes, Map owners, + List targets, Path projectRoot) throws MojoExecutionException { + Set runOwners = new HashSet<>(); + runOwners.add(ownerId()); + for (GenTarget t : targets) { + for (String rel : relativeFiles(t.tempDir)) { + String key = OrphanSweep.keyFor(projectRoot, t.realDir.resolve(rel)); + String owner = key != null ? owners.get(key) : null; + if (owner != null) runOwners.add(owner); + } + } + Map owned = new LinkedHashMap<>(); + for (Map.Entry e : hashes.entrySet()) { + if (runOwners.contains(owners.get(e.getKey()))) owned.put(e.getKey(), e.getValue()); + } + return owned; + } + /** * Compare one output directory's freshly-generated temp tree — the union of every * generator configured to write there — against its committed tree. @@ -460,6 +492,7 @@ List compareTrees(GenTarget t, Map hashes, Path projectR 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) { @@ -578,10 +611,17 @@ static final class GenTarget { final Path tempDir; /** True iff every generator writing into {@code realDir} this run 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/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java index 254aeea9b..6d99c103a 100644 --- 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 @@ -4,7 +4,10 @@ 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.plugin.MojoExecution; +import org.apache.maven.plugin.descriptor.MojoDescriptor; import org.apache.maven.project.MavenProject; import org.junit.Before; import org.junit.Rule; @@ -21,6 +24,7 @@ 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 @@ -201,6 +205,101 @@ public void twoExecutionsInOneModule_shareManifest_neitherSweepsTheOther() throw assertTrue(manifest().containsKey("genB/B.java")); } + /** 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)); + } + + /** A fresh build of this module: a new project instance, so a new build context. */ + private MavenProject newBuild() { + MavenProject build = new MavenProject(); + build.setArtifactId("app"); + build.setFile(projectRoot.resolve("pom.xml").toFile()); + return build; + } + + @Test + public void twoExecutionsSharingOneOutputDir_neitherSweepsTheOther_acrossBuilds() throws IOException { + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + Path dto = Path.of(shared, "Dto.java"); + Path controller = Path.of(shared, "Controller.java"); + Path repository = Path.of(shared, "Repository.java"); + + for (int build = 1; build <= 2; build++) { + MavenProject module = newBuild(); + runExecution(module, "codegen-web", + param(shared, "Dto.java", MARKER_BODY, null), + param(shared, "Controller.java", MARKER_BODY, null)); + runExecution(module, "codegen-data", + param(shared, "Repository.java", MARKER_BODY, null)); + + assertTrue("build " + build + ": the first execution's files survive the second's sweep", + Files.exists(dto) && Files.exists(controller)); + assertTrue("build " + build, Files.exists(repository)); + } + Map owners = HashManifest.loadOwners(projectRoot.resolve(".metaobjects").resolve(".gen-state")); + assertEquals("app:codegen-web", owners.get("generated-sources/Dto.java")); + assertEquals("app:codegen-web", owners.get("generated-sources/Controller.java")); + assertEquals("app:codegen-data", owners.get("generated-sources/Repository.java")); + } + + @Test + public void sharedOutputDir_anExecutionStillSweepsItsOwnOrphans() throws IOException { + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + MavenProject first = newBuild(); + runExecution(first, "codegen-web", param(shared, "Telemetry.java", MARKER_BODY, null)); + runExecution(first, "codegen-data", param(shared, "Repository.java", MARKER_BODY, null)); + + MavenProject second = newBuild(); + runExecution(second, "codegen-web", param(shared, "TelemetryReading.java", MARKER_BODY, null)); + runExecution(second, "codegen-data", param(shared, "Repository.java", MARKER_BODY, null)); + + assertFalse(Files.exists(Path.of(shared, "Telemetry.java"))); + assertTrue(Files.exists(Path.of(shared, "TelemetryReading.java"))); + assertTrue(Files.exists(Path.of(shared, "Repository.java"))); + } + + @Test + public void onePathEmittedByTwoExecutions_isAnErrorNamingBoth() throws IOException { + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + MavenProject module = newBuild(); + runExecution(module, "codegen-web", param(shared, "Dto.java", MARKER_BODY, null)); + try { + runExecution(module, "codegen-data", param(shared, "Dto.java", MARKER_BODY, null)); + fail("expected the second execution to be refused"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("Dto.java") + && e.getMessage().contains("app:codegen-web") && e.getMessage().contains("app:codegen-data")); + } + } + + @Test + public void manifestWithoutOwners_isAdoptedByTheEmittingExecution_andNothingIsDeleted() throws IOException { + String outDir = tempFolder.newFolder("gen").getAbsolutePath(); + Path kept = Path.of(outDir, "Kept.java"); + Path old = Path.of(outDir, "Old.java"); + Files.writeString(kept, MARKER_BODY); + Files.writeString(old, MARKER_BODY); + Path genState = projectRoot.resolve(".metaobjects").resolve(".gen-state"); + Files.createDirectories(genState); + String hash = HashManifest.contentHash(MARKER_BODY); + Files.writeString(genState.resolve(".hashes.json"), + "{\n \"gen/Kept.java\": \"" + hash + "\",\n \"gen/Old.java\": \"" + hash + "\"\n}\n"); + + runExecution(newBuild(), "codegen", param(outDir, "Kept.java", MARKER_BODY, null)); + + assertTrue("nothing recorded without an owner is deleted", Files.exists(old)); + assertTrue(Files.exists(kept)); + Map owners = HashManifest.loadOwners(genState); + assertEquals("app:codegen", owners.get("gen/Kept.java")); + assertFalse(owners.containsKey("gen/Old.java")); + } + // === multi-module: two basedirs, two manifests =============================== @Test 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 index 0fb2dd571..ed0091c32 100644 --- 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 @@ -4,7 +4,9 @@ import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.util.GeneratedFileWriter; import com.metaobjects.loader.MetaDataLoader; +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.junit.Before; import org.junit.Rule; @@ -229,4 +231,33 @@ public void verifyFailureMessage_allEditedOrphans_suppressesRegenerateLeadIn() t assertTrue(msg.contains("will NOT delete them")); } } + + /** + * Two executions share one output dir; a verify that regenerates only the first + * execution's generators must not report the second's files as stale, because + * {@code generate} never deletes another execution's file. + */ + @Test + public void sharedOutputDir_anotherExecutionsFiles_areNotDrift() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); + MavenProject module = new MavenProject(); + module.setArtifactId("app"); + module.setFile(basedir.resolve("pom.xml").toFile()); + for (String[] exec : new String[][] {{"codegen-web", "Dto.java"}, {"codegen-data", "Repository.java"}}) { + MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); + gen.setLoader(new LoaderParam()); + gen.project = module; + gen.execution = new MojoExecution(new MojoDescriptor(), exec[0]); + gen.setGenerators(List.of(param(shared, exec[1], MARKER_BODY, null))); + gen.executeGenerators(null, gen.buildGenerators(getClass().getClassLoader(), null)); + } + + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + verify.project = mavenProject(basedir); + verify.setLoader(testLoader()); + verify.setGenerators(List.of(param(shared, "Dto.java", MARKER_BODY, null))); + + verify.execute(); + } } diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 00023da8b..2852faf7b 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -1751,7 +1751,7 @@ def _overwrites_hand_edits(config: ProjectConfig, out_dir: str) -> bool: _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 @@ -1768,7 +1768,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): @@ -2026,10 +2026,12 @@ def compare(naming: str) -> tuple[list[str], list[_DirReport]]: 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)) + inside = [o for o in real_outdirs if o != real_outdir and Path(o).is_relative_to(real_outdir)] + 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 inside]) 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( diff --git a/server/python/src/metaobjects/codegen/orphan_sweep.py b/server/python/src/metaobjects/codegen/orphan_sweep.py index 8feb873c0..5722444aa 100644 --- a/server/python/src/metaobjects/codegen/orphan_sweep.py +++ b/server/python/src/metaobjects/codegen/orphan_sweep.py @@ -53,12 +53,16 @@ class SweepResult: discarded: list[str] = field(default_factory=list) -def _walk(out_dir: Path) -> list[Path]: +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(): + 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: @@ -92,14 +96,15 @@ def sweep_orphans( emitted_real = {os.path.realpath(p) for p in emitted} forget: set[str] = set() - for out in dict.fromkeys(out_dirs): - out_path = Path(out).resolve() + resolved = list(dict.fromkeys(Path(out).resolve() for out in out_dirs)) + for out_path in resolved: + nested = [o for o in resolved 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): + for p in _walk(out_path, nested): if os.path.realpath(p) in emitted_real: continue rel = str(p.relative_to(out_path)) @@ -137,6 +142,8 @@ def sweep_orphans( 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) diff --git a/server/python/tests/codegen/test_orphan_sweep.py b/server/python/tests/codegen/test_orphan_sweep.py index 9dc94252a..cb5b27142 100644 --- a/server/python/tests/codegen/test_orphan_sweep.py +++ b/server/python/tests/codegen/test_orphan_sweep.py @@ -171,3 +171,52 @@ def test_a_target_run_spares_a_shared_directory_another_target_writes( assert pages assert main(["gen", "--target", "app"]) == 0 assert all(p.exists() for p in pages) + + +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/typescript/packages/cli/src/lib/codegen-drift.ts b/server/typescript/packages/cli/src/lib/codegen-drift.ts index f9dbfda88..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, @@ -344,6 +345,10 @@ 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); // `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 @@ -354,11 +359,11 @@ export async function computeCodegenDrift( 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 (overwrite?.outDirs.has(fresh) === true) { - // Every generator writing here says handEdits: "overwrite", so 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; ` + - `handEdits is "overwrite", so 'meta gen' deletes it)`, + `a force or handEdits: "overwrite" orphan policy owns it, so 'meta gen' deletes it)`, ); } else { editedOrphans.add(relKey); 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..6600e311c --- /dev/null +++ b/server/typescript/packages/cli/test/codegen-drift-orphan-policy.test.ts @@ -0,0 +1,93 @@ +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); + }); +}); diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 2b4899940..3b8be78ac 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -409,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/orphan-sweep.ts b/server/typescript/packages/codegen-ts/src/orphan-sweep.ts index decc711a3..02d8399fd 100644 --- a/server/typescript/packages/codegen-ts/src/orphan-sweep.ts +++ b/server/typescript/packages/codegen-ts/src/orphan-sweep.ts @@ -76,6 +76,39 @@ 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(), forcedBy: new Map() }; @@ -102,20 +135,9 @@ export function sweepOrphans(args: SweepOrphansArgs): SweepOrphansResult { const refusedBy = new Map(); const forcedBy = new Map(); - const jobOwns = (job: OrphanJob, relPath: string): boolean => { - 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)); - }; - // A `keep` policy decides nothing itself; it takes its namespace out of every other - // job's reach, the run-wide jobs included. + 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); - const kept = (relPath: string): boolean => keepJobs.some((job) => jobOwns(job, relPath)); for (const job of args.jobs) { if (job.policy.keep === true) continue; diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index 22c0d3462..20000dc7b 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -55,6 +55,11 @@ const VALID_ENTITY_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; * 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; @@ -193,11 +198,13 @@ export interface RunGenResult { conflicts: WriteResult[]; /** * Where this run discards hand edits (`Generator.handEdits: "overwrite"`): the absolute - * paths emitted by a generator that says so, and the output directories where every - * generator writing into them says so (the only places an edited orphan is deleted). - * `verify --codegen` reads it to tell a hand edit `meta gen` keeps from one it replaces. + * 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; outDirs: ReadonlySet }; + 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[]; } /** @@ -788,7 +795,6 @@ export async function runGen(opts: RunGenOpts): Promise { const overwriteOwners = new Set(); const handEditsOverwrite = (): NonNullable => ({ paths: new Set(emitted.filter((f) => f.handEdits === "overwrite").map((f) => f.fullPath)), - outDirs: new Set([...outDirOverwrites].filter(([, all]) => all).map(([dir]) => dir)), }); // 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. @@ -1082,13 +1088,20 @@ export async function runGen(opts: RunGenOpts): Promise { opts.generatorNames === undefined && baseline !== "adopt" ? [ ...orphanJobs, - ...[...runOutDirs].map((writeOutDir) => ({ - generatorName: RUN_WIDE_SWEEP, - writeOutDir, - policy: outDirOverwrites.get(writeOutDir) === true - ? { owns: () => true, force: true } - : { owns: () => true }, - })), + ...[...runOutDirs].map((writeOutDir) => { + const nested = [...runOutDirs].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; @@ -1204,7 +1217,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, handEditsOverwrite: handEditsOverwrite() }; + return { files: writes, warnings, conflicts, handEditsOverwrite: handEditsOverwrite(), orphanSweepJobs: sweepJobs }; } for (const file of emitted) { @@ -1246,7 +1259,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, handEditsOverwrite: handEditsOverwrite() }; + return { files: writes, warnings, conflicts, handEditsOverwrite: handEditsOverwrite(), orphanSweepJobs: sweepJobs }; } /** 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 index 252ee6c4c..3b56ee3c5 100644 --- 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 @@ -166,3 +166,53 @@ describe("handEdits", () => { 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/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 d8143ef81..ae97cdf05 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 @@ -25,18 +25,13 @@ function readSkill(name: string): string { return readFileSync(join(resolveAgentContextRoot(), "skills", name, "SKILL.md"), "utf8"); } -test("the skill states one hash-manifest overwrite policy for every port", () => { +test("the skill does not state the retired header-ownership overwrite claim", () => { const skill = readSkill("metaobjects-codegen"); // 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 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/); - // The hash manifest decides on every port, the JVM included since 8.1.0. - expect(skill).toMatch(/hash manifest\*\*\s+\(`\.metaobjects\/\.gen-state\/\.hashes\.json`\) on every port/); - expect(skill).toMatch(/Java and Kotlin have used it since 8\.1\.0/); - // The habit an 8.0.x JVM adopter has to drop is named, or they keep deleting headers. - expect(skill).toMatch(/Do not carry that habit forward/); }); test("the skill points at the sections that answer the write-once question", () => { diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index d25ddf9f2..ee5c0d1cb 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -36,8 +36,15 @@ does not cover deletion, and deletion without evidence is not safe on any port. 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; each touches only the keys under its own generators' output - directories. Two modules have two manifests. + module share the file, and may share an output directory, so each entry the JVM writes + also names its **owner**, the execution that emitted it: + `"": { "hash": "", "owner": ":" }`. An + execution sweeps only entries it owns, under its own generators' output directories, so a + sibling execution's files are never its orphans. One path emitted by two executions in + the same build is an error naming both. An entry in the plain `"": ""` form + has no owner: the first execution that emits the path takes it, and no sweep deletes it + otherwise, so a manifest without owners loses nothing on the run that adopts it. Two + modules have two manifests. 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; @@ -73,6 +80,10 @@ does not cover deletion, and deletion without evidence is not safe on any port. ## Consequences +- The owner is the execution id, and running the goal from the command line runs it as + `default-cli`. Alternating a command-line run with a lifecycle run of the same generators + moves the owner back and forth in the committed file; `mvn metaobjects:generate@` runs + a declared execution under its own id. - 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. From df84816b1366a4306ed6c0f2a621d79fdbd13313 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 09:40:54 -0400 Subject: [PATCH 03/22] no-mistakes(review): Key JVM manifest owners on generator and outputDir --- CHANGELOG.md | 7 +- .../migrations/jvm-codegen-hash-manifest.md | 18 +- docs/features/own-your-codegen.md | 30 +-- .../generator/util/GeneratedFileWriter.java | 58 +++-- .../generator/util/HashManifest.java | 86 ++++--- .../generator/util/OrphanSweep.java | 142 ++++++------ .../util/GeneratedFileWriterTest.java | 36 +-- .../generator/util/HashManifestTest.java | 20 +- .../generator/util/OrphanSweepTest.java | 106 ++++++--- .../mojo/AbstractMetaDataMojo.java | 103 +++++++-- .../mojo/MetaDataGeneratorMojo.java | 99 +++++--- .../metaobjects/mojo/MetaDataVerifyMojo.java | 76 +++++-- .../com/metaobjects/mojo/RequiresCheck.java | 7 + .../MetaDataGeneratorMojoManifestTest.java | 214 ++++++++++++++---- .../mojo/MetaDataVerifyMojoManifestTest.java | 109 +++++++-- server/python/src/metaobjects/cli.py | 5 +- .../src/metaobjects/codegen/orphan_sweep.py | 9 +- .../python/tests/codegen/test_orphan_sweep.py | 17 ++ .../ADR-0059-jvm-codegen-hash-manifest.md | 33 ++- 19 files changed, 821 insertions(+), 354 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e58e53a5..7fdc416f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -871,9 +871,10 @@ The first run on an 8.0.x project adopts every generated file, deletes nothing a 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 (`:`), an execution sweeps only its own entries even -in a shared output directory, and one file emitted by two executions fails the goal; two -modules have two. User templates, api-docs pages and the `META-INF/services` registration are not +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 emitted by two +generators fails the goal; two modules have two. 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). diff --git a/docs/features/migrations/jvm-codegen-hash-manifest.md b/docs/features/migrations/jvm-codegen-hash-manifest.md index 18e8fb5ac..877cf3f7b 100644 --- a/docs/features/migrations/jvm-codegen-hash-manifest.md +++ b/docs/features/migrations/jvm-codegen-hash-manifest.md @@ -20,13 +20,15 @@ manifest, the same one TypeScript, Python and C# use: ``` It maps each generated file's module-relative path to the SHA-256 of what was written and -to the execution that wrote it, its owner (`:`): +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…", - "owner": "app:codegen" + "generator": "java:dto", + "outputDir": "target/generated-sources/java" } } ``` @@ -70,12 +72,12 @@ With it: 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, each one deletes only the -files it recorded as their owner, so executions that share an output directory never delete -each other's output. A file emitted by two executions in one build fails the goal, naming -both: declare that generator in one execution only. Run a declared execution by id with -`mvn metaobjects:generate@`; a bare `mvn metaobjects:generate` runs as `default-cli` -and records that as the owner. +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 emitted by two generators in one build fails +the goal, naming both: configure only one generator to emit it. 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. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 31ebd5e7f..d31de72dc 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -494,9 +494,9 @@ forbids renaming it, so the body has to stay where it is. 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 execution -that wrote it, and an execution touches only its own entries, even in an output directory it -shares with another; two modules have two manifests. A file that still hashes to its record is +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.** @@ -588,17 +588,19 @@ handles those files by one rule: - **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. Each execution sweeps only the output directories its -own generators write to, and in them only the files it recorded: every manifest entry names -its owner, `:`. So two executions in one module never delete each -other's files, even when they share an output directory. One file emitted by two executions -in the same build fails the goal, naming both. An entry with no owner (written before owners -existed, or by another port) is taken by the first execution that emits it, and is never -deleted otherwise. Running the goal from the command line runs it as execution -`default-cli`; run `mvn metaobjects:generate@` to run a declared execution under its own -id. A refused orphan is a warning there, as every JVM refusal is (ADR-0059). -`mvn metaobjects:verify` leaves out a file owned by an execution whose generators it did not -run, since `generate` never deletes it for them. +`` 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. +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. +One file emitted by two generator/output-directory pairs in the same build fails the goal, +naming both. 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-0059). `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 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 7095909a9..991d1fd5b 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 @@ -10,7 +10,8 @@ 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; @@ -132,18 +133,19 @@ public static final class Run implements AutoCloseable { 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; - private String owner; // 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 HashSet<>(); + // unchanged, refused or adopted — with the owner of each generator that asked. Its + // key set is the "emitted" set the caller's orphan sweep must not touch: a refused + // or adopted path is still spoken for. + private final Map> emittedOwners = new LinkedHashMap<>(); // Aggregate state for the Mojo's end-of-run notices. private final List adoptedPaths = new ArrayList<>(); private final List overwrittenHandEditedPaths = new ArrayList<>(); @@ -154,39 +156,44 @@ private Run() {} /** 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); + return attributeTo(generatorName, HAND_EDITS_KEEP, null); } /** - * Name the generator whose writes follow and its {@code handEdits} policy - * ({@link #HAND_EDITS_KEEP} or {@link #HAND_EDITS_OVERWRITE}). Called once per - * generator by the runner, before that generator executes. + * 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) { + 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}), - * {@code projectRoot} is what manifest keys are relative to (typically the module - * basedir), and {@code owner} names the execution every path this run emits is - * recorded against. Called once, by the Mojo, right after {@link #beginRun}. Leaving + * 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, String owner) { + public Run configureManifest(Path genStateDir, Path projectRoot) { this.genStateDir = genStateDir; this.projectRoot = projectRoot; - this.owner = owner; return this; } /** The files written, left in place, or adopted this run — the orphan sweep's * "do not touch" set. */ public Set emittedPaths() { - return emittedPaths; + return emittedOwners.keySet(); + } + + /** Each emitted path with the owners of the generators that emitted it. */ + public Map> emittedOwners() { + return emittedOwners; } /** Project-relative paths adopted into a fresh manifest (or a manifest that had no @@ -251,9 +258,9 @@ public static Run currentRun() { return ACTIVE_RUN.get(); } - /** {@link Run#attributeTo(String, String)}'s default: refuse a hand-edited file by name. */ + /** {@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)}: replace a hand-edited file with fresh output. */ + /** {@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. */ @@ -307,7 +314,8 @@ public enum Outcome { public static Outcome write(Path outFile, String content) throws IOException { Run run = ACTIVE_RUN.get(); if (run != null) { - run.emittedPaths.add(outFile.toAbsolutePath().normalize()); + run.emittedOwners.computeIfAbsent(outFile.toAbsolutePath().normalize(), k -> new LinkedHashSet<>()) + .add(run.currentOwner); } if (run == null || run.genStateDir == null) { @@ -322,7 +330,7 @@ public static Outcome write(Path outFile, String content) throws IOException { if (!Files.exists(outFile)) { performWrite(outFile, content, run); - HashManifest.record(run.genStateDir, key, content, run.owner); + HashManifest.record(run.genStateDir, key, content, run.currentOwner); return Outcome.WRITTEN; } @@ -337,7 +345,7 @@ public static Outcome write(Path outFile, String content) throws IOException { // replace under any policy. if (looksGenerated(current)) { performWrite(outFile, content, run); - HashManifest.record(run.genStateDir, key, content, run.owner); + HashManifest.record(run.genStateDir, key, content, run.currentOwner); run.adoptedPaths.add(outFile.toString()); return Outcome.WRITTEN; } @@ -349,26 +357,26 @@ public static Outcome write(Path outFile, String content) throws IOException { // 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); - HashManifest.record(run.genStateDir, key, content, run.owner); + HashManifest.record(run.genStateDir, key, content, run.currentOwner); 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); - HashManifest.record(run.genStateDir, key, content, run.owner); + HashManifest.record(run.genStateDir, key, content, run.currentOwner); 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); - HashManifest.record(run.genStateDir, key, content, run.owner); + HashManifest.record(run.genStateDir, key, content, run.currentOwner); run.overwrittenHandEditedPaths.add(outFile.toString()); LOG.warn("overwrote hand-edited " + outFile + " (handEdits: overwrite)"); return Outcome.WRITTEN; } - HashManifest.recordHash(run.genStateDir, key, recorded, run.owner); + HashManifest.recordHash(run.genStateDir, key, recorded, run.currentOwner); LOG.warn(refusedHandEditedMessage(outFile)); return Outcome.REFUSED; } 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 index 6cf63f66f..8dcd96e76 100644 --- 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 @@ -36,17 +36,28 @@ * 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 OWNER, the Maven execution that emitted - * the file ({@code :}): {@code "": { "hash": "", "owner": "" }}. Two {@code }s of one module share this file, - * and may share an output directory, so the owner is what keeps one execution's orphan - * sweep off the other's files. An entry in the plain {@code "": ""} form, - * the form the other ports write, reads as recorded with no owner. + *

    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(); @@ -86,16 +97,33 @@ public static boolean exists(Path genStateDir) { * closed — every path then reads as unrecorded rather than being guessed at. */ public static Map load(Path genStateDir) { - return read(genStateDir, "hash"); + 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) { - return read(genStateDir, "owner"); + 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 read(Path genStateDir, String field) { - Map out = new LinkedHashMap<>(); + private static Map entries(Path genStateDir) { + Map out = new LinkedHashMap<>(); Path path = pathFor(genStateDir); if (!Files.isRegularFile(path)) return out; JsonElement parsed; @@ -106,38 +134,36 @@ private static Map read(Path genStateDir, String field) { } if (!parsed.isJsonObject()) return out; for (Map.Entry e : parsed.getAsJsonObject().entrySet()) { - JsonElement value = e.getValue(); - if ("hash".equals(field) && value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { - out.put(e.getKey(), value.getAsString()); - } else if (value.isJsonObject()) { - JsonElement v = value.getAsJsonObject().get(field); - if (v != null && v.isJsonPrimitive() && v.getAsJsonPrimitive().isString()) { - out.put(e.getKey(), v.getAsString()); - } - } + 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", "owner" }}, 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. + * 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) { + 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()) { - String owner = owners.get(e.getKey()); + 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("owner", owner); + entry.addProperty("generator", owner.generator()); + entry.addProperty("outputDir", owner.outputDir()); root.add(e.getKey(), entry); } } @@ -148,14 +174,14 @@ public static void save(Path genStateDir, Map hashes, Map hashes = load(genStateDir); - Map owners = loadOwners(genStateDir); + Map owners = loadOwners(genStateDir); hashes.put(key, hash); if (owner == null) owners.remove(key); else owners.put(key, owner); 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 index 8cdb5092b..7308e7da3 100644 --- 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 @@ -12,7 +12,6 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; -import java.util.stream.Stream; /** * The orphan sweep: delete a file {@code mvn metaobjects:generate} wrote earlier and no @@ -26,12 +25,16 @@ * 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 a candidate must also be OWNED by the sweeping execution: two - * {@code }s of one module share the manifest and may share an output - * directory, so a file another execution recorded, or one recorded with no owner - * at all, is never this execution's orphan.
  • + *
  • 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.
  • + * 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 @@ -131,89 +134,80 @@ public static Verdict classify(Map hashes, Path projectRoot, Pat } /** - * Delete the untouched files under {@code dirs} that the manifest records as written - * by {@code owner} and {@code emitted} (normalized absolute paths this run produced, - * refused hand-edits included) does not hold, and forget every path recorded for - * {@code owner} under those directories 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. + * 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. + */ + public static boolean isCandidate(HashManifest.Owner owner, boolean inRunDir, + Set runOwners, Set configured) { + if (owner != null && runOwners.contains(owner)) return true; + if (configured == null) return false; + return owner == null ? inRunDir : !configured.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, - String owner) { + Set runOwners, Set configured) { if (!HashManifest.exists(genStateDir)) { return new Result(List.of(), List.of(), List.of()); } Map hashes = HashManifest.load(genStateDir); - Map owners = HashManifest.loadOwners(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(); - // De-dupe by normalized directory — two generators sharing an outputDir supply - // the same Entry twice; only its first (any, since dirs sharing a path carry the - // same allowOverwrite — callers build Entry per unique dir) is needed. Map byDir = new LinkedHashMap<>(); for (Entry e : dirs) { byDir.putIfAbsent(e.dir.toAbsolutePath().normalize(), e); } - for (Entry e : byDir.values()) { - Path full = e.dir.toAbsolutePath().normalize(); - String prefix = keyFor(root, full); - if (prefix == null) continue; // nothing outside the project is ours to delete - List nested = byDir.keySet().stream() - .filter(d -> !d.equals(full) && d.startsWith(full)) - .collect(Collectors.toList()); - - if (Files.isDirectory(full)) { - List files; - try (Stream walk = Files.walk(full)) { - files = walk.filter(Files::isRegularFile).collect(Collectors.toList()); - } catch (IOException ex) { - throw new UncheckedIOException("Could not walk generated output dir [" + full + "]", ex); - } - for (Path file : files) { - Path normFile = file.toAbsolutePath().normalize(); - if (emitted.contains(normFile) || isUnder(normFile, nested)) continue; - String key = keyFor(root, normFile); - if (key == null || !hashes.containsKey(key)) continue; // stranger — not ours - if (!owner.equals(owners.get(key))) continue; // another execution's, or unowned - - String relDisplay = full.relativize(normFile).toString().replace(java.io.File.separatorChar, '/'); - Verdict verdict = classify(hashes, root, normFile); - switch (verdict) { - case PRISTINE: - delete(normFile); - forget.add(key); - removed.add(e.label + relDisplay); - break; - case EDITED: - if (e.allowOverwrite) { - delete(normFile); - forget.add(key); - overwrittenDeleted.add(e.label + relDisplay); - } else { - refused.add(key); - } - break; - case STRANGER: - default: - // Already excluded above by the hashes.containsKey() check. - break; - } - } + 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; - // 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. - String under = prefix.isEmpty() ? "" : prefix + "/"; - for (String key : hashes.keySet()) { - if (!key.startsWith(under) || !owner.equals(owners.get(key))) continue; - Path path = root.resolve(key).normalize(); - if (emitted.contains(path) || isUnder(path, nested) || Files.exists(path)) continue; - forget.add(key); + 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; } } @@ -229,11 +223,13 @@ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, return new Result(removed, overwrittenDeleted, distinctRefused); } - private static boolean isUnder(Path path, List dirs) { + /** 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)) return true; + if (path.startsWith(d) && (best == null || d.getNameCount() > best.getNameCount())) best = d; } - return false; + return best; } private static void delete(Path path) { 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 ec9d05829..8fb2df012 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 @@ -254,7 +254,7 @@ public void absentFile_writesAndRecords() throws Exception { Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, GENERATED_BODY)); } assertEquals(GENERATED_BODY, Files.readString(out)); @@ -270,7 +270,7 @@ public void firstRunAdoption_markerFilePresentNoManifest_isAdoptedNotRefused() t Files.writeString(out, GENERATED_BODY); // pre-existing, never recorded try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, GENERATED_BODY)); assertEquals(List.of(out.toString()), run.adoptedPaths()); } @@ -287,7 +287,7 @@ public void firstRunAdoption_noMarkerNoManifest_isRefusedNotAdopted() throws Exc Files.writeString(out, mine); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, GENERATED_BODY)); assertTrue(run.adoptedPaths().isEmpty()); } @@ -302,12 +302,12 @@ public void pristineRecordedFile_isOverwrittenAndReRecorded() throws Exception { Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + 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, "test"); + run.configureManifest(genState, project); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, fresh)); } assertEquals(fresh, Files.readString(out)); @@ -321,11 +321,11 @@ public void unchangedContent_isReaffirmedNotRewritten() throws Exception { Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); GeneratedFileWriter.write(out, GENERATED_BODY); } try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); assertEquals(GeneratedFileWriter.Outcome.UNCHANGED, GeneratedFileWriter.write(out, GENERATED_BODY)); } } @@ -337,11 +337,11 @@ public void unchangedWriteStillClaimsThePath_soADifferingSecondEmitterCollides() Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); GeneratedFileWriter.write(out, GENERATED_BODY); } try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); run.attributeTo("GeneratorA"); assertEquals(GeneratedFileWriter.Outcome.UNCHANGED, GeneratedFileWriter.write(out, GENERATED_BODY)); run.attributeTo("GeneratorB"); @@ -363,7 +363,7 @@ public void editedRecordedFile_handEditsKeep_isRefusedByNameAndStaysRecorded() t Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); GeneratedFileWriter.write(out, GENERATED_BODY); } String edited = GENERATED_BODY + "// mine\n"; @@ -371,8 +371,8 @@ public void editedRecordedFile_handEditsKeep_isRefusedByNameAndStaysRecorded() t String fresh = "/** GENERATED — REST controller for Council. */\npublic class X { int z; }\n"; try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); - run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_KEEP); + 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)); @@ -387,15 +387,15 @@ public void editedRecordedFile_handEditsOverwrite_isReplacedAndReBaselined() thr Path out = project.resolve("gen/X.java"); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + 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, "test"); - run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_OVERWRITE); + 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()); } @@ -415,8 +415,8 @@ public void strangerFile_isNeverTouchedByOverwritePolicy() throws Exception { Files.writeString(out, mine); try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); - run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_OVERWRITE); + 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)); @@ -429,7 +429,7 @@ public void untrackedPathOutsideProjectRoot_fallsBackToMarkerOnlyRule() throws E Path outside = tmp.newFolder().toPath().resolve("X.java"); // sibling, not under project try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { - run.configureManifest(genState, project, "test"); + run.configureManifest(genState, project); assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(outside, GENERATED_BODY)); assertEquals(1, run.untrackedCount()); } 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 index 8a0df3836..062bd337f 100644 --- 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 @@ -16,7 +16,7 @@ /** The committed hash manifest: shape, merge semantics, fail-closed reads. */ public class HashManifestTest { - private static final String OWNER = "app:generate"; + private static final HashManifest.Owner OWNER = new HashManifest.Owner("entity", "gen"); @Rule public TemporaryFolder tmp = new TemporaryFolder(); @@ -90,26 +90,30 @@ public void contentHashIsStableAndLowercaseHex() { } @Test - public void ownedEntryIsWrittenWithItsOwner_plainEntryReadsAsUnowned() throws Exception { + 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. - Files.writeString(genState.resolve(".hashes.json"), "{\n \"src/Plain.java\": \"abc\"\n}\n"); + // 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(OWNER, owners.get("src/Owned.java")); - assertFalse(owners.containsKey("src/Plain.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(OWNER, owned.get("owner").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 index a5ea25a6c..bf0d2c7a6 100644 --- 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 @@ -18,7 +18,7 @@ /** The orphan sweep: classify, delete, refuse, forget — against a real manifest and tree. */ public class OrphanSweepTest { - private static final String OWNER = "app:generate"; + private static final HashManifest.Owner OWNER = new HashManifest.Owner("entity", "gen"); @Rule public TemporaryFolder tmp = new TemporaryFolder(); @@ -94,7 +94,7 @@ public void renameCase_pristineOrphanIsDeletedAndForgotten() throws Exception { 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, OWNER); + 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")); @@ -115,7 +115,7 @@ public void handEditedOrphan_isRefused_keptAndStillRecorded() throws Exception { 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(), OWNER); + 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")); @@ -134,7 +134,7 @@ public void handEditedOrphan_underAllowOverwrite_isDeleted() throws Exception { 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(), OWNER); + 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")); @@ -153,7 +153,7 @@ public void strangerFile_isNeverTouched() throws Exception { 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(), OWNER); + 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")); @@ -167,7 +167,7 @@ public void noManifestAtAll_sweepIsANoOp() throws Exception { 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(), OWNER); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), null); assertTrue(result.removed.isEmpty()); assertTrue(result.refused.isEmpty()); @@ -183,7 +183,7 @@ public void alreadyGoneRecordedFile_isForgotten() throws Exception { 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(), OWNER); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), null); assertFalse(HashManifest.load(genStateDir).containsKey("gen/Deleted.java")); } @@ -202,47 +202,89 @@ public void twoDirectories_neitherSweepsTheOthersFiles() throws Exception { Path fileB = dirB.resolve("B.java"); Files.writeString(fileA, "a"); Files.writeString(fileB, "b"); - HashManifest.record(genStateDir, "genA/A.java", "a", OWNER); - HashManifest.record(genStateDir, "genB/B.java", "b", OWNER); + 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(), OWNER); + 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: only the sweeping execution's own entries are candidates ===== + // === sweep: whose entries a run may judge ================================== - @Test - public void sharedDirectory_otherOwnersAndUnownedEntries_areNeverCandidates() throws Exception { + 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(); - Path mine = outDir.resolve("Mine.java"); - Path theirs = outDir.resolve("Theirs.java"); - Path unowned = outDir.resolve("Unowned.java"); - Files.writeString(mine, "m"); - Files.writeString(theirs, "t"); - Files.writeString(unowned, "u"); + 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", "app:other"); + HashManifest.record(genStateDir, "gen/Theirs.java", "t", DTO); HashManifest.record(genStateDir, "gen/Unowned.java", "u", null); - HashManifest.record(genStateDir, "gen/TheirsGone.java", "g", "app:other"); + 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(), OWNER); + 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); - assertFalse(Files.exists(mine)); - assertTrue(Files.exists(theirs)); - assertTrue(Files.exists(unowned)); - assertTrue(HashManifest.load(genStateDir).containsKey("gen/Theirs.java")); - assertTrue(HashManifest.load(genStateDir).containsKey("gen/Unowned.java")); - assertTrue("another execution's gone file is that execution's to forget", - HashManifest.load(genStateDir).containsKey("gen/TheirsGone.java")); - assertEquals("app:other", HashManifest.loadOwners(genStateDir).get("gen/Theirs.java")); + assertTrue(Files.exists(outDir.resolve("Unowned.java"))); + assertTrue(Files.exists(web.resolve("Route.java"))); } // === sweep: a nested directory is judged by its own policy ================== @@ -259,7 +301,7 @@ public void nestedDirectory_editedOrphanIsJudgedOnceByTheInnermostDirectory() th 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(), OWNER); + genStateDir, projectRoot, List.of(outerKeep, innerOverwrite), Set.of(), Set.of(OWNER), null); assertFalse(Files.exists(orphan)); assertEquals(List.of("inner/Old.java"), result.overwrittenDeleted); @@ -278,7 +320,7 @@ public void nestedDirectory_keepInsideOverwrite_isRefusedNotDeleted() throws Exc 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(), OWNER); + 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); 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 74df45a5d..e9d966caf 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,16 @@ 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.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.Parameter; import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; import java.io.File; import java.lang.reflect.Constructor; @@ -33,6 +37,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -57,16 +62,71 @@ public abstract class AbstractMetaDataMojo extends AbstractMojo @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"; + /** - * The owner a codegen hash manifest entry is recorded against: the module's - * artifactId and the {@code }. {@code default} when no - * execution is injected (a unit test, an embedder). + * 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 String ownerId() { - String executionId = execution != null && execution.getExecutionId() != null - ? execution.getExecutionId() : "default"; - String module = project != null ? project.getArtifactId() : null; - return module != null ? module + ":" + executionId : executionId; + 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; + Path dir = Path.of(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 new HashManifest.Owner(RequiresCheck.registryId(classname), + shown.replace(File.separatorChar, '/')); + } + + /** + * 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. {@code null} when the module's build model + * does not declare this plugin, so what is configured is unknown. + */ + protected Set configuredOwners() { + if (project == null) return null; + Plugin plugin = project.getPlugin(PLUGIN_KEY); + if (plugin == null) return null; + Set out = new HashSet<>(); + addConfiguredOwners(plugin.getConfiguration(), out); + for (PluginExecution e : plugin.getExecutions()) { + if (e.getGoals().contains("generate")) addConfiguredOwners(e.getConfiguration(), out); + } + return out; + } + + private void addConfiguredOwners(Object configuration, Set out) { + if (!(configuration instanceof Xpp3Dom dom)) return; + Map domGlobals = childValues(dom.getChild("globals")); + Xpp3Dom generators = dom.getChild("generators"); + if (generators == null) return; + 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) out.add(owner); + } + } + + 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") @@ -269,9 +329,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 ) { @@ -281,14 +356,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/MetaDataGeneratorMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java index 88405b3a4..f9bd3397c 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 @@ -59,8 +59,9 @@ protected void executeGenerators(MetaDataLoader loader, List generato // 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 execution that emitted it, and a sweep only touches its own) and two modules - // never collide (each has its own basedir). `null` in a unit test that sets no project + // 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 @@ -70,6 +71,7 @@ protected void executeGenerators(MetaDataLoader loader, List generato // 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 @@ -77,17 +79,17 @@ protected void executeGenerators(MetaDataLoader loader, List generato // 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()) { - String owner = ownerId(); if (genStateDir != null) { - run.configureManifest(genStateDir, projectRoot, owner); + run.configureManifest(genStateDir, projectRoot); } // Every output dir THIS EXECUTION's generators write to, with the handEdits - // policy of every generator that writes there — the orphan sweep's jurisdiction - // is scoped to these dirs, and within them to the files this execution owns, so - // a sibling sharing the manifest, or even an output dir, is never - // touched. + // 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++) { @@ -95,7 +97,9 @@ protected void executeGenerators(MetaDataLoader loader, List generato getLog().info("MetaData Mojo > Executing Generator: " + gen.getClass().getName() ); GeneratorParam param = params != null && i < params.size() ? params.get(i) : null; String handEdits = param != null ? param.validatedHandEdits() : GeneratorParam.HAND_EDITS_KEEP; - run.attributeTo( gen.getClass().getSimpleName(), handEdits ); + 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) { @@ -107,9 +111,12 @@ protected void executeGenerators(MetaDataLoader loader, List generato } } - refuseSharedPaths(run.emittedPaths(), owner); - if (genStateDir != null) { - sweepOrphans(genStateDir, projectRoot, handEditsByDir, run.emittedPaths(), owner); + Set emittedThisBuild = refuseSharedPaths(run.emittedOwners()); + 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); } @@ -118,20 +125,20 @@ protected void executeGenerators(MetaDataLoader loader, List generato } /** - * Delete the files under this execution's output dirs that the manifest records and - * this run did not emit — the files no generator produces any more. Scoped to exactly - * the dirs {@code handEditsByDir} names, so a sibling {@code } writing - * elsewhere is untouched even though it shares this same manifest file. + * 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, String owner) { + 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); dirs.add(new OrphanSweep.Entry(e.getKey(), e.getKey() + File.separator, allOverwrite)); } - OrphanSweep.Result swept = OrphanSweep.sweep(genStateDir, projectRoot, dirs, emitted, owner); + OrphanSweep.Result swept = OrphanSweep.sweep( + genStateDir, projectRoot, dirs, emitted, runOwners, configuredOwners()); for (String path : swept.removed) { getLog().info("MetaData Mojo > Removed orphaned generated file no longer produced: " + path); } @@ -144,33 +151,61 @@ private void sweepOrphans(Path genStateDir, Path projectRoot, Map emitted, String owner) { - if (project == null) return; + private Set refuseSharedPaths(Map> emitted) { @SuppressWarnings("unchecked") - Map emittedBy = (Map) project.getContextValue(EMITTED_BY_CONTEXT_KEY); + Map emittedBy = project == null ? null + : (Map) project.getContextValue(EMITTED_BY_CONTEXT_KEY); if (emittedBy == null) { emittedBy = new HashMap<>(); - project.setContextValue(EMITTED_BY_CONTEXT_KEY, emittedBy); + if (project != null) project.setContextValue(EMITTED_BY_CONTEXT_KEY, emittedBy); } List shared = new ArrayList<>(); - for (Path path : new TreeSet<>(emitted)) { - String previous = emittedBy.putIfAbsent(path, owner); - if (previous != null && !previous.equals(owner)) { - shared.add(path + " (executions '" + previous + "' and '" + owner + "')"); + for (Path path : new TreeSet<>(emitted.keySet())) { + Set owners = new LinkedHashSet<>(); + HashManifest.Owner previous = emittedBy.get(path); + if (previous != null) owners.add(previous); + for (HashManifest.Owner owner : emitted.get(path)) { + if (owner != null) owners.add(owner); + } + if (owners.isEmpty()) continue; + emittedBy.putIfAbsent(path, owners.iterator().next()); + if (owners.size() > 1) { + shared.add(path + " (" + owners.stream().map(o -> "'" + o + "'") + .collect(java.util.stream.Collectors.joining(" and ")) + ")"); } } if (!shared.isEmpty()) { - throw new MetaDataException("Two executions of " + OWNER + " in this module emitted the same " + throw new MetaDataException("Two generators of " + OWNER + " in this module emitted the same " + "file, so each would overwrite the other's output: " + String.join(", ", shared) - + ". Declare the generator that emits it in only one ."); + + ". Configure only one generator to emit it, in one output directory."); } + Set emittedThisBuild = new HashSet<>(emittedBy.keySet()); + emittedThisBuild.addAll(emitted.keySet()); + return emittedThisBuild; + } + + /** + * 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 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 8a41b22c6..2af7dc397 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 @@ -403,6 +403,9 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException for (GenTarget t : targets) { drift.addAll(compareTrees(t, ownHashes, projectRoot, editedOrphans)); } + if (ownHashes != null) { + drift.addAll(strandedOutsideTargets(ownHashes, targets, projectRoot, editedOrphans)); + } if (!drift.isEmpty()) { StringBuilder sb = new StringBuilder(); @@ -438,30 +441,69 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException } /** - * The manifest entries {@code generate} would sweep for the generators this verify - * runs: those owned by an execution that recorded one of the files just regenerated, - * or by this execution itself. A file another execution owns, or one recorded with no - * owner, is never an orphan to {@code generate}, so it is left out and reads as a - * stranger here too. + * 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) throws MojoExecutionException { - Set runOwners = new HashSet<>(); - runOwners.add(ownerId()); - for (GenTarget t : targets) { - for (String rel : relativeFiles(t.tempDir)) { - String key = OrphanSweep.keyFor(projectRoot, t.realDir.resolve(rel)); - String owner = key != null ? owners.get(key) : null; - if (owner != null) runOwners.add(owner); - } - } + 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); + } + Set configured = configuredOwners(); + Path root = projectRoot.toAbsolutePath().normalize(); for (Map.Entry e : hashes.entrySet()) { - if (runOwners.contains(owners.get(e.getKey()))) owned.put(e.getKey(), e.getValue()); + boolean inRunDir = targetHolding(targets, root.resolve(e.getKey()).normalize()) != null; + if (OrphanSweep.isCandidate(owners.get(e.getKey()), inRunDir, runOwners, configured)) { + 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. 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/MetaDataGeneratorMojoManifestTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java index 6d99c103a..8ef2d1aac 100644 --- 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 @@ -6,9 +6,13 @@ 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; @@ -90,6 +94,10 @@ private List build(GeneratorParam... 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")); } @@ -205,6 +213,65 @@ public void twoExecutionsInOneModule_shareManifest_neitherSweepsTheOther() throw 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(); @@ -215,71 +282,124 @@ private void runExecution(MavenProject build, String executionId, GeneratorParam execution.executeGenerators(null, execution.buildGenerators(getClass().getClassLoader(), null)); } - /** A fresh build of this module: a new project instance, so a new build context. */ - private MavenProject newBuild() { - MavenProject build = new MavenProject(); - build.setArtifactId("app"); - build.setFile(projectRoot.resolve("pom.xml").toFile()); - return build; - } - @Test public void twoExecutionsSharingOneOutputDir_neitherSweepsTheOther_acrossBuilds() throws IOException { String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); - Path dto = Path.of(shared, "Dto.java"); - Path controller = Path.of(shared, "Controller.java"); - Path repository = Path.of(shared, "Repository.java"); + 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(); - runExecution(module, "codegen-web", - param(shared, "Dto.java", MARKER_BODY, null), - param(shared, "Controller.java", MARKER_BODY, null)); - runExecution(module, "codegen-data", - param(shared, "Repository.java", MARKER_BODY, null)); + 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(dto) && Files.exists(controller)); - assertTrue("build " + build, Files.exists(repository)); + 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 = HashManifest.loadOwners(projectRoot.resolve(".metaobjects").resolve(".gen-state")); - assertEquals("app:codegen-web", owners.get("generated-sources/Dto.java")); - assertEquals("app:codegen-web", owners.get("generated-sources/Controller.java")); - assertEquals("app:codegen-data", owners.get("generated-sources/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_anExecutionStillSweepsItsOwnOrphans() throws IOException { + public void sharedOutputDir_aGeneratorStillSweepsItsOwnOrphans() throws IOException { String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); - MavenProject first = newBuild(); - runExecution(first, "codegen-web", param(shared, "Telemetry.java", MARKER_BODY, null)); - runExecution(first, "codegen-data", param(shared, "Repository.java", MARKER_BODY, null)); + 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(); - runExecution(second, "codegen-web", param(shared, "TelemetryReading.java", MARKER_BODY, null)); - runExecution(second, "codegen-data", param(shared, "Repository.java", MARKER_BODY, null)); + 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 onePathEmittedByTwoExecutions_isAnErrorNamingBoth() throws IOException { + 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 generatorRemovedFromConfiguration_itsFilesAreDeletedEvenInADirectoryNothingWrites() + throws IOException { + String genDir = tempFolder.newFolder("gen").getAbsolutePath(); + String webDir = tempFolder.newFolder("web").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, genDir, "Dto.java"); + GeneratorParam route = gen(RouteGenerator.class, webDir, "Route.java"); + runExecution(newBuild(null, Map.of("codegen", configuration(dto, route))), "codegen", dto, route); + assertTrue(Files.exists(Path.of(webDir, "Route.java"))); + + runExecution(newBuild(null, Map.of("codegen", configuration(dto))), "codegen", dto); + + assertFalse("no execution configures its generator any more", Files.exists(Path.of(webDir, "Route.java"))); + assertFalse(manifest().containsKey("web/Route.java")); + assertTrue(Files.exists(Path.of(genDir, "Dto.java"))); + } + + @Test + public void onePathEmittedByTwoGenerators_isAnErrorNamingBoth() throws IOException { String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); - MavenProject module = newBuild(); - runExecution(module, "codegen-web", param(shared, "Dto.java", MARKER_BODY, null)); + GeneratorParam dto = gen(DtoGenerator.class, shared, "Dto.java"); + GeneratorParam repository = gen(RepositoryGenerator.class, shared, "Dto.java"); + MavenProject module = newBuild(null, Map.of( + "codegen-web", configuration(dto), "codegen-data", configuration(repository))); + runExecution(module, "codegen-web", dto); try { - runExecution(module, "codegen-data", param(shared, "Dto.java", MARKER_BODY, null)); - fail("expected the second execution to be refused"); + 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("app:codegen-web") && e.getMessage().contains("app:codegen-data")); + && e.getMessage().contains(DtoGenerator.class.getName() + " -> generated-sources") + && e.getMessage().contains(RepositoryGenerator.class.getName() + " -> generated-sources")); } } + /** 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 manifestWithoutOwners_isAdoptedByTheEmittingExecution_andNothingIsDeleted() throws IOException { + public void manifestWithoutGeneratorOwners_firstBuildRecordsOwnersAndDeletesNothing() throws IOException { String outDir = tempFolder.newFolder("gen").getAbsolutePath(); Path kept = Path.of(outDir, "Kept.java"); Path old = Path.of(outDir, "Old.java"); @@ -288,16 +408,22 @@ public void manifestWithoutOwners_isAdoptedByTheEmittingExecution_andNothingIsDe Path genState = projectRoot.resolve(".metaobjects").resolve(".gen-state"); Files.createDirectories(genState); String hash = HashManifest.contentHash(MARKER_BODY); - Files.writeString(genState.resolve(".hashes.json"), - "{\n \"gen/Kept.java\": \"" + hash + "\",\n \"gen/Old.java\": \"" + hash + "\"\n}\n"); + 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"); + GeneratorParam dto = gen(DtoGenerator.class, outDir, "Kept.java"); + Map executions = Map.of("codegen", configuration(dto)); + + runExecution(newBuild(null, executions), "codegen", dto); + + assertTrue("the build that records owners deletes nothing", Files.exists(old)); + assertEquals(new HashManifest.Owner(DtoGenerator.class.getName(), "gen"), owners().get("gen/Kept.java")); + assertFalse(owners().containsKey("gen/Old.java")); - runExecution(newBuild(), "codegen", param(outDir, "Kept.java", MARKER_BODY, null)); + runExecution(newBuild(null, executions), "codegen", dto); - assertTrue("nothing recorded without an owner is deleted", Files.exists(old)); + assertFalse("recorded in this run's directory, emitted by nothing", Files.exists(old)); assertTrue(Files.exists(kept)); - Map owners = HashManifest.loadOwners(genState); - assertEquals("app:codegen", owners.get("gen/Kept.java")); - assertFalse(owners.containsKey("gen/Old.java")); } // === multi-module: two basedirs, two manifests =============================== 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 index ed0091c32..2feb4e05f 100644 --- 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 @@ -4,10 +4,14 @@ 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; @@ -232,32 +236,103 @@ public void verifyFailureMessage_allEditedOrphans_suppressesRegenerateLeadIn() t } } + 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) { + 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); + Plugin plugin = new Plugin(); + plugin.setGroupId("com.metaobjects"); + plugin.setArtifactId("metaobjects-maven-plugin"); + PluginExecution execution = new PluginExecution(); + execution.setId("codegen"); + execution.addGoal("generate"); + execution.setConfiguration(configuration); + plugin.addExecution(execution); + Build build = new Build(); + build.addPlugin(plugin); + MavenProject module = new MavenProject(); + module.setArtifactId("app"); + module.setFile(basedir.resolve("pom.xml").toFile()); + module.getModel().setBuild(build); + return module; + } + + 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 executions share one output dir; a verify that regenerates only the first - * execution's generators must not report the second's files as stale, because - * {@code generate} never deletes another execution's file. + * 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_anotherExecutionsFiles_areNotDrift() throws Exception { + public void sharedOutputDir_anotherConfiguredGeneratorsFiles_areNotDrift() throws Exception { Path basedir = tempFolder.getRoot().toPath(); String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); - MavenProject module = new MavenProject(); - module.setArtifactId("app"); - module.setFile(basedir.resolve("pom.xml").toFile()); - for (String[] exec : new String[][] {{"codegen-web", "Dto.java"}, {"codegen-data", "Repository.java"}}) { - MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); - gen.setLoader(new LoaderParam()); - gen.project = module; - gen.execution = new MojoExecution(new MojoDescriptor(), exec[0]); - gen.setGenerators(List.of(param(shared, exec[1], MARKER_BODY, null))); - gen.executeGenerators(null, gen.buildGenerators(getClass().getClassLoader(), null)); - } + 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 = mavenProject(basedir); + verify.project = module; verify.setLoader(testLoader()); - verify.setGenerators(List.of(param(shared, "Dto.java", MARKER_BODY, null))); + verify.setGenerators(List.of(dto)); verify.execute(); } + + /** A generator removed from the configuration leaves files only {@code generate} + * deletes; verify reports them with that remedy, wherever they live. */ + @Test + public void removedGeneratorsFiles_areDriftThatGenerateDeletes() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String genDir = tempFolder.newFolder("gen").getAbsolutePath(); + String webDir = tempFolder.newFolder("web").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, genDir, "Dto.java"); + GeneratorParam route = gen(RepositoryGenerator.class, webDir, "Route.java"); + generateIn(module(basedir, dto, route), dto, route); + + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + verify.project = module(basedir, dto); + verify.setLoader(testLoader()); + verify.setGenerators(List.of(dto)); + + try { + verify.execute(); + fail("expected MojoFailureException"); + } catch (MojoFailureException e) { + assertTrue(e.getMessage(), e.getMessage().contains("Route.java") + && e.getMessage().contains("'mvn metaobjects:generate' deletes it")); + } + } } diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 2852faf7b..418e83273 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -1704,9 +1704,11 @@ def _sweep_orphans_after_gen( 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) + 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 " @@ -1718,6 +1720,7 @@ def _sweep_orphans_after_gen( 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( diff --git a/server/python/src/metaobjects/codegen/orphan_sweep.py b/server/python/src/metaobjects/codegen/orphan_sweep.py index 5722444aa..4d60d36c1 100644 --- a/server/python/src/metaobjects/codegen/orphan_sweep.py +++ b/server/python/src/metaobjects/codegen/orphan_sweep.py @@ -79,13 +79,17 @@ def sweep_orphans( *, 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``. ``dry_run`` decides and reports without touching the disk or the manifest. + ``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() @@ -97,8 +101,9 @@ def sweep_orphans( 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 resolved if o != out_path and o.is_relative_to(out_path)] + 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) diff --git a/server/python/tests/codegen/test_orphan_sweep.py b/server/python/tests/codegen/test_orphan_sweep.py index cb5b27142..e6e811c90 100644 --- a/server/python/tests/codegen/test_orphan_sweep.py +++ b/server/python/tests/codegen/test_orphan_sweep.py @@ -173,6 +173,23 @@ def test_a_target_run_spares_a_shared_directory_another_target_writes( 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 _nested_layout(root: Path) -> tuple[Path, Path, Path, Path]: from metaobjects.codegen.overwrite_policy import _save_hashes, content_hash diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index ee5c0d1cb..4abc858b6 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -37,14 +37,24 @@ does not cover deletion, and deletion without evidence is not safe on any port. `/.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**, the execution that emitted it: - `"": { "hash": "", "owner": ":" }`. An - execution sweeps only entries it owns, under its own generators' output directories, so a - sibling execution's files are never its orphans. One path emitted by two executions in - the same build is an error naming both. An entry in the plain `"": ""` form - has no owner: the first execution that emits the path takes it, and no sweep deletes it - otherwise, so a manifest without owners loses nothing on the run that adopts it. Two - modules have two manifests. + 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 reads the module's `metaobjects:generate` executions and plugin-level + configuration from the build model to know every generator/output-directory pair the + module configures. 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. One path emitted by two generator/output-directory pairs in the same build + is an error naming both. 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. 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; @@ -80,10 +90,9 @@ does not cover deletion, and deletion without evidence is not safe on any port. ## Consequences -- The owner is the execution id, and running the goal from the command line runs it as - `default-cli`. Alternating a command-line run with a lifecycle run of the same generators - moves the owner back and forth in the committed file; `mvn metaobjects:generate@` runs - a declared execution under its own id. +- 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. From 0ce053531be62bdf21f6efb5f80dc50f39e33660 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 09:48:04 -0400 Subject: [PATCH 04/22] no-mistakes(review): Limit JVM shared-path error to differing owned emissions --- CHANGELOG.md | 4 +- .../migrations/jvm-codegen-hash-manifest.md | 4 +- docs/features/own-your-codegen.md | 5 +- .../generator/util/GeneratedFileWriter.java | 30 ++++++++---- .../mojo/MetaDataGeneratorMojo.java | 48 +++++++++---------- .../MetaDataGeneratorMojoManifestTest.java | 43 ++++++++++++++++- .../ADR-0059-jvm-codegen-hash-manifest.md | 5 +- 7 files changed, 95 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fdc416f2..8635d44e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -873,8 +873,8 @@ refused when edited, and `mvn metaobjects:verify` reports it with the matching r 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 emitted by two -generators fails the goal; two modules have two. User templates, api-docs pages and the `META-INF/services` registration are not +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. 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). diff --git a/docs/features/migrations/jvm-codegen-hash-manifest.md b/docs/features/migrations/jvm-codegen-hash-manifest.md index 877cf3f7b..a9c4e3fbf 100644 --- a/docs/features/migrations/jvm-codegen-hash-manifest.md +++ b/docs/features/migrations/jvm-codegen-hash-manifest.md @@ -76,8 +76,8 @@ If one module declares several `metaobjects:generate` executions, a run deletes 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 emitted by two generators in one build fails -the goal, naming both: configure only one generator to emit it. +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. 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. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index d31de72dc..416d92ab5 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -596,8 +596,9 @@ no `metaobjects:generate` execution of the module (nor the plugin-level configur configures its owner any more: a removed generator's files are deleted wherever they live. 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. -One file emitted by two generator/output-directory pairs in the same build fails the goal, -naming both. The first build on a manifest that names no generator owner records the owners +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-0059). `mvn metaobjects:verify` applies the same rule, so a removed generator's file is drift that `generate` deletes. 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 991d1fd5b..35afd5370 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 @@ -142,10 +142,12 @@ public static final class Run implements AutoCloseable { private Path projectRoot; // Every path write() was asked to produce this run, whatever the outcome — written, - // unchanged, refused or adopted — with the owner of each generator that asked. Its - // key set is the "emitted" set the caller's orphan sweep must not touch: a refused - // or adopted path is still spoken for. - private final Map> emittedOwners = new LinkedHashMap<>(); + // 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<>(); @@ -188,12 +190,13 @@ public Run configureManifest(Path genStateDir, Path projectRoot) { /** The files written, left in place, or adopted this run — the orphan sweep's * "do not touch" set. */ public Set emittedPaths() { - return emittedOwners.keySet(); + return emittedPaths; } - /** Each emitted path with the owners of the generators that emitted it. */ - public Map> emittedOwners() { - return emittedOwners; + /** 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 @@ -214,7 +217,14 @@ 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) { @@ -314,8 +324,7 @@ public enum Outcome { public static Outcome write(Path outFile, String content) throws IOException { Run run = ACTIVE_RUN.get(); if (run != null) { - run.emittedOwners.computeIfAbsent(outFile.toAbsolutePath().normalize(), k -> new LinkedHashSet<>()) - .add(run.currentOwner); + run.emittedPaths.add(outFile.toAbsolutePath().normalize()); } if (run == null || run.genStateDir == null) { @@ -377,6 +386,7 @@ public static Outcome write(Path outFile, String content) throws IOException { return Outcome.WRITTEN; } HashManifest.recordHash(run.genStateDir, key, recorded, run.currentOwner); + run.note(outFile, content); LOG.warn(refusedHandEditedMessage(outFile)); return Outcome.REFUSED; } 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 f9bd3397c..5101b24a9 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 @@ -111,7 +111,7 @@ protected void executeGenerators(MetaDataLoader loader, List generato } } - Set emittedThisBuild = refuseSharedPaths(run.emittedOwners()); + 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."); @@ -151,46 +151,44 @@ private void sweepOrphans(Path genStateDir, Path projectRoot, Map refuseSharedPaths(Map> emitted) { + private Set refuseSharedPaths(Map> emissions, Set emitted) { @SuppressWarnings("unchecked") - Map emittedBy = project == null ? null - : (Map) project.getContextValue(EMITTED_BY_CONTEXT_KEY); + 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<>(emitted.keySet())) { - Set owners = new LinkedHashSet<>(); - HashManifest.Owner previous = emittedBy.get(path); - if (previous != null) owners.add(previous); - for (HashManifest.Owner owner : emitted.get(path)) { - if (owner != null) owners.add(owner); - } - if (owners.isEmpty()) continue; - emittedBy.putIfAbsent(path, owners.iterator().next()); - if (owners.size() > 1) { - shared.add(path + " (" + owners.stream().map(o -> "'" + o + "'") - .collect(java.util.stream.Collectors.joining(" and ")) + ")"); + 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, so each would overwrite the other's output: " + String.join(", ", shared) - + ". Configure only one generator to emit it, in one output directory."); + + "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.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 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 index 8ef2d1aac..c7b19259b 100644 --- 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 @@ -382,7 +382,8 @@ public void generatorRemovedFromConfiguration_itsFilesAreDeletedEvenInADirectory public void onePathEmittedByTwoGenerators_isAnErrorNamingBoth() throws IOException { String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); GeneratorParam dto = gen(DtoGenerator.class, shared, "Dto.java"); - GeneratorParam repository = gen(RepositoryGenerator.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); @@ -396,6 +397,46 @@ public void onePathEmittedByTwoGenerators_isAnErrorNamingBoth() throws IOExcepti } } + 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"))); + } + /** 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 diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index 4abc858b6..7470631e2 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -51,8 +51,9 @@ does not cover deletion, and deletion without evidence is not safe on any port. 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. One path emitted by two generator/output-directory pairs in the same build - is an error naming both. The first build on a manifest that names no generator owner + directory. 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. 2. **The overwrite decision is the hash, not the marker.** For a file at a generated path: From 4a35aaaea5e6dfaf4fed1e97578ce0deb83e94dd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 09:55:47 -0400 Subject: [PATCH 05/22] no-mistakes(review): Count profile-declared generators as configured in JVM sweep --- docs/features/own-your-codegen.md | 2 + .../mojo/AbstractMetaDataMojo.java | 42 +++++-- .../mojo/MetaDataVerifyMojoManifestTest.java | 108 +++++++++++++++--- .../ADR-0059-jvm-codegen-hash-manifest.md | 6 +- 4 files changed, 131 insertions(+), 27 deletions(-) diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 416d92ab5..848295d43 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -594,6 +594,8 @@ registry id and its output directory within the module, not the Maven execution, 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, so a plain build +keeps the files of a generator only `-P` runs. 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. One file emitted with different content by two generator/output-directory pairs in the same 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 e9d966caf..514ddfaf3 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 @@ -19,6 +19,7 @@ import com.metaobjects.generator.util.HashManifest; import org.apache.maven.model.Plugin; import org.apache.maven.model.PluginExecution; +import org.apache.maven.model.Profile; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.MojoExecutionException; @@ -91,23 +92,46 @@ private HashManifest.Owner ownerOf(String classname, String outputDir) { /** * 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. {@code null} when the module's build model - * does not declare this plugin, so what is configured is unknown. + * configuration a command-line run uses, in the main build and in every profile, + * active or not, so a profile switched off for one build does not make its generators' + * files look stranded. {@code null} when the module's build model declares this plugin + * nowhere, so what is configured is unknown. */ protected Set configuredOwners() { if (project == null) return null; - Plugin plugin = project.getPlugin(PLUGIN_KEY); - if (plugin == null) return null; + Plugin main = project.getPlugin(PLUGIN_KEY); + List plugins = new ArrayList<>(); + if (main != null) plugins.add(main); + List profiles = project.getModel() != null ? project.getModel().getProfiles() : List.of(); + for (Profile profile : profiles) { + Plugin plugin = profile.getBuild() != null ? profile.getBuild().getPluginsAsMap().get(PLUGIN_KEY) : null; + if (plugin != null) plugins.add(plugin); + } + if (plugins.isEmpty()) return null; + Object mainLevel = main != null ? main.getConfiguration() : null; Set out = new HashSet<>(); - addConfiguredOwners(plugin.getConfiguration(), out); - for (PluginExecution e : plugin.getExecutions()) { - if (e.getGoals().contains("generate")) addConfiguredOwners(e.getConfiguration(), out); + for (Plugin plugin : plugins) { + Xpp3Dom pluginLevel = plugin == main ? merged(mainLevel, null) : merged(plugin.getConfiguration(), mainLevel); + addConfiguredOwners(pluginLevel, out); + for (PluginExecution e : plugin.getExecutions()) { + if (!e.getGoals().contains("generate")) continue; + addConfiguredOwners(plugin == main ? merged(e.getConfiguration(), null) + : merged(e.getConfiguration(), pluginLevel), out); + } } return out; } - private void addConfiguredOwners(Object configuration, Set out) { - if (!(configuration instanceof Xpp3Dom dom)) return; + /** {@code dominant} over {@code recessive}, as Maven merges an execution's configuration + * over its plugin's; {@code null} when neither is a configuration. */ + private static Xpp3Dom merged(Object dominant, Object recessive) { + Xpp3Dom r = recessive instanceof Xpp3Dom dom ? dom : null; + if (!(dominant instanceof Xpp3Dom d)) return r; + return r == null ? d : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(d), r); + } + + private void addConfiguredOwners(Xpp3Dom dom, Set out) { + if (dom == null) return; Map domGlobals = childValues(dom.getChild("globals")); Xpp3Dom generators = dom.getChild("generators"); if (generators == null) return; 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 index 2feb4e05f..9d2aa1bc1 100644 --- 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 @@ -6,7 +6,9 @@ import com.metaobjects.loader.MetaDataLoader; import org.apache.maven.model.Build; import org.apache.maven.model.Plugin; +import org.apache.maven.model.BuildBase; import org.apache.maven.model.PluginExecution; +import org.apache.maven.model.Profile; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugin.descriptor.MojoDescriptor; @@ -248,6 +250,44 @@ private GeneratorParam gen(Class generator, String outDir, String filename) { /** 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; + } + + /** Declare profile {@code id} running {@code generators} in an execution of the same + * id. When it is active, Maven's effective build also carries that execution. */ + private MavenProject withProfile(MavenProject module, String id, boolean active, GeneratorParam... generators) { + BuildBase profileBuild = new BuildBase(); + profileBuild.addPlugin(plugin(id, configuration(generators))); + Profile profile = new Profile(); + profile.setId(id); + profile.setBuild(profileBuild); + module.getModel().addProfile(profile); + if (active) { + module.getPlugin(AbstractMetaDataMojo.PLUGIN_KEY).addExecution( + plugin(id, configuration(generators)).getExecutions().get(0)); + } + 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) { @@ -263,28 +303,18 @@ private MavenProject module(Path basedir, GeneratorParam... generators) { list.addChild(generator); } configuration.addChild(list); - Plugin plugin = new Plugin(); - plugin.setGroupId("com.metaobjects"); - plugin.setArtifactId("metaobjects-maven-plugin"); - PluginExecution execution = new PluginExecution(); - execution.setId("codegen"); - execution.addGoal("generate"); - execution.setConfiguration(configuration); - plugin.addExecution(execution); - Build build = new Build(); - build.addPlugin(plugin); - MavenProject module = new MavenProject(); - module.setArtifactId("app"); - module.setFile(basedir.resolve("pom.xml").toFile()); - module.getModel().setBuild(build); - return module; + return configuration; } private void generateIn(MavenProject module, GeneratorParam... params) { + generateIn(module, "codegen", params); + } + + private void generateIn(MavenProject module, String executionId, GeneratorParam... params) { MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); gen.setLoader(new LoaderParam()); gen.project = module; - gen.execution = new MojoExecution(new MojoDescriptor(), "codegen"); + gen.execution = new MojoExecution(new MojoDescriptor(), executionId); gen.setGenerators(List.of(params)); gen.executeGenerators(null, gen.buildGenerators(getClass().getClassLoader(), null)); } @@ -335,4 +365,50 @@ public void removedGeneratorsFiles_areDriftThatGenerateDeletes() throws Exceptio && e.getMessage().contains("'mvn metaobjects:generate' deletes it")); } } + + public static class ClientGenerator extends ConfigurableFileGenerator {} + + /** {@code mvn -Pclient compile}, then a plain {@code mvn compile} and a plain + * {@code mvn metaobjects:verify}: the inactive profile still configures its generator, + * so its files are neither deleted nor drift. */ + @Test + public void inactiveProfilesGeneratorFiles_areKept_andVerifyStaysGreen() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String mainDir = tempFolder.newFolder("gen").getAbsolutePath(); + String clientDir = tempFolder.newFolder("client").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, mainDir, "Dto.java"); + GeneratorParam client = gen(ClientGenerator.class, clientDir, "Client.java"); + + MavenProject withClient = withProfile(module(basedir, dto), "client", true, client); + generateIn(withClient, "codegen", dto); + generateIn(withClient, "client", client); + + generateIn(withProfile(module(basedir, dto), "client", false, client), "codegen", dto); + assertTrue(Files.exists(Path.of(clientDir, "Client.java"))); + + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + verify.project = withProfile(module(basedir, dto), "client", false, client); + verify.setLoader(testLoader()); + verify.setGenerators(List.of(dto)); + verify.execute(); + } + + /** A generator removed from the profile is configured nowhere, so its files are cleaned + * even while the profile is off. */ + @Test + public void generatorRemovedFromAProfile_itsFilesAreDeleted() throws Exception { + Path basedir = tempFolder.getRoot().toPath(); + String mainDir = tempFolder.newFolder("gen").getAbsolutePath(); + String clientDir = tempFolder.newFolder("client").getAbsolutePath(); + GeneratorParam dto = gen(DtoGenerator.class, mainDir, "Dto.java"); + GeneratorParam client = gen(ClientGenerator.class, clientDir, "Client.java"); + + MavenProject withClient = withProfile(module(basedir, dto), "client", true, client); + generateIn(withClient, "codegen", dto); + generateIn(withClient, "client", client); + + generateIn(withProfile(module(basedir, dto), "client", false), "codegen", dto); + + assertFalse(Files.exists(Path.of(clientDir, "Client.java"))); + } } diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index 7470631e2..e292b4526 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -44,8 +44,10 @@ does not cover deletion, and deletion without evidence is not safe on any port. 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 reads the module's `metaobjects:generate` executions and plugin-level - configuration from the build model to know every generator/output-directory pair the - module configures. A recorded file is an orphan when no generator emitted it in this + configuration from the build model, in the main build and in every profile whether + active or not, to know every generator/output-directory pair the module configures. A + pair declared only in a profile that is off for this build is still configured, so its + files are never treated as stranded. 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) From b7d74b38dffecaa3088019c5e36c133257ba15f2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 10:07:33 -0400 Subject: [PATCH 06/22] no-mistakes(review): Read profile generators via Maven; ports keep JVM entries --- docs/features/own-your-codegen.md | 6 +- .../OverwriteHashManifestTests.cs | 28 ++ .../MetaObjects.Codegen/HashManifest.cs | 51 +++- .../mojo/AbstractMetaDataMojo.java | 80 +++-- .../mojo/ConfiguredGeneratorsTest.java | 285 ++++++++++++++++++ .../MetaDataGeneratorMojoManifestTest.java | 47 --- .../mojo/MetaDataVerifyMojoManifestTest.java | 95 +----- .../metaobjects/codegen/overwrite_policy.py | 30 +- .../codegen/test_overwrite_policy_hash.py | 27 ++ .../codegen-ts/src/overwrite-policy.ts | 34 ++- .../codegen-ts/test/overwrite-policy.test.ts | 33 +- .../ADR-0059-jvm-codegen-hash-manifest.md | 17 +- 12 files changed, 536 insertions(+), 197 deletions(-) create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ConfiguredGeneratorsTest.java diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 848295d43..ed59c03ec 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -594,8 +594,10 @@ registry id and its output directory within the module, not the Maven execution, 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, so a plain build -keeps the files of a generator only `-P` runs. +Executions inside a profile count whether the profile is active or not, including profiles a +parent POM declares: Maven builds the module with every profile on to read them, so a profile's +own properties resolve as they do under `-P`, and a plain build keeps the files of a +generator only `-P` runs. 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. One file emitted with different content by two generator/output-directory pairs in the same diff --git a/server/csharp/MetaObjects.Codegen.Tests/OverwriteHashManifestTests.cs b/server/csharp/MetaObjects.Codegen.Tests/OverwriteHashManifestTests.cs index 9e86e4e16..541472b5f 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/OverwriteHashManifestTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/OverwriteHashManifestTests.cs @@ -35,6 +35,34 @@ public class OverwriteHashManifestTests : IDisposable private string StateDir => Path.Combine(_root, ".metaobjects", ".gen-state"); private string TargetPath => Path.Combine(OutDir, "A.cs"); + [Fact] + public void JvmObjectEntry_IsReadAsRecordedHashAndPreservedOnRecord() + { + Directory.CreateDirectory(StateDir); + var jvmHash = HashManifest.ContentHash("jvm body"); + File.WriteAllText(Path.Combine(StateDir, ".hashes.json"), + "{\n \"Jvm.java\": { \"hash\": \"" + jvmHash + "\", \"generator\": \"java:dto\", \"outputDir\": \"target/gen\" },\n \"Plain.txt\": \"abc\"\n}\n"); + + Assert.True(HashManifest.IsPristine(StateDir, "Jvm.java", "jvm body")); + + HashManifest.Record(StateDir, "Other.cs", "other"); + + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(StateDir, ".hashes.json"))); + var jvm = doc.RootElement.GetProperty("Jvm.java"); + Assert.Equal(JsonValueKind.Object, jvm.ValueKind); + Assert.Equal(jvmHash, jvm.GetProperty("hash").GetString()); + Assert.Equal("java:dto", jvm.GetProperty("generator").GetString()); + Assert.Equal("target/gen", jvm.GetProperty("outputDir").GetString()); + Assert.Equal("abc", doc.RootElement.GetProperty("Plain.txt").GetString()); + Assert.Equal(HashManifest.ContentHash("other"), doc.RootElement.GetProperty("Other.cs").GetString()); + + HashManifest.Record(StateDir, "Jvm.java", "jvm body v2"); + using var after = JsonDocument.Parse(File.ReadAllText(Path.Combine(StateDir, ".hashes.json"))); + var updated = after.RootElement.GetProperty("Jvm.java"); + Assert.Equal(HashManifest.ContentHash("jvm body v2"), updated.GetProperty("hash").GetString()); + Assert.Equal("java:dto", updated.GetProperty("generator").GetString()); + } + public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); diff --git a/server/csharp/MetaObjects.Codegen/HashManifest.cs b/server/csharp/MetaObjects.Codegen/HashManifest.cs index ae8c52cf4..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"); } 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 514ddfaf3..adec9f293 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 @@ -19,13 +19,20 @@ 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; @@ -60,6 +67,12 @@ 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; @@ -92,42 +105,51 @@ private HashManifest.Owner ownerOf(String classname, String outputDir) { /** * 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, in the main build and in every profile, - * active or not, so a profile switched off for one build does not make its generators' - * files look stranded. {@code null} when the module's build model declares this plugin - * nowhere, so what is configured is unknown. + * configuration a command-line run uses, with every profile of the module and its + * parents active, so a profile switched off for one build does not make its + * generators' files look stranded. Maven builds that model, so profile properties, + * inherited profiles and basedirs resolve as they do when the profile is on. + * {@code null} when that model cannot be built or does not declare this plugin, so + * what is configured is unknown. */ protected Set configuredOwners() { - if (project == null) return null; - Plugin main = project.getPlugin(PLUGIN_KEY); - List plugins = new ArrayList<>(); - if (main != null) plugins.add(main); - List profiles = project.getModel() != null ? project.getModel().getProfiles() : List.of(); - for (Profile profile : profiles) { - Plugin plugin = profile.getBuild() != null ? profile.getBuild().getPluginsAsMap().get(PLUGIN_KEY) : null; - if (plugin != null) plugins.add(plugin); - } - if (plugins.isEmpty()) return null; - Object mainLevel = main != null ? main.getConfiguration() : null; + MavenProject all = allProfilesProject(); + Plugin plugin = all != null ? all.getPlugin(PLUGIN_KEY) : null; + if (plugin == null) return null; Set out = new HashSet<>(); - for (Plugin plugin : plugins) { - Xpp3Dom pluginLevel = plugin == main ? merged(mainLevel, null) : merged(plugin.getConfiguration(), mainLevel); - addConfiguredOwners(pluginLevel, out); - for (PluginExecution e : plugin.getExecutions()) { - if (!e.getGoals().contains("generate")) continue; - addConfiguredOwners(plugin == main ? merged(e.getConfiguration(), null) - : merged(e.getConfiguration(), pluginLevel), out); - } + Xpp3Dom pluginLevel = plugin.getConfiguration() instanceof Xpp3Dom dom ? dom : null; + addConfiguredOwners(pluginLevel, out); + for (PluginExecution e : plugin.getExecutions()) { + if (!e.getGoals().contains("generate")) continue; + if (!(e.getConfiguration() instanceof Xpp3Dom execution)) continue; + addConfiguredOwners(pluginLevel == null ? execution + : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(execution), pluginLevel), out); } return out; } - /** {@code dominant} over {@code recessive}, as Maven merges an execution's configuration - * over its plugin's; {@code null} when neither is a configuration. */ - private static Xpp3Dom merged(Object dominant, Object recessive) { - Xpp3Dom r = recessive instanceof Xpp3Dom dom ? dom : null; - if (!(dominant instanceof Xpp3Dom d)) return r; - return r == null ? d : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(d), r); + /** This module built by Maven with every profile it and its parents declare active; + * {@code null} outside a Maven session or when the build fails. */ + private MavenProject allProfilesProject() { + if (project == null || project.getFile() == null || projectBuilder == null || session == null) return null; + List profileIds = new ArrayList<>(); + for (MavenProject p = project; p != null; p = p.getParent()) { + if (p.getModel() == null) continue; + for (Profile profile : p.getModel().getProfiles()) profileIds.add(profile.getId()); + } + ProjectBuildingRequest request = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest()); + request.setActiveProfileIds(profileIds); + request.setInactiveProfileIds(List.of()); + 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; + } } private void addConfiguredOwners(Xpp3Dom dom, Set out) { 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..74e891bbe --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ConfiguredGeneratorsTest.java @@ -0,0 +1,285 @@ +package com.metaobjects.mojo; + +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.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 + * every profile on: 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(); + } + + 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 = rule.lookup(ProjectBuilder.class); + 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 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"))); + } + + /** 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/MetaDataGeneratorMojoManifestTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataGeneratorMojoManifestTest.java index c7b19259b..44b93a6e4 100644 --- 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 @@ -361,23 +361,6 @@ public void renamedExecution_stillSweepsTheOrphansTheOldIdWrote() throws IOExcep assertTrue(Files.exists(Path.of(outDir, "TelemetryReading.java"))); } - @Test - public void generatorRemovedFromConfiguration_itsFilesAreDeletedEvenInADirectoryNothingWrites() - throws IOException { - String genDir = tempFolder.newFolder("gen").getAbsolutePath(); - String webDir = tempFolder.newFolder("web").getAbsolutePath(); - GeneratorParam dto = gen(DtoGenerator.class, genDir, "Dto.java"); - GeneratorParam route = gen(RouteGenerator.class, webDir, "Route.java"); - runExecution(newBuild(null, Map.of("codegen", configuration(dto, route))), "codegen", dto, route); - assertTrue(Files.exists(Path.of(webDir, "Route.java"))); - - runExecution(newBuild(null, Map.of("codegen", configuration(dto))), "codegen", dto); - - assertFalse("no execution configures its generator any more", Files.exists(Path.of(webDir, "Route.java"))); - assertFalse(manifest().containsKey("web/Route.java")); - assertTrue(Files.exists(Path.of(genDir, "Dto.java"))); - } - @Test public void onePathEmittedByTwoGenerators_isAnErrorNamingBoth() throws IOException { String shared = tempFolder.newFolder("generated-sources").getAbsolutePath(); @@ -437,36 +420,6 @@ public void byteIdenticalReEmissionByTwoGenerators_isOneFile() throws IOExceptio assertEquals(MARKER_BODY, Files.readString(Path.of(shared, "Status.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 IOException { - String outDir = tempFolder.newFolder("gen").getAbsolutePath(); - Path kept = Path.of(outDir, "Kept.java"); - Path old = Path.of(outDir, "Old.java"); - Files.writeString(kept, MARKER_BODY); - Files.writeString(old, MARKER_BODY); - Path genState = projectRoot.resolve(".metaobjects").resolve(".gen-state"); - Files.createDirectories(genState); - String hash = HashManifest.contentHash(MARKER_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"); - GeneratorParam dto = gen(DtoGenerator.class, outDir, "Kept.java"); - Map executions = Map.of("codegen", configuration(dto)); - - runExecution(newBuild(null, executions), "codegen", dto); - - assertTrue("the build that records owners deletes nothing", Files.exists(old)); - assertEquals(new HashManifest.Owner(DtoGenerator.class.getName(), "gen"), owners().get("gen/Kept.java")); - assertFalse(owners().containsKey("gen/Old.java")); - - runExecution(newBuild(null, executions), "codegen", dto); - - assertFalse("recorded in this run's directory, emitted by nothing", Files.exists(old)); - assertTrue(Files.exists(kept)); - } - // === multi-module: two basedirs, two manifests =============================== @Test 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 index 9d2aa1bc1..9d37a4259 100644 --- 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 @@ -6,9 +6,7 @@ import com.metaobjects.loader.MetaDataLoader; import org.apache.maven.model.Build; import org.apache.maven.model.Plugin; -import org.apache.maven.model.BuildBase; import org.apache.maven.model.PluginExecution; -import org.apache.maven.model.Profile; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugin.descriptor.MojoDescriptor; @@ -259,22 +257,6 @@ private MavenProject module(Path basedir, GeneratorParam... generators) { return module; } - /** Declare profile {@code id} running {@code generators} in an execution of the same - * id. When it is active, Maven's effective build also carries that execution. */ - private MavenProject withProfile(MavenProject module, String id, boolean active, GeneratorParam... generators) { - BuildBase profileBuild = new BuildBase(); - profileBuild.addPlugin(plugin(id, configuration(generators))); - Profile profile = new Profile(); - profile.setId(id); - profile.setBuild(profileBuild); - module.getModel().addProfile(profile); - if (active) { - module.getPlugin(AbstractMetaDataMojo.PLUGIN_KEY).addExecution( - plugin(id, configuration(generators)).getExecutions().get(0)); - } - return module; - } - private static Plugin plugin(String executionId, Xpp3Dom configuration) { Plugin plugin = new Plugin(); plugin.setGroupId("com.metaobjects"); @@ -307,14 +289,10 @@ private static Xpp3Dom configuration(GeneratorParam... generators) { } private void generateIn(MavenProject module, GeneratorParam... params) { - generateIn(module, "codegen", params); - } - - private void generateIn(MavenProject module, String executionId, GeneratorParam... params) { MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); gen.setLoader(new LoaderParam()); gen.project = module; - gen.execution = new MojoExecution(new MojoDescriptor(), executionId); + gen.execution = new MojoExecution(new MojoDescriptor(), "codegen"); gen.setGenerators(List.of(params)); gen.executeGenerators(null, gen.buildGenerators(getClass().getClassLoader(), null)); } @@ -340,75 +318,4 @@ public void sharedOutputDir_anotherConfiguredGeneratorsFiles_areNotDrift() throw verify.execute(); } - - /** A generator removed from the configuration leaves files only {@code generate} - * deletes; verify reports them with that remedy, wherever they live. */ - @Test - public void removedGeneratorsFiles_areDriftThatGenerateDeletes() throws Exception { - Path basedir = tempFolder.getRoot().toPath(); - String genDir = tempFolder.newFolder("gen").getAbsolutePath(); - String webDir = tempFolder.newFolder("web").getAbsolutePath(); - GeneratorParam dto = gen(DtoGenerator.class, genDir, "Dto.java"); - GeneratorParam route = gen(RepositoryGenerator.class, webDir, "Route.java"); - generateIn(module(basedir, dto, route), dto, route); - - MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); - verify.project = module(basedir, dto); - verify.setLoader(testLoader()); - verify.setGenerators(List.of(dto)); - - try { - verify.execute(); - fail("expected MojoFailureException"); - } catch (MojoFailureException e) { - assertTrue(e.getMessage(), e.getMessage().contains("Route.java") - && e.getMessage().contains("'mvn metaobjects:generate' deletes it")); - } - } - - public static class ClientGenerator extends ConfigurableFileGenerator {} - - /** {@code mvn -Pclient compile}, then a plain {@code mvn compile} and a plain - * {@code mvn metaobjects:verify}: the inactive profile still configures its generator, - * so its files are neither deleted nor drift. */ - @Test - public void inactiveProfilesGeneratorFiles_areKept_andVerifyStaysGreen() throws Exception { - Path basedir = tempFolder.getRoot().toPath(); - String mainDir = tempFolder.newFolder("gen").getAbsolutePath(); - String clientDir = tempFolder.newFolder("client").getAbsolutePath(); - GeneratorParam dto = gen(DtoGenerator.class, mainDir, "Dto.java"); - GeneratorParam client = gen(ClientGenerator.class, clientDir, "Client.java"); - - MavenProject withClient = withProfile(module(basedir, dto), "client", true, client); - generateIn(withClient, "codegen", dto); - generateIn(withClient, "client", client); - - generateIn(withProfile(module(basedir, dto), "client", false, client), "codegen", dto); - assertTrue(Files.exists(Path.of(clientDir, "Client.java"))); - - MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); - verify.project = withProfile(module(basedir, dto), "client", false, client); - verify.setLoader(testLoader()); - verify.setGenerators(List.of(dto)); - verify.execute(); - } - - /** A generator removed from the profile is configured nowhere, so its files are cleaned - * even while the profile is off. */ - @Test - public void generatorRemovedFromAProfile_itsFilesAreDeleted() throws Exception { - Path basedir = tempFolder.getRoot().toPath(); - String mainDir = tempFolder.newFolder("gen").getAbsolutePath(); - String clientDir = tempFolder.newFolder("client").getAbsolutePath(); - GeneratorParam dto = gen(DtoGenerator.class, mainDir, "Dto.java"); - GeneratorParam client = gen(ClientGenerator.class, clientDir, "Client.java"); - - MavenProject withClient = withProfile(module(basedir, dto), "client", true, client); - generateIn(withClient, "codegen", dto); - generateIn(withClient, "client", client); - - generateIn(withProfile(module(basedir, dto), "client", false), "codegen", dto); - - assertFalse(Files.exists(Path.of(clientDir, "Client.java"))); - } } diff --git a/server/python/src/metaobjects/codegen/overwrite_policy.py b/server/python/src/metaobjects/codegen/overwrite_policy.py index bca643934..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") 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/typescript/packages/codegen-ts/src/overwrite-policy.ts b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts index 16ec956a8..a1b0d45b5 100644 --- a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts +++ b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts @@ -150,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 { @@ -165,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"); } 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/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index e292b4526..64c7f26fb 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -43,11 +43,14 @@ does not cover deletion, and deletion without evidence is not safe on any port. `"": { "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 reads the module's `metaobjects:generate` executions and plugin-level - configuration from the build model, in the main build and in every profile whether - active or not, to know every generator/output-directory pair the module configures. A - pair declared only in a profile that is off for this build is still configured, so its - files are never treated as stranded. A recorded file is an orphan when no generator emitted it in this + sweep asks Maven to build the module again with every profile the module and its + parent POMs declare switched on, using this build's request settings, and reads the + `metaobjects:generate` executions and plugin-level configuration of that model to know + every generator/output-directory pair the module configures. Maven resolves profile-only + properties, inherited profiles and basedirs there exactly as on a build with the + profile on, so a pair declared only in a profile that is off for this build is still + configured and its files are never treated as stranded. When that 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) @@ -57,7 +60,9 @@ does not cover deletion, and deletion without evidence is not safe on any port. 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. + 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; From 4151ce4f541919c616d32653276f0eae6120a50f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 11:56:48 -0400 Subject: [PATCH 07/22] feat(metadata): references resolve once, after the tree, in every port (ADR-0059) A reference was interpreted in two places: the YAML desugar rewrote reference values while lowering, and canonical JSON took them as written, refused `::` and `..::`, and resolved them later in each port's loader. The two paths disagreed, so the same model loaded differently from YAML and from JSON, and depended on file order. Now every source keeps references exactly as written. One resolver per port binds them after the whole tree has loaded and validated, records the bound target and the authored spelling, and rewrites each value to its canonical spelling before freeze. - One grammar in every source: `::Name` (root-absolute), `..::Name` (parent-relative, over-drop is ERR_BAD_ATTR_VALUE), `pkg::Name`, and a bare name (own file's package, then root). ERR_RELATIVE_REF_IN_CANONICAL is retired. - The referrer package is the package of the top-level node that declares the attribute, so an inherited reference means what it meant where it was written. `@of` after `@via` uses the package the walk reaches, and `@spine` uses the report's `@from` package. - The default package is per file, nested nodes included. - The serializer writes canonical values plus a package rule that lets a multi-package export load back to itself. `meta fmt` keeps authored spellings. - Consumers in codegen and runtime read the bound target (TS `refTarget`), never a short-name match. `findObject` accepts canonical values. Human-facing text keeps quoting the authored spelling. - Two new gates in every port: the YAML twin (each fixture loads to the same model from JSON and from YAML) and the round trip (each expected.json serializes back to itself), with zero exclusions. - YAML-twin defects fixed: C# YAML overlays now defer, and Java accepts a non-string field `default:` and list-valued `aliases`/`seeAlso`. - Shared goldens regenerated from the TypeScript oracle. Ten new conformance fixtures; one retired. --- AGENTS.md | 2 +- CHANGELOG.md | 63 ++- README.md | 2 +- .../references/abstracts-and-overlay.md | 6 +- docs/CONFORMANCE.md | 7 +- docs/features/abstracts-and-inheritance.md | 7 +- docs/features/codegen-data-shapes.md | 2 +- examples/showcase/site-payload.json | 2 +- .../references/abstracts-and-overlay.md | 6 +- .../references/abstracts-and-overlay.md | 6 +- .../references/abstracts-and-overlay.md | 6 +- .../references/abstracts-and-overlay.md | 6 +- .../references/abstracts-and-overlay.md | 6 +- fixtures/conformance/ERROR-CODES.json | 1 - .../ai-llm-call-extends/expected.json | 4 +- .../collect-whole-object/expected.json | 12 +- .../expected-errors.json | 17 + .../input/meta.acme.json | 13 + .../input/meta.other.json | 19 + .../providers.json | 1 + .../expected-errors.json | 17 + .../input/meta.a.json | 9 + .../input/meta.b.json | 11 + .../providers.json | 1 + .../expected-errors.json | 17 + .../input/meta.acme.json | 25 ++ .../error-ref-parent-over-drop/providers.json | 1 + .../expected-errors.json | 1 - .../input/meta.bad.json | 25 -- .../expected-effective.json | 46 +++ .../expected.json | 39 ++ .../input/meta.a.json | 19 + .../input/meta.b.json | 25 ++ .../providers.json | 1 + .../expected-effective.json | 4 +- .../expected.json | 4 +- .../expected.json | 3 +- .../expected-effective.json | 51 +++ .../expected.json | 51 +++ .../input/meta.a.json | 9 + .../input/meta.b.json | 11 + .../providers.json | 1 + .../expected-effective.json | 21 +- .../flattened-kitchen-sink/expected.json | 21 +- .../expected.json | 2 +- .../expected.json | 5 +- .../expected-effective.json | 5 +- .../expected.json | 5 +- .../expected-effective.json | 31 ++ .../package-default-per-file/expected.json | 31 ++ .../input/meta.a.json | 19 + .../input/meta.b.json | 18 + .../package-default-per-file/providers.json | 1 + .../expected-effective.json | 59 +++ .../ref-bare-local-shadows-root/expected.json | 52 +++ .../input/meta.acme.json | 38 ++ .../input/meta.root.json | 18 + .../providers.json | 1 + .../expected-effective.json | 61 +++ .../expected.json | 60 +++ .../input/meta.base.json | 32 ++ .../input/meta.copy.json | 32 ++ .../providers.json | 1 + .../expected-effective.json | 52 +++ .../ref-parent-relative/expected.json | 47 +++ .../ref-parent-relative/input/meta.acme.json | 31 ++ .../ref-parent-relative/input/meta.sub.json | 21 + .../ref-parent-relative/providers.json | 1 + .../expected-effective.json | 59 +++ .../ref-root-escape-shadowed/expected.json | 52 +++ .../input/meta.acme.json | 38 ++ .../input/meta.root.json | 18 + .../ref-root-escape-shadowed/providers.json | 1 + .../relationship-m2m-hetero/expected.json | 2 +- .../expected.json | 2 +- .../expected.json | 2 +- .../reporting-any-all-predicate/expected.json | 6 +- .../reporting-boolean-measures/expected.json | 10 +- .../expected.json | 12 +- .../reporting-inherited-members/expected.json | 8 +- .../expected.json | 10 +- .../reporting-spine-and-default/expected.json | 50 +-- .../reporting-spine-inherited/expected.json | 20 +- .../reporting-vocabulary/expected.json | 46 +-- .../expected.json | 5 +- .../expected.json | 5 +- .../reverse-finders-same-pair/expected.json | 13 +- .../conformance/round-trip-exclusions.json | 5 + .../expected-effective.json | 65 ++++ .../expected.json | 65 ++++ .../input/meta.catalog.json | 19 + .../input/meta.orders.json | 38 ++ .../input/meta.people.json | 19 + .../providers.json | 1 + .../expected.json | 2 +- .../expected-effective.json | 7 +- .../expected.json | 7 +- .../expected-effective.json | 5 +- .../xpkg-collision-extends-fqn/expected.json | 5 +- .../xpkg-field-object/expected-effective.json | 4 +- .../xpkg-field-object/expected.json | 4 +- .../expected-effective.json | 5 +- .../xpkg-m2n-collision/expected.json | 5 +- .../xpkg-m2n-through/expected-effective.json | 5 +- .../xpkg-m2n-through/expected.json | 5 +- .../expected-effective.json | 5 +- .../xpkg-origin-passthrough/expected.json | 5 +- .../expected-effective.json | 4 +- .../xpkg-relationship-objectref/expected.json | 4 +- .../conformance/yaml-twin-exclusions.json | 15 + .../expected/Order.txt | 2 +- .../yaml-ref-parent-relative/expected.json | 47 +++ .../yaml-ref-parent-relative/input.yaml | 21 + .../yaml-ref-root-escape/expected.json | 52 +++ .../yaml-ref-root-escape/input.yaml | 24 ++ .../yaml-reporting-via-of-bare/expected.json | 117 ++++++ .../yaml-reporting-via-of-bare/input.yaml | 53 +++ scripts/check-doc-examples.ts | 1 - server/csharp/MetaObjects.Cli/FmtCommand.cs | 2 +- .../ExtractTierCollisionTests.cs | 2 +- .../Fr015CodegenTests.cs | 2 +- .../TemplateDataTests.cs | 2 +- .../Generators/EntityGenerator.cs | 2 +- .../Generators/ExtractDelegateEmitter.cs | 6 +- .../Generators/M2MNavigation.cs | 4 +- .../Runtime/ExtractObject.cs | 42 +- .../MetaObjects.Codegen/ValueObjectNames.cs | 12 +- .../ConformanceAdapter.cs | 19 +- .../IdentityReferenceTests.cs | 4 +- .../ReportShapeTests.cs | 2 +- .../ReportingAccessorsTests.cs | 14 +- .../TemplateToolcallTests.cs | 2 +- .../YamlTwinConformanceTests.cs | 89 +++++ server/csharp/MetaObjects/Errors.cs | 4 - .../MetaObjects/Loader/MetaDataLoader.cs | 13 + .../MetaObjects/Loader/ResolveReferences.cs | 119 ++++++ .../Loader/ValidationPasses.Reporting.cs | 8 + server/csharp/MetaObjects/Meta/MetaData.cs | 80 +++- server/csharp/MetaObjects/Meta/MetaRoot.cs | 8 +- server/csharp/MetaObjects/NamingRefs.cs | 267 +++++++++++-- server/csharp/MetaObjects/Parser.cs | 86 +---- server/csharp/MetaObjects/ParserYaml.cs | 3 + server/csharp/MetaObjects/SerializerJson.cs | 76 +++- server/csharp/MetaObjects/SuperResolve.cs | 147 +++---- .../Validation/RegisteredValidation.cs | 37 +- server/csharp/MetaObjects/YamlDesugar.cs | 203 +--------- .../generator/template/TemplateDataTest.java | 2 +- .../generator/kotlin/KotlinGenUtil.kt | 13 +- .../kotlin/KotlinRenderHelperGenerator.kt | 23 +- .../direct/plantuml/PlantUMLWriter.java | 3 +- .../generator/spring/SpringM2mSupport.java | 16 +- .../java/com/metaobjects/mojo/FmtSupport.java | 2 +- .../com/metaobjects/mojo/FmtSupportTest.java | 16 +- server/java/metadata-ktx/README.md | 2 +- .../metaobjects/metadata/ktx/Relationships.kt | 20 +- .../metadata/ktx/MetaObjectsTest.kt | 2 +- .../com/metaobjects/metadata/ktx/README.kt | 2 +- .../main/java/com/metaobjects/ErrorCode.java | 9 - .../main/java/com/metaobjects/MetaData.java | 34 ++ .../main/java/com/metaobjects/MetaRoot.java | 4 +- .../com/metaobjects/attr/MetaAttribute.java | 16 + .../com/metaobjects/attr/StringAttribute.java | 9 + .../io/json/CanonicalJsonSerializer.java | 122 +++++- .../metaobjects/loader/DirectorySource.java | 2 +- .../metaobjects/loader/MetaDataLoader.java | 82 ++-- .../com/metaobjects/loader/References.java | 363 ++++++++++++++++++ .../loader/ReportingValidation.java | 10 + .../metaobjects/loader/ValidationPhase.java | 30 +- .../loader/parser/BaseMetaDataParser.java | 89 +---- .../parser/json/CanonicalJsonParser.java | 57 --- .../loader/parser/yaml/YamlDesugar.java | 168 ++------ .../com/metaobjects/object/MetaObject.java | 7 +- .../util/ErrorMessageConstants.java | 9 - .../com/metaobjects/util/MetaDataUtil.java | 31 +- .../metaobjects/validation/SymbolTable.java | 28 +- .../ConformanceExclusionsTest.java | 69 ++++ .../conformance/ConformanceTest.java | 8 +- .../conformance/YamlTwinConformanceTest.java | 215 +++++++++++ .../io/json/CanonicalJsonSerializerTest.java | 13 +- .../loader/OverlayFileDefaultPackageTest.java | 76 ++++ .../parser/json/CanonicalJsonParserTest.java | 3 + .../relationship/M2MSlimVocabularyTest.java | 6 +- .../metaobjects/reporting/ReportingTest.java | 20 +- .../template/TemplateLoaderTest.java | 2 +- .../template/TemplateToolcallTest.java | 2 +- .../metaobjects/manager/db/M2MResolver.java | 23 +- .../codegen/runtime/m2m_codegen.py | 24 +- server/python/src/metaobjects/errors.py | 5 - server/python/src/metaobjects/fmt.py | 2 +- .../metaobjects/loader/meta_data_loader.py | 5 + .../loader/registered_validation.py | 66 ++-- .../metaobjects/loader/resolve_references.py | 154 ++++++++ .../metaobjects/loader/validate_reporting.py | 8 + .../loader/validate_source_parameter_ref.py | 4 +- .../metaobjects/loader/validation_passes.py | 16 +- .../metaobjects/meta/core/attr/meta_attr.py | 7 + .../meta/core/object/object_extract.py | 26 +- .../core/relationship/derive_m2m_fields.py | 79 +--- .../relationship/relationship_references.py | 30 +- .../meta/core/reporting/report_shape.py | 10 +- .../meta/core/requirement/resolve_claim.py | 31 +- .../python/src/metaobjects/meta/meta_data.py | 50 ++- server/python/src/metaobjects/naming_refs.py | 335 ++++++++++------ server/python/src/metaobjects/parser.py | 54 +-- .../src/metaobjects/runtime/n2m_resolver.py | 26 +- .../metaobjects/runtime/validator_runner.py | 4 +- .../python/src/metaobjects/serializer_json.py | 102 ++++- .../python/src/metaobjects/super_resolve.py | 181 ++++----- server/python/src/metaobjects/yaml_desugar.py | 83 +--- .../tests/codegen/test_template_data.py | 2 +- .../test_conformance_twin_roundtrip.py | 122 ++++++ .../conformance/test_yaml_conformance.py | 2 + .../tests/unit/test_effective_package.py | 4 +- .../tests/unit/test_reporting_accessors.py | 22 +- .../python/tests/unit/test_super_resolve.py | 4 +- .../tests/unit/test_template_toolcall.py | 2 +- .../codegen-ts/src/cube/build-cube-model.ts | 14 +- .../src/generators/agent-docs-file.ts | 8 +- .../src/generators/docs-data-builder.ts | 11 +- .../src/projection/extract-report-spec.ts | 10 +- .../src/projection/extract-view-spec.ts | 20 +- .../src/projection/report-resolve.ts | 13 +- .../codegen-ts/src/reference/agent-docs.ts | 8 +- .../codegen-ts/src/relation-resolver.ts | 4 +- .../codegen-ts/src/templates/callable-file.ts | 17 +- .../codegen-ts/src/templates/mermaid-er.ts | 10 +- .../codegen-ts/src/templates/output-prompt.ts | 8 +- .../docs-site/src/builders/object-data.ts | 8 +- .../packages/docs-site/src/link-graph.ts | 7 +- .../test/api-contract-report-corpus.test.ts | 7 +- .../metadata/src/core/attr/meta-attr.ts | 16 + .../src/core/identity/meta-identity.ts | 15 +- .../src/core/relationship/find-reference.ts | 6 +- .../resolve-relationship-reference.ts | 23 +- .../src/core/reporting/report-describe.ts | 15 +- .../src/core/reporting/report-shape.ts | 2 +- .../metadata/src/core/yaml-desugar.ts | 98 +---- .../packages/metadata/src/errors.ts | 6 - .../typescript/packages/metadata/src/fmt.ts | 4 +- .../typescript/packages/metadata/src/index.ts | 16 +- .../metadata/src/loader/meta-data-loader.ts | 18 + .../src/loader/reporting-validation.ts | 10 + .../metadata/src/loader/resolve-references.ts | 144 +++++++ .../metadata/src/loader/validation-passes.ts | 13 +- .../src/loader/validation-registry.ts | 46 ++- .../packages/metadata/src/naming-refs.ts | 307 ++++++++++----- .../metadata/src/object-serializer.ts | 16 +- .../packages/metadata/src/parser-core.ts | 75 +--- .../packages/metadata/src/serializer-json.ts | 91 ++++- .../packages/metadata/src/shared/meta-data.ts | 23 +- .../packages/metadata/src/shared/meta-root.ts | 13 +- .../packages/metadata/src/super-resolve.ts | 112 +++--- .../test/conformance-twin-roundtrip.test.ts | 139 +++++++ .../metadata/test/conformance/adapter.ts | 2 +- .../metadata/test/meta/find-reference.test.ts | 2 +- .../metadata/test/meta/meta-misc.test.ts | 6 +- .../test/meta/meta-reference-identity.test.ts | 6 +- .../metadata/test/relationship-m2m.test.ts | 2 +- .../metadata/test/report-shape.test.ts | 2 +- .../test/reporting-validation.test.ts | 12 +- .../packages/metadata/test/round-trip.test.ts | 23 +- .../metadata/test/serializer-json.test.ts | 15 +- .../test/template-response-ref.test.ts | 2 +- .../packages/runtime-ts/src/n2m-resolver.ts | 6 +- .../runtime-ts/src/relation-resolver.ts | 29 +- spec/cross-language-porting-guide.md | 18 +- .../ADR-0006-ai-first-yaml-authoring.md | 10 +- spec/decisions/ADR-0032-canonical-fqn-refs.md | 2 +- ...-0042-bare-references-are-package-local.md | 2 +- ...-references-resolve-once-after-the-tree.md | 144 +++++++ spec/decisions/README.md | 1 + 271 files changed, 5895 insertions(+), 2264 deletions(-) create mode 100644 fixtures/conformance/error-extends-bare-cross-package/expected-errors.json create mode 100644 fixtures/conformance/error-extends-bare-cross-package/input/meta.acme.json create mode 100644 fixtures/conformance/error-extends-bare-cross-package/input/meta.other.json create mode 100644 fixtures/conformance/error-extends-bare-cross-package/providers.json create mode 100644 fixtures/conformance/error-extends-nested-overlay-target-package/expected-errors.json create mode 100644 fixtures/conformance/error-extends-nested-overlay-target-package/input/meta.a.json create mode 100644 fixtures/conformance/error-extends-nested-overlay-target-package/input/meta.b.json create mode 100644 fixtures/conformance/error-extends-nested-overlay-target-package/providers.json create mode 100644 fixtures/conformance/error-ref-parent-over-drop/expected-errors.json create mode 100644 fixtures/conformance/error-ref-parent-over-drop/input/meta.acme.json create mode 100644 fixtures/conformance/error-ref-parent-over-drop/providers.json delete mode 100644 fixtures/conformance/error-relative-ref-in-canonical/expected-errors.json delete mode 100644 fixtures/conformance/error-relative-ref-in-canonical/input/meta.bad.json create mode 100644 fixtures/conformance/extends-bare-declaring-file-package/expected-effective.json create mode 100644 fixtures/conformance/extends-bare-declaring-file-package/expected.json create mode 100644 fixtures/conformance/extends-bare-declaring-file-package/input/meta.a.json create mode 100644 fixtures/conformance/extends-bare-declaring-file-package/input/meta.b.json create mode 100644 fixtures/conformance/extends-bare-declaring-file-package/providers.json create mode 100644 fixtures/conformance/extends-nested-overlay-file-package/expected-effective.json create mode 100644 fixtures/conformance/extends-nested-overlay-file-package/expected.json create mode 100644 fixtures/conformance/extends-nested-overlay-file-package/input/meta.a.json create mode 100644 fixtures/conformance/extends-nested-overlay-file-package/input/meta.b.json create mode 100644 fixtures/conformance/extends-nested-overlay-file-package/providers.json create mode 100644 fixtures/conformance/package-default-per-file/expected-effective.json create mode 100644 fixtures/conformance/package-default-per-file/expected.json create mode 100644 fixtures/conformance/package-default-per-file/input/meta.a.json create mode 100644 fixtures/conformance/package-default-per-file/input/meta.b.json create mode 100644 fixtures/conformance/package-default-per-file/providers.json create mode 100644 fixtures/conformance/ref-bare-local-shadows-root/expected-effective.json create mode 100644 fixtures/conformance/ref-bare-local-shadows-root/expected.json create mode 100644 fixtures/conformance/ref-bare-local-shadows-root/input/meta.acme.json create mode 100644 fixtures/conformance/ref-bare-local-shadows-root/input/meta.root.json create mode 100644 fixtures/conformance/ref-bare-local-shadows-root/providers.json create mode 100644 fixtures/conformance/ref-inherited-bare-objectref/expected-effective.json create mode 100644 fixtures/conformance/ref-inherited-bare-objectref/expected.json create mode 100644 fixtures/conformance/ref-inherited-bare-objectref/input/meta.base.json create mode 100644 fixtures/conformance/ref-inherited-bare-objectref/input/meta.copy.json create mode 100644 fixtures/conformance/ref-inherited-bare-objectref/providers.json create mode 100644 fixtures/conformance/ref-parent-relative/expected-effective.json create mode 100644 fixtures/conformance/ref-parent-relative/expected.json create mode 100644 fixtures/conformance/ref-parent-relative/input/meta.acme.json create mode 100644 fixtures/conformance/ref-parent-relative/input/meta.sub.json create mode 100644 fixtures/conformance/ref-parent-relative/providers.json create mode 100644 fixtures/conformance/ref-root-escape-shadowed/expected-effective.json create mode 100644 fixtures/conformance/ref-root-escape-shadowed/expected.json create mode 100644 fixtures/conformance/ref-root-escape-shadowed/input/meta.acme.json create mode 100644 fixtures/conformance/ref-root-escape-shadowed/input/meta.root.json create mode 100644 fixtures/conformance/ref-root-escape-shadowed/providers.json create mode 100644 fixtures/conformance/round-trip-exclusions.json create mode 100644 fixtures/conformance/serialize-multi-package-document/expected-effective.json create mode 100644 fixtures/conformance/serialize-multi-package-document/expected.json create mode 100644 fixtures/conformance/serialize-multi-package-document/input/meta.catalog.json create mode 100644 fixtures/conformance/serialize-multi-package-document/input/meta.orders.json create mode 100644 fixtures/conformance/serialize-multi-package-document/input/meta.people.json create mode 100644 fixtures/conformance/serialize-multi-package-document/providers.json create mode 100644 fixtures/conformance/yaml-twin-exclusions.json create mode 100644 fixtures/yaml-conformance/yaml-ref-parent-relative/expected.json create mode 100644 fixtures/yaml-conformance/yaml-ref-parent-relative/input.yaml create mode 100644 fixtures/yaml-conformance/yaml-ref-root-escape/expected.json create mode 100644 fixtures/yaml-conformance/yaml-ref-root-escape/input.yaml create mode 100644 fixtures/yaml-conformance/yaml-reporting-via-of-bare/expected.json create mode 100644 fixtures/yaml-conformance/yaml-reporting-via-of-bare/input.yaml create mode 100644 server/csharp/MetaObjects.Conformance.Tests/YamlTwinConformanceTests.cs create mode 100644 server/csharp/MetaObjects/Loader/ResolveReferences.cs create mode 100644 server/java/metadata/src/main/java/com/metaobjects/loader/References.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceExclusionsTest.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/loader/OverlayFileDefaultPackageTest.java create mode 100644 server/python/src/metaobjects/loader/resolve_references.py create mode 100644 server/python/tests/conformance/test_conformance_twin_roundtrip.py create mode 100644 server/typescript/packages/metadata/src/loader/resolve-references.ts create mode 100644 server/typescript/packages/metadata/test/conformance-twin-roundtrip.test.ts create mode 100644 spec/decisions/ADR-0059-references-resolve-once-after-the-tree.md diff --git a/AGENTS.md b/AGENTS.md index 1e010f5ed..a4d2fb6eb 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/` (394 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..d872e085a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,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 @@ -840,6 +853,21 @@ 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 `MetaRoot.findObject` accepts a canonical value. +- 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. + **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 +1298,39 @@ 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. - **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..a224408cc 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), 394 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-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/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index ffd0ad379..b893508cf 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) | 394 | ✓ | ✓ | 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 | ✓ | — | — | — | ✓ | @@ -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 (394) | 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 394 + 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/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/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index c315df099..0e9820e2a 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.1" }, "counts": { - "fixtures": 384, + "fixtures": 394, "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-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/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/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-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/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-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.Codegen.Tests/ExtractTierCollisionTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ExtractTierCollisionTests.cs index a5f13e30c..92c2ca35f 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ExtractTierCollisionTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ExtractTierCollisionTests.cs @@ -256,7 +256,7 @@ public void ExtractLenient_MetaRoot_overload_binds_the_payload_not_a_same_named_ // "Report") and resolves via the canonical FQN-exact matcher — never MetaRoot's // bare-name-first-match FindObject. Assert.Contains("public const string PAYLOAD_FQN = \"acme::beta::Report\";", parserSrc); - Assert.Contains("global::MetaObjects.NamingRefs.ResolveObjectRef(root, PAYLOAD_FQN, \"\")", parserSrc); + Assert.Contains("global::MetaObjects.NamingRefs.LookupCanonical(root, PAYLOAD_FQN)", parserSrc); Assert.DoesNotContain("root.FindObject(PAYLOAD_FQN)", parserSrc); var asm = Compile([.. entityFiles.Select(f => f.Content), namesSrc, .. parserFiles.Select(f => f.Content)]); diff --git a/server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs index 902eac288..b2c6867ab 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs @@ -90,7 +90,7 @@ public void Source_ParameterRef_and_IsCallable_reflect_attrs() var root = Load(); var ps = root.FindObject("PhaseSummary")!.Sources()[0]; Assert.True(ps.IsCallable()); - Assert.Equal("PhaseSummaryArgs", ps.ParameterRef); + Assert.Equal("acme::analytics::PhaseSummaryArgs", ps.ParameterRef); var plain = root.FindObject("Plain")!.Sources()[0]; Assert.False(plain.IsCallable()); diff --git a/server/csharp/MetaObjects.Codegen.Tests/TemplateDataTests.cs b/server/csharp/MetaObjects.Codegen.Tests/TemplateDataTests.cs index 03c34e564..06f29d571 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/TemplateDataTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/TemplateDataTests.cs @@ -53,7 +53,7 @@ public void OrderRelationship() Assert.Single(rels); Assert.Equal("product", rels[0]["name"]); Assert.Equal("one", rels[0]["cardinality"]); - Assert.Equal("Product", rels[0]["targetRef"]); + Assert.Equal("shop::Product", rels[0]["targetRef"]); } [Fact] diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index e67be7609..9b10dd916 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -1485,7 +1485,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 = ctx.Root.FindObject(of[..dot]); var sourceField = sourceObj?.FindField(of[(dot + 1)..]); return sourceField is null ? null : CSharpNaming.ScalarForField(sourceField); } diff --git a/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs b/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs index 763f2df89..b8060d65b 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs @@ -217,7 +217,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 +256,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/M2MNavigation.cs b/server/csharp/MetaObjects.Codegen/Generators/M2MNavigation.cs index 672aaab19..69afa6d47 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/M2MNavigation.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/M2MNavigation.cs @@ -114,9 +114,9 @@ public static IReadOnlyList For(MetaObject entity, MetaRoot root, // 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)); + ?? root.FindObject(targetRef); var junction = M2MDerivation.ResolveEntity(root, throughRef) - ?? root.FindObject(CSharpNaming.StripPkg(throughRef)); + ?? root.FindObject(throughRef); 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/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/ValueObjectNames.cs b/server/csharp/MetaObjects.Codegen/ValueObjectNames.cs index 407b7263f..93ea59035 100644 --- a/server/csharp/MetaObjects.Codegen/ValueObjectNames.cs +++ b/server/csharp/MetaObjects.Codegen/ValueObjectNames.cs @@ -68,16 +68,12 @@ 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; + global::MetaObjects.NamingRefs.RefTarget(field, MetaObjects.Core.Field.FieldConstants.FIELD_ATTR_OBJECT_REF) as MetaObject; /// The payload shape a @payloadRef names, or null. ADR-0042: /// package-local — an FQN binds the exact package; a bare ref binds 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/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/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..eaeaced29 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) => 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/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/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-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..ab8d279a9 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 @@ -49,15 +49,12 @@ 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 resolveObjectByShortOrFqn(loader: MetaDataLoader, ref: String): MetaObject? = + com.metaobjects.loader.References.lookupCanonical(loader.root, ref) // ========================================================================= // ADR-0042 — canonical package-local object-ref resolution (@payloadRef). 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..10d1b7e84 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 @@ -285,25 +285,10 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase resolve(MetaObject entity, MetaDataLoader loader) { return out; } + /** ADR-0059 R7: a reference value is canonical after load; match its exact resolution key. */ 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; + return com.metaobjects.loader.References.lookupCanonical(root, name); } } 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/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/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..f0d149a65 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()); 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..3ae1f7a48 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 @@ -4197,27 +4202,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 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/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/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/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..1fc38ba6b 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 @@ -160,24 +160,9 @@ private static boolean keyEquals(Object a, Object 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; + // ADR-0059 R7: a reference value is canonical after load; match its exact resolution key. + MetaObject found = com.metaobjects.loader.References.lookupCanonical(root, name); + if (found == null) throw new IllegalStateException("Entity '" + name + "' not found in model root"); + return found; } } diff --git a/server/python/src/metaobjects/codegen/runtime/m2m_codegen.py b/server/python/src/metaobjects/codegen/runtime/m2m_codegen.py index 813794dbb..f5eac8deb 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. @@ -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 ' 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..e6d63e6f5 100644 --- a/server/python/src/metaobjects/loader/validate_reporting.py +++ b/server/python/src/metaobjects/loader/validate_reporting.py @@ -1127,3 +1127,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..ecc9d6b9b 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -137,7 +137,7 @@ 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 ..meta.core.relationship.relationship_references import ( reference_candidates_for, reference_fields, @@ -1715,7 +1715,7 @@ 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 "" + hop_pkg = referrer_package_of(current_entity) next_entity = resolve_object_ref(root, obj_ref, hop_pkg) if next_entity is None: # FR5d — the hop's target points at a missing entity. @@ -2170,7 +2170,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 +2377,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( @@ -2670,7 +2670,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. @@ -3042,7 +3042,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 +3862,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 +3902,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..95af96977 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,9 +44,8 @@ from dataclasses import dataclass from ...meta_data import MetaData -from ....naming_refs import resolve_object_ref +from ....naming_refs import resolve_object_ref, referrer_package_of 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, @@ -69,12 +68,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,47 +93,10 @@ 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``. - """ + """The target-entity half of a reference's ``@references`` (dotted ``Entity.field`` + form reduced to its entity head), package intact, for resolution in the junction's + package.""" return reference_target_entity(ref) @@ -224,7 +180,7 @@ def derive_m2m_fields( # 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 "" + referrer_pkg = referrer_package_of(declaring) through_name = rel.through() if through_name is None: @@ -281,17 +237,19 @@ def _is_subject(entity: MetaData | None) -> bool: # 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 - ) + 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) # 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 "" + junction_pkg = referrer_package_of(junction) def _ref_entity(r: MetaData) -> MetaData | None: return _find_entity(root, _ref_target_qualified(r), junction_pkg) @@ -307,21 +265,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..2604568cb 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, resolve_object_ref, referrer_package_of, split_child_tail 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,26 @@ 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: "str | MetaData") -> 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 itself, or a reference string resolved in ``holder``'s package. An unresolved + target matches nothing. """ - target = strip_package(target_entity) + if isinstance(target, str): + owner, _tail = split_child_tail(target) + target_node = resolve_object_ref(holder.root(), owner, referrer_package_of(holder)) + else: + 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 +140,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: "str | MetaData", 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..9d08aa495 100644 --- a/server/python/src/metaobjects/meta/core/reporting/report_shape.py +++ b/server/python/src/metaobjects/meta/core/reporting/report_shape.py @@ -269,7 +269,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 @@ -348,7 +348,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 +387,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 +410,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..23f87aae8 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) @@ -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..e013db691 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 resolve_object_ref, referrer_package_of from ..shared.base_types import TYPE_FIELD, TYPE_VALIDATOR @@ -219,7 +219,7 @@ def _resolve_vo_ref(field: MetaData) -> MetaObject | None: # (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 "" + referrer_pkg = referrer_package_of(owner) target = resolve_object_ref(root, ref, referrer_pkg) 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_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..238e15c14 --- /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: + providers = _resolve_providers(fix.providers) + result = MetaDataLoader(providers=providers, strict=True).load(sources) + except (ParseError, ValueError): + return None + 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_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/codegen-ts/src/cube/build-cube-model.ts b/server/typescript/packages/codegen-ts/src/cube/build-cube-model.ts index 75d7bd355..dbb9d019e 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 @@ -41,6 +41,8 @@ import { 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) { @@ -636,7 +639,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 +672,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 +778,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/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/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/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..f8cce6072 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, @@ -400,7 +403,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 }; } @@ -544,15 +547,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 ("::"). */ 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..6003ee3c8 100644 --- a/server/typescript/packages/codegen-ts/src/projection/report-resolve.ts +++ b/server/typescript/packages/codegen-ts/src/projection/report-resolve.ts @@ -18,6 +18,7 @@ import { 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 @@ -90,10 +97,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 +116,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/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/relation-resolver.ts b/server/typescript/packages/codegen-ts/src/relation-resolver.ts index 215e4f8b3..47b204835 100644 --- a/server/typescript/packages/codegen-ts/src/relation-resolver.ts +++ b/server/typescript/packages/codegen-ts/src/relation-resolver.ts @@ -9,6 +9,7 @@ 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, CARDINALITY_ONE, @@ -172,8 +173,9 @@ 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; + // ADR-0059 R7: match references by the bound target node when it resolves. const matching = resolveRelationshipReference( - obj, child.name, targetEntity, declaredRefField, + obj, child.name, refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF) ?? targetEntity, declaredRefField, ); if (!matching) continue; 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/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-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/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..57bcaba2a 100644 --- a/server/typescript/packages/docs-site/src/link-graph.ts +++ b/server/typescript/packages/docs-site/src/link-graph.ts @@ -2,6 +2,7 @@ import type { MetaData, MetaObject, MetaRelationship, MetaRoot } from "@metaobje import { deriveM2MFields, reportFrom, resolveRelationshipReference, stripPackage, OBJECT_REPORT_ATTR_FROM, OBJECT_SUBTYPE_REPORT, TYPE_OBJECT, + RELATIONSHIP_ATTR_OBJECT_REF, refTarget, } from "@metaobjectsdev/metadata"; import { type LoadedModel, treeOf } from "./load.js"; @@ -55,6 +56,8 @@ export class LinkGraph { (this._to.get(r.to) ?? this._to.set(r.to, []).get(r.to)!).push(r); }; const resolveRef = (raw: string, ctxPkg: string): string | undefined => { + // `::Name` is root-absolute (ADR-0059): the loader writes it for a root-level target. + if (raw.startsWith("::")) return this._nodes.has(raw.slice(2)) ? raw.slice(2) : undefined; const cand = raw.includes("::") ? raw : `${ctxPkg}::${raw}`; return this._nodes.has(cand) ? cand : (this._nodes.has(raw) ? raw : undefined); }; @@ -116,7 +119,9 @@ export class LinkGraph { // 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) ?? target, 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 }); 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..284e5c8e0 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"; @@ -154,14 +157,10 @@ export class MetaReferenceIdentity extends MetaIdentity { 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); + // 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); + const targetObj = bound !== undefined && bound.type === TYPE_OBJECT ? (bound as MetaObject) : root.findObject(targetName); if (!targetObj) return undefined; const primary = targetObj.primaryIdentity(); 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..f8dd34301 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,10 @@ import type { MetaObject } from "../object/meta-object.js"; import type { MetaReferenceIdentity } from "../identity/meta-identity.js"; +import type { MetaData } from "../../shared/meta-data.js"; import { stripPackage } from "../../naming.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 @@ -52,19 +55,21 @@ 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. + * Given the target NODE, a reference matches when the loader bound its @references to + * that node (ADR-0059 R7: identity, never a short name). Given only a name, the match + * is package-insensitive on both sides, for callers that hold no node. */ export function referenceCandidatesFor( holder: MetaObject, - targetEntity: string, + targetEntity: string | MetaData, ): MetaReferenceIdentity[] { - const target = stripPackage(targetEntity); // ADR-0039: resolving — referenceIdentities() honors references inherited via extends. - return holder - .referenceIdentities() - .filter((ref) => stripPackage(ref.targetEntity ?? "") === target) - .filter((ref) => refFkField(ref) !== undefined); + const all = holder.referenceIdentities().filter((ref) => refFkField(ref) !== undefined); + if (typeof targetEntity !== "string") { + return all.filter((ref) => refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES) === targetEntity); + } + const target = stripPackage(targetEntity); + return all.filter((ref) => stripPackage(ref.targetEntity ?? "") === target); } /** @@ -82,7 +87,7 @@ export function referenceCandidatesFor( export function resolveRelationshipReference( holder: MetaObject, relationshipName: string, - targetEntity: string, + targetEntity: string | MetaData, 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..bd2020f02 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts @@ -328,7 +328,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..723942ad0 100644 --- a/server/typescript/packages/metadata/src/loader/reporting-validation.ts +++ b/server/typescript/packages/metadata/src/loader/reporting-validation.ts @@ -523,6 +523,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..dc86bee2c 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,9 @@ 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 target node when it resolves. + const targetNode = refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF) ?? objectRef; + const candidates = referenceCandidatesFor(obj, targetNode); const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD); const declared = typeof sourceRefField === "string" && sourceRefField !== "" @@ -2237,7 +2238,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/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..ca491289a 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,7 @@ 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(); }); }); 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/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..8156aeecb 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: string | 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/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..7284abbb8 --- /dev/null +++ b/spec/decisions/ADR-0059-references-resolve-once-after-the-tree.md @@ -0,0 +1,144 @@ +# 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-refs-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. Every canonical spelling resolves to the same node with or without a referrer. + +**7. Consumers read the resolved model.** A consumer with a referrer keeps calling the shared +resolver, which accepts every form. A consumer without one reads the bound target +(`refTarget(node, attr)` in TypeScript) or resolves the canonical value exactly +(`lookupCanonical`). Code that compared a reference value to an object's short name compares +the target node instead. + +**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/README.md b/spec/decisions/README.md index 2c1cd859a..9299ff2d5 100644 --- a/spec/decisions/README.md +++ b/spec/decisions/README.md @@ -55,6 +55,7 @@ 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) > **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 From 51a9047841d2277c7487e787c46d957187d1adac Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 12:15:47 -0400 Subject: [PATCH 08/22] no-mistakes(review): Align gen and verify sweeps; union JVM profile owners --- docs/features/own-your-codegen.md | 14 ++-- .../generator/util/GeneratedFileWriter.java | 42 ++++++++--- .../util/GeneratedFileWriterTest.java | 36 ++++++++++ .../mojo/AbstractMetaDataMojo.java | 72 ++++++++++++------- .../mojo/MetaDataGeneratorMojo.java | 1 + .../mojo/ConfiguredGeneratorsTest.java | 41 ++++++++++- server/python/src/metaobjects/cli.py | 10 ++- .../python/tests/codegen/test_orphan_sweep.py | 18 +++++ .../test/codegen-drift-orphan-policy.test.ts | 28 ++++++++ .../packages/codegen-ts/src/runner.ts | 14 +++- .../ADR-0059-jvm-codegen-hash-manifest.md | 21 +++--- 11 files changed, 244 insertions(+), 53 deletions(-) diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index ed59c03ec..1ea7efbf1 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -575,7 +575,10 @@ handles those files by one rule: - **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. + 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. @@ -595,9 +598,12 @@ generator emitted in this build is an orphan when this run's own generators own 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 every profile on to read them, so a profile's -own properties resolve as they do under `-P`, and a plain build keeps the files of a -generator only `-P` runs. +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. One file emitted with different content by two generator/output-directory pairs in the same 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 35afd5370..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 @@ -14,6 +14,7 @@ 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; @@ -140,6 +141,10 @@ public static final class Run implements AutoCloseable { // 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 @@ -184,9 +189,26 @@ public Run attributeTo(String generatorName, String handEdits, HashManifest.Owne 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() { @@ -245,7 +267,11 @@ private void claim(Path outFile, String content) { @Override public void close() { - ACTIVE_RUN.remove(); + try { + flush(); + } finally { + ACTIVE_RUN.remove(); + } } } @@ -339,12 +365,12 @@ public static Outcome write(Path outFile, String content) throws IOException { if (!Files.exists(outFile)) { performWrite(outFile, content, run); - HashManifest.record(run.genStateDir, key, content, run.currentOwner); + run.record(key, HashManifest.contentHash(content)); return Outcome.WRITTEN; } String current = Files.readString(outFile, StandardCharsets.UTF_8); - String recorded = HashManifest.recordedHash(run.genStateDir, key); + String recorded = run.hashes.get(key); if (recorded == null) { // Never recorded under this key. Marker present ⇒ this toolchain's own prior @@ -354,7 +380,7 @@ public static Outcome write(Path outFile, String content) throws IOException { // replace under any policy. if (looksGenerated(current)) { performWrite(outFile, content, run); - HashManifest.record(run.genStateDir, key, content, run.currentOwner); + run.record(key, HashManifest.contentHash(content)); run.adoptedPaths.add(outFile.toString()); return Outcome.WRITTEN; } @@ -366,26 +392,26 @@ public static Outcome write(Path outFile, String content) throws IOException { // 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); - HashManifest.record(run.genStateDir, key, content, run.currentOwner); + 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); - HashManifest.record(run.genStateDir, key, content, run.currentOwner); + 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); - HashManifest.record(run.genStateDir, key, content, run.currentOwner); + run.record(key, HashManifest.contentHash(content)); run.overwrittenHandEditedPaths.add(outFile.toString()); LOG.warn("overwrote hand-edited " + outFile + " (handEdits: overwrite)"); return Outcome.WRITTEN; } - HashManifest.recordHash(run.genStateDir, key, recorded, run.currentOwner); + run.record(key, recorded); run.note(outFile, content); LOG.warn(refusedHandEditedMessage(outFile)); return Outcome.REFUSED; 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 8fb2df012..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 @@ -10,6 +10,7 @@ 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; @@ -261,6 +262,41 @@ public void absentFile_writesAndRecords() throws Exception { 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(); 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 adec9f293..cfea446b2 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 @@ -47,6 +47,7 @@ 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; @@ -105,41 +106,58 @@ private HashManifest.Owner ownerOf(String classname, String outputDir) { /** * 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, with every profile of the module and its - * parents active, so a profile switched off for one build does not make its - * generators' files look stranded. Maven builds that model, so profile properties, - * inherited profiles and basedirs resolve as they do when the profile is on. - * {@code null} when that model cannot be built or does not declare this plugin, so - * what is configured is unknown. + * 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. */ protected Set configuredOwners() { - MavenProject all = allProfilesProject(); - Plugin plugin = all != null ? all.getPlugin(PLUGIN_KEY) : null; - if (plugin == null) return null; - Set out = new HashSet<>(); - Xpp3Dom pluginLevel = plugin.getConfiguration() instanceof Xpp3Dom dom ? dom : null; - addConfiguredOwners(pluginLevel, out); - for (PluginExecution e : plugin.getExecutions()) { - if (!e.getGoals().contains("generate")) continue; - if (!(e.getConfiguration() instanceof Xpp3Dom execution)) continue; - addConfiguredOwners(pluginLevel == null ? execution - : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(execution), pluginLevel), out); - } - return out; - } - - /** This module built by Maven with every profile it and its parents declare active; - * {@code null} outside a Maven session or when the build fails. */ - private MavenProject allProfilesProject() { if (project == null || project.getFile() == null || projectBuilder == null || session == null) return null; - List profileIds = new ArrayList<>(); + 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 null; + models.add(base); + for (String id : profileIds) { + MavenProject alone = projectWith(id, profileIds); + if (alone == null) return null; + models.add(alone); + } + Set out = new HashSet<>(); + 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; + addConfiguredOwners(pluginLevel, out); + for (PluginExecution e : plugin.getExecutions()) { + if (!e.getGoals().contains("generate")) continue; + if (!(e.getConfiguration() instanceof Xpp3Dom execution)) continue; + addConfiguredOwners(pluginLevel == null ? execution + : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(execution), pluginLevel), out); + } + } + return declared ? out : null; + } + + /** 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(profileIds); - request.setInactiveProfileIds(List.of()); + request.setActiveProfileIds(active == null ? List.of() : List.of(active)); + request.setInactiveProfileIds(inactive); request.setResolveDependencies(false); request.setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); request.setProject(null); 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 5101b24a9..f3b20a329 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 @@ -111,6 +111,7 @@ protected void executeGenerators(MetaDataLoader loader, List generato } } + run.flush(); Set emittedThisBuild = refuseSharedPaths(run.emissions(), run.emittedPaths()); if (migrating) { getLog().info("MetaData Mojo > Recorded the generator and output dir of every generated " 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 index 74e891bbe..1f88ee3e0 100644 --- 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 @@ -32,8 +32,9 @@ import static org.junit.Assert.fail; /** - * Which generators a module configures is read from the module as Maven builds it with - * every profile on: a generator an inactive profile runs is still configured, so a plain + * 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}. @@ -212,6 +213,42 @@ public void parentPomsProfile_countsAsConfiguredInTheModule() throws Exception { 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"); diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 418e83273..947b2a8af 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -1970,6 +1970,9 @@ 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}) + # 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. @@ -2029,9 +2032,12 @@ def compare(naming: str) -> tuple[list[str], list[_DirReport]]: tmp_slot = temp_for[real_outdir] if narrowed and tmp_slot not in targeted: continue - inside = [o for o in real_outdirs if o != real_outdir and Path(o).is_relative_to(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 inside]) + committed = _relative_set(Path(real_outdir), [Path(o) for o in nested]) if narrowed: declared = _relative_set( Path(declared_for[real_outdir]), [Path(declared_for[o]) for o in inside]) diff --git a/server/python/tests/codegen/test_orphan_sweep.py b/server/python/tests/codegen/test_orphan_sweep.py index e6e811c90..d677b4e2e 100644 --- a/server/python/tests/codegen/test_orphan_sweep.py +++ b/server/python/tests/codegen/test_orphan_sweep.py @@ -190,6 +190,24 @@ def test_a_target_run_spares_a_nested_directory_of_a_target_that_did_not_run( 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 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 index 6600e311c..f77a41564 100644 --- a/server/typescript/packages/cli/test/codegen-drift-orphan-policy.test.ts +++ b/server/typescript/packages/cli/test/codegen-drift-orphan-policy.test.ts @@ -91,3 +91,31 @@ describe("verify --codegen classifies orphans by the sweep's own policy", () => 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/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index 20000dc7b..51389c5a2 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -1084,12 +1084,22 @@ export async function runGen(opts: RunGenOpts): Promise { // 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, - ...[...runOutDirs].map((writeOutDir) => { - const nested = [...runOutDirs].filter((other) => isNestedIn(other, writeOutDir)); + ...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); diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index 64c7f26fb..55bee9176 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -43,14 +43,19 @@ does not cover deletion, and deletion without evidence is not safe on any port. `"": { "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 asks Maven to build the module again with every profile the module and its - parent POMs declare switched on, using this build's request settings, and reads the - `metaobjects:generate` executions and plugin-level configuration of that model to know - every generator/output-directory pair the module configures. Maven resolves profile-only - properties, inherited profiles and basedirs there exactly as on a build with the - profile on, so a pair declared only in a profile that is off for this build is still - configured and its files are never treated as stranded. When that 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 + 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) From 9725f000deb8bca8c5720b34f558673dc389b234 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 12:23:27 -0400 Subject: [PATCH 09/22] no-mistakes(review): Memoize JVM configured owners and compute them lazily --- .../generator/util/OrphanSweep.java | 12 +++-- .../generator/util/OrphanSweepTest.java | 24 ++++----- .../mojo/AbstractMetaDataMojo.java | 17 +++++- .../mojo/MetaDataGeneratorMojo.java | 2 +- .../metaobjects/mojo/MetaDataVerifyMojo.java | 3 +- .../mojo/ConfiguredGeneratorsTest.java | 54 ++++++++++++++++++- 6 files changed, 90 insertions(+), 22 deletions(-) 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 index 7308e7da3..021dbb0b7 100644 --- 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 @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -139,13 +140,14 @@ public static Verdict classify(Map hashes, Path projectRoot, Pat * 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. + * port's. {@code configured} is asked only when the answer depends on it. */ public static boolean isCandidate(HashManifest.Owner owner, boolean inRunDir, - Set runOwners, Set configured) { + Set runOwners, Supplier> configured) { if (owner != null && runOwners.contains(owner)) return true; - if (configured == null) return false; - return owner == null ? inRunDir : !configured.contains(owner); + if (owner == null && !inRunDir) return false; + Set known = configured.get(); + return known != null && (owner == null || !known.contains(owner)); } /** @@ -156,7 +158,7 @@ public static boolean isCandidate(HashManifest.Owner owner, boolean inRunDir, * 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, Set configured) { + Set runOwners, Supplier> configured) { if (!HashManifest.exists(genStateDir)) { return new Result(List.of(), List.of(), List.of()); } 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 index bf0d2c7a6..ec55953c9 100644 --- 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 @@ -94,7 +94,7 @@ public void renameCase_pristineOrphanIsDeletedAndForgotten() throws Exception { 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); + 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")); @@ -115,7 +115,7 @@ public void handEditedOrphan_isRefused_keptAndStillRecorded() throws Exception { 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); + 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")); @@ -134,7 +134,7 @@ public void handEditedOrphan_underAllowOverwrite_isDeleted() throws Exception { 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); + 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")); @@ -153,7 +153,7 @@ public void strangerFile_isNeverTouched() throws Exception { 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); + 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")); @@ -167,7 +167,7 @@ public void noManifestAtAll_sweepIsANoOp() throws Exception { 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); assertTrue(result.removed.isEmpty()); assertTrue(result.refused.isEmpty()); @@ -183,7 +183,7 @@ public void alreadyGoneRecordedFile_isForgotten() throws Exception { 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); assertFalse(HashManifest.load(genStateDir).containsKey("gen/Deleted.java")); } @@ -209,7 +209,7 @@ public void twoDirectories_neitherSweepsTheOthersFiles() throws Exception { // 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); + 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)); @@ -247,7 +247,7 @@ public void anotherConfiguredGeneratorsEntries_areNeverCandidates_evenInASharedD 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)); + 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"))); @@ -265,7 +265,7 @@ public void anEntryWhoseOwnerIsConfiguredNowhere_isAnOrphan_whereverItLives() th 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)); + Set.of(OWNER), () -> Set.of(OWNER)); assertEquals(List.of("gen/Mine.java", "gen/Theirs.java", "gen/Unowned.java", "web/Route.java"), result.removed); @@ -280,7 +280,7 @@ public void withTheModulesConfigurationUnknown_onlyTheRunsOwnEntriesAreCandidate 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); + Set.of(OWNER), () -> null); assertEquals(List.of("gen/Mine.java"), result.removed); assertTrue(Files.exists(outDir.resolve("Unowned.java"))); @@ -301,7 +301,7 @@ public void nestedDirectory_editedOrphanIsJudgedOnceByTheInnermostDirectory() th 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); + genStateDir, projectRoot, List.of(outerKeep, innerOverwrite), Set.of(), Set.of(OWNER), () -> null); assertFalse(Files.exists(orphan)); assertEquals(List.of("inner/Old.java"), result.overwrittenDeleted); @@ -320,7 +320,7 @@ public void nestedDirectory_keepInsideOverwrite_isRefusedNotDeleted() throws Exc 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); + 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); 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 cfea446b2..1534a711c 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 @@ -113,10 +113,25 @@ private HashManifest.Owner ownerOf(String classname, String outputDir) { * 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. + * 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() { if (project == null || project.getFile() == null || projectBuilder == null || session == null) return null; + if (project.getContextValue(CONFIGURED_OWNERS_CONTEXT_KEY) instanceof ConfiguredOwners known) { + return known.owners(); + } + Set owners = readConfiguredOwners(); + project.setContextValue(CONFIGURED_OWNERS_CONTEXT_KEY, new ConfiguredOwners(owners)); + return owners; + } + + private record ConfiguredOwners(Set owners) {} + + private static final String CONFIGURED_OWNERS_CONTEXT_KEY = + AbstractMetaDataMojo.class.getName() + ".configuredOwners"; + + private Set readConfiguredOwners() { Set profileIds = new LinkedHashSet<>(); for (MavenProject p = project; p != null; p = p.getParent()) { if (p.getModel() == null) continue; 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 f3b20a329..96d412578 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 @@ -139,7 +139,7 @@ private void sweepOrphans(Path genStateDir, Path projectRoot, Map Removed orphaned generated file no longer produced: " + path); } 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 2af7dc397..c8324694f 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 @@ -456,11 +456,10 @@ private Map ownedBy(Map hashes, Map configured = configuredOwners(); 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, configured)) { + if (OrphanSweep.isCandidate(owners.get(e.getKey()), inRunDir, runOwners, this::configuredOwners)) { owned.put(e.getKey(), e.getValue()); } } 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 index 1f88ee3e0..5b546ead9 100644 --- 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 @@ -27,6 +27,7 @@ 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; @@ -119,13 +120,26 @@ 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 = rule.lookup(ProjectBuilder.class); + mojo.projectBuilder = modelBuilder(); mojo.execution = new MojoExecution(new MojoDescriptor(), executionId); return mojo; } @@ -290,6 +304,44 @@ public void generatorRemovedFromTheMainBuild_itsFilesAreCleanedWhereverTheyLive( assertTrue(Files.exists(mainDir.resolve("Dto.java"))); } + @Test + public void buildWhoseEntriesAreAllItsOwn_buildsNoExtraModel() throws Exception { + Path mainDir = root.resolve("gen"); + Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), + profile("client", "", plugin("client", generators(CLIENT, "${project.basedir}/client")))); + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Old.java")); + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); + + assertFalse("this run's own orphan is still cleaned", Files.exists(mainDir.resolve("Old.java"))); + assertEquals(0, modelBuilds()); + } + + @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); + } + /** 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 From 60f459ddbd0413b8b241254b149e4089f80aaa9e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 12:31:03 -0400 Subject: [PATCH 10/22] no-mistakes(review): Qualify per-port manifest generator records in docs and ADR --- docs/features/own-your-codegen.md | 8 +++++--- spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 1ea7efbf1..a625a33bc 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -649,9 +649,11 @@ Every file `overwrite` discards is named, one line each (`overwrote hand-edited silently. `overwrite` applies only to files the manifest records `gen` writing: a file with no record is not generated output and is still refused. -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. +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 diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index 55bee9176..0417a3430 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -89,7 +89,10 @@ does not cover deletion, and deletion without evidence is not safe on any port. 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. `mvn metaobjects:verify` judges + 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. From 923ff8dab680f64a0317a1707023cda04799b071 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 12:41:33 -0400 Subject: [PATCH 11/22] no-mistakes(review): Sweep shared JVM generator pairs only in last execution --- CHANGELOG.md | 6 +- docs/features/own-your-codegen.md | 6 ++ .../generator/util/OrphanSweep.java | 13 ++-- .../generator/util/OrphanSweepTest.java | 24 +++--- .../mojo/AbstractMetaDataMojo.java | 63 +++++++++++---- .../mojo/MetaDataGeneratorMojo.java | 75 +++++++++++++++--- .../metaobjects/mojo/MetaDataVerifyMojo.java | 7 +- .../mojo/ConfiguredGeneratorsTest.java | 76 ++++++++++++++++++- .../MetaDataGeneratorMojoManifestTest.java | 2 +- .../ADR-0059-jvm-codegen-hash-manifest.md | 7 +- 10 files changed, 228 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8635d44e8..7bf905a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -874,7 +874,11 @@ executions in one module share the manifest without touching each other's files: 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. User templates, api-docs pages and the `META-INF/services` registration are not +emit with different content fails the goal. When two executions configure the same generator +into the same output directory, only the last of them in declared order removes that +generator's orphans, and only in a build that runs them all, so a standalone +`generate@` deletes nothing of the other's; the same file from both fails the goal. Two +modules have two. 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). diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index a625a33bc..af2fc5bad 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -606,6 +606,12 @@ the two profiles point one generator at different directories. A generator that 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. +When two executions configure the same generator into the same output directory (one +generator over two sets of metadata, say), their files share one owner. Only the last of +those executions in declared order removes that owner's orphans, and only in a build that +ran every one of them, judging against what all of them emitted; a standalone +`mvn metaobjects:generate@` leaves them alone and says so in one line. The same file +emitted by both executions fails the goal, naming both, whatever its content. 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 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 index 021dbb0b7..4883278a8 100644 --- 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 @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -136,15 +137,16 @@ public static Verdict classify(Map hashes, Path projectRoot, Pat /** * 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 + * with no owner): this run judges its owner's entries ({@code judgedByRun}), 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; + Predicate judgedByRun, + Supplier> configured) { + if (owner != null && judgedByRun.test(owner)) return true; if (owner == null && !inRunDir) return false; Set known = configured.get(); return known != null && (owner == null || !known.contains(owner)); @@ -158,7 +160,8 @@ public static boolean isCandidate(HashManifest.Owner owner, boolean inRunDir, * 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) { + Predicate judgedByRun, + Supplier> configured) { if (!HashManifest.exists(genStateDir)) { return new Result(List.of(), List.of(), List.of()); } @@ -179,7 +182,7 @@ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, 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 (!isCandidate(owners.get(key), dir != null, judgedByRun, 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. 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 index ec55953c9..610f7c64e 100644 --- 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 @@ -94,7 +94,7 @@ public void renameCase_pristineOrphanIsDeletedAndForgotten() throws Exception { 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), emitted, Set.of(OWNER)::contains, () -> null); assertFalse("old file should be deleted", Files.exists(oldFile)); assertTrue(result.removed.contains("gen/Telemetry.java")); @@ -115,7 +115,7 @@ public void handEditedOrphan_isRefused_keptAndStillRecorded() throws Exception { 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER)::contains, () -> null); assertTrue("edited orphan must be left in place", Files.exists(oldFile)); assertTrue(result.refused.contains("gen/Telemetry.java")); @@ -134,7 +134,7 @@ public void handEditedOrphan_underAllowOverwrite_isDeleted() throws Exception { 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER)::contains, () -> null); assertFalse("overwrite policy deletes a hand-edited orphan", Files.exists(oldFile)); assertTrue(result.overwrittenDeleted.contains("gen/Telemetry.java")); @@ -153,7 +153,7 @@ public void strangerFile_isNeverTouched() throws Exception { 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER)::contains, () -> null); assertTrue(Files.exists(stray)); assertFalse(result.removed.contains("gen/HandWritten.java")); @@ -167,7 +167,7 @@ public void noManifestAtAll_sweepIsANoOp() throws Exception { 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER)::contains, () -> null); assertTrue(result.removed.isEmpty()); assertTrue(result.refused.isEmpty()); @@ -183,7 +183,7 @@ public void alreadyGoneRecordedFile_isForgotten() throws Exception { 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER)::contains, () -> null); assertFalse(HashManifest.load(genStateDir).containsKey("gen/Deleted.java")); } @@ -209,7 +209,7 @@ public void twoDirectories_neitherSweepsTheOthersFiles() throws Exception { // 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); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entryA), Set.of(), Set.of(ownerA)::contains, () -> null); assertFalse("A.java is dirA's orphan", Files.exists(fileA)); assertTrue("B.java is untouched — outside dirA's jurisdiction", Files.exists(fileB)); @@ -247,7 +247,7 @@ public void anotherConfiguredGeneratorsEntries_areNeverCandidates_evenInASharedD 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)); + Set.of(OWNER)::contains, () -> Set.of(OWNER, DTO, REMOVED)); assertEquals(List.of("gen/Mine.java", "gen/Unowned.java"), result.removed); assertTrue(Files.exists(outDir.resolve("Theirs.java"))); @@ -265,7 +265,7 @@ public void anEntryWhoseOwnerIsConfiguredNowhere_isAnOrphan_whereverItLives() th 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)); + Set.of(OWNER)::contains, () -> Set.of(OWNER)); assertEquals(List.of("gen/Mine.java", "gen/Theirs.java", "gen/Unowned.java", "web/Route.java"), result.removed); @@ -280,7 +280,7 @@ public void withTheModulesConfigurationUnknown_onlyTheRunsOwnEntriesAreCandidate 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); + Set.of(OWNER)::contains, () -> null); assertEquals(List.of("gen/Mine.java"), result.removed); assertTrue(Files.exists(outDir.resolve("Unowned.java"))); @@ -301,7 +301,7 @@ public void nestedDirectory_editedOrphanIsJudgedOnceByTheInnermostDirectory() th 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); + genStateDir, projectRoot, List.of(outerKeep, innerOverwrite), Set.of(), Set.of(OWNER)::contains, () -> null); assertFalse(Files.exists(orphan)); assertEquals(List.of("inner/Old.java"), result.overwrittenDeleted); @@ -320,7 +320,7 @@ public void nestedDirectory_keepInsideOverwrite_isRefusedNotDeleted() throws Exc 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); + genStateDir, projectRoot, List.of(outerOverwrite, innerKeep), Set.of(), Set.of(OWNER)::contains, () -> null); assertTrue(Files.exists(orphan)); assertEquals(List.of("gen/inner/Old.java"), result.refused); 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 1534a711c..b5a89cdbd 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 @@ -117,21 +117,43 @@ private HashManifest.Owner ownerOf(String classname, String outputDir) { * project's build context, since every execution of the module reads the same POMs. */ protected Set configuredOwners() { - if (project == null || project.getFile() == null || projectBuilder == null || session == null) return null; + return configured().owners(); + } + + /** + * The ids of the module's {@code metaobjects:generate} executions that configure + * {@code owner}, in declared order; empty when none does or that is unknown. The + * plugin-level configuration alone is no execution. + */ + protected List executionsConfiguring(HashManifest.Owner owner) { + return configured().executions().getOrDefault(owner, List.of()); + } + + /** Whether more than one execution of the module configures {@code owner}. */ + protected boolean sharedByExecutions(HashManifest.Owner owner) { + return executionsConfiguring(owner).size() > 1; + } + + 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.owners(); + return known; } - Set owners = readConfiguredOwners(); - project.setContextValue(CONFIGURED_OWNERS_CONTEXT_KEY, new ConfiguredOwners(owners)); - return owners; + ConfiguredOwners read = readConfiguredOwners(); + project.setContextValue(CONFIGURED_OWNERS_CONTEXT_KEY, read); + return read; } - private record ConfiguredOwners(Set owners) {} + private record ConfiguredOwners(Set owners, Map> executions) { + static final ConfiguredOwners UNKNOWN = new ConfiguredOwners(null, Map.of()); + } private static final String CONFIGURED_OWNERS_CONTEXT_KEY = AbstractMetaDataMojo.class.getName() + ".configuredOwners"; - private Set readConfiguredOwners() { + private ConfiguredOwners readConfiguredOwners() { Set profileIds = new LinkedHashSet<>(); for (MavenProject p = project; p != null; p = p.getParent()) { if (p.getModel() == null) continue; @@ -140,29 +162,36 @@ private Set readConfiguredOwners() { List models = new ArrayList<>(); models.add(project); MavenProject base = projectWith(null, profileIds); - if (base == null) return null; + if (base == null) return ConfiguredOwners.UNKNOWN; models.add(base); for (String id : profileIds) { MavenProject alone = projectWith(id, profileIds); - if (alone == null) return null; + if (alone == null) return ConfiguredOwners.UNKNOWN; models.add(alone); } Set out = new HashSet<>(); + Map> executions = new HashMap<>(); 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; - addConfiguredOwners(pluginLevel, out); + out.addAll(configuredIn(pluginLevel)); for (PluginExecution e : plugin.getExecutions()) { if (!e.getGoals().contains("generate")) continue; if (!(e.getConfiguration() instanceof Xpp3Dom execution)) continue; - addConfiguredOwners(pluginLevel == null ? execution - : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(execution), pluginLevel), out); + for (HashManifest.Owner owner : configuredIn(pluginLevel == null ? execution + : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(execution), pluginLevel))) { + out.add(owner); + executions.computeIfAbsent(owner, k -> new LinkedHashSet<>()).add(e.getId()); + } } } - return declared ? out : null; + if (!declared) return ConfiguredOwners.UNKNOWN; + Map> byOwner = new HashMap<>(); + executions.forEach((owner, ids) -> byOwner.put(owner, List.copyOf(ids))); + return new ConfiguredOwners(out, byOwner); } /** This module built by Maven with only profile {@code active} on ({@code null}: none of @@ -185,11 +214,12 @@ private MavenProject projectWith(String active, Set all) { } } - private void addConfiguredOwners(Xpp3Dom dom, Set out) { - if (dom == null) return; + private List configuredIn(Xpp3Dom dom) { + List out = new ArrayList<>(); + if (dom == null) return out; Map domGlobals = childValues(dom.getChild("globals")); Xpp3Dom generators = dom.getChild("generators"); - if (generators == null) return; + if (generators == null) return out; for (Xpp3Dom generator : generators.getChildren()) { Xpp3Dom classname = generator.getChild("classname"); if (classname == null) continue; @@ -197,6 +227,7 @@ private void addConfiguredOwners(Xpp3Dom dom, Set out) { HashManifest.Owner owner = ownerOf(classname.getValue(), args.get(GeneratorBase.ARG_OUTPUTDIR)); if (owner != null) out.add(owner); } + return out; } private static Map childValues(Xpp3Dom dom) { 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 96d412578..6e3d092ed 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 @@ -72,6 +72,7 @@ protected void executeGenerators(MetaDataLoader loader, List generato // 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); + if (project != null) ranThisBuild().add(executionId()); // 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 @@ -138,8 +139,15 @@ private void sweepOrphans(Path genStateDir, Path projectRoot, Map deferred = new TreeSet<>(Comparator.comparing(HashManifest.Owner::toString)); + OrphanSweep.Result swept = OrphanSweep.sweep(genStateDir, projectRoot, dirs, emitted, + owner -> judges(owner, runOwners, deferred), this::configuredOwners); + for (HashManifest.Owner owner : deferred) { + List by = executionsConfiguring(owner); + getLog().info("MetaData Mojo > Not cleaning up files of '" + owner + "' in this execution: executions " + + String.join(", ", by) + " all configure it, so only '" + by.get(by.size() - 1) + + "' removes its files no longer generated, in a build that runs every one of them."); + } for (String path : swept.removed) { getLog().info("MetaData Mojo > Removed orphaned generated file no longer produced: " + path); } @@ -151,14 +159,48 @@ private void sweepOrphans(Path genStateDir, Path projectRoot, Map runOwners, + Set deferred) { + if (!runOwners.contains(owner)) return false; + List by = executionsConfiguring(owner); + if (by.size() < 2) return true; + if (by.get(by.size() - 1).equals(executionId()) && ranThisBuild().containsAll(by)) return true; + deferred.add(owner); + return false; + } + + private String executionId() { + return execution != null ? execution.getExecutionId() : null; + } + + /** The ids of this module's executions of this goal that have run in this build. */ + private Set ranThisBuild() { + @SuppressWarnings("unchecked") + Set ran = (Set) project.getContextValue(RAN_CONTEXT_KEY); + if (ran == null) { + ran = new HashSet<>(); + project.setContextValue(RAN_CONTEXT_KEY, ran); + } + return ran; + } + /** * 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. + * adopter hand-owns is no generator's output, so neither is a collision. Two executions + * whose same pair emitted one path collide whatever the content: they are two + * configurations of one generator writing one file. 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") @@ -169,10 +211,18 @@ private Set refuseSharedPaths(Map> e if (project != null) project.setContextValue(EMITTED_BY_CONTEXT_KEY, emittedBy); } List shared = new ArrayList<>(); + List twice = new ArrayList<>(); + String self = executionId(); 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())) { + Emission previous = emittedBy.putIfAbsent(path, new Emission(e.getKey(), e.getValue(), self)); + if (previous == null) continue; + if (previous.owner().equals(e.getKey())) { + if (!Objects.equals(previous.execution(), self)) { + twice.add(path + " ('" + e.getKey() + "' in executions '" + previous.execution() + + "' and '" + self + "')"); + } + } else if (!previous.hash().equals(e.getValue())) { shared.add(path + " ('" + previous.owner() + "' and '" + e.getKey() + "')"); } } @@ -183,12 +233,18 @@ private Set refuseSharedPaths(Map> e + String.join(", ", shared) + ". Configure only one generator to emit it, in one output " + "directory."); } + if (!twice.isEmpty()) { + throw new MetaDataException("Two executions of " + OWNER + " in this module configure the same " + + "generator into the same output directory and both emitted the same file, so one " + + "overwrites the other's output: " + String.join(", ", twice) + ". Give one of them its " + + "own output directory, or let only one execution emit the file."); + } Set emittedThisBuild = new HashSet<>(emittedBy.keySet()); emittedThisBuild.addAll(emitted); return emittedThisBuild; } - private record Emission(HashManifest.Owner owner, String hash) {} + private record Emission(HashManifest.Owner owner, String hash, String execution) {} /** * Whether this build is the first to record owners: the manifest has entries but none @@ -206,6 +262,7 @@ private boolean migratingOwners(Path genStateDir) { private static final String MIGRATING_CONTEXT_KEY = MetaDataGeneratorMojo.class.getName() + ".migratingOwners"; private static final String EMITTED_BY_CONTEXT_KEY = MetaDataGeneratorMojo.class.getName() + ".emittedBy"; + private static final String RAN_CONTEXT_KEY = MetaDataGeneratorMojo.class.getName() + ".ran"; /** 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. */ 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 c8324694f..d37d74292 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 @@ -444,7 +444,9 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException * 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 + * entry is left out and reads as a stranger here, as it is to {@code generate}. So is + * the entry of a pair several executions configure: only the last of them judges it, in + * a build that runs them all, and verify is not that execution. Empty * while the manifest names no owner yet: the build that records them deletes nothing. */ private Map ownedBy(Map hashes, Map owners, @@ -459,7 +461,8 @@ private Map ownedBy(Map hashes, Map e : hashes.entrySet()) { boolean inRunDir = targetHolding(targets, root.resolve(e.getKey()).normalize()) != null; - if (OrphanSweep.isCandidate(owners.get(e.getKey()), inRunDir, runOwners, this::configuredOwners)) { + if (OrphanSweep.isCandidate(owners.get(e.getKey()), inRunDir, + owner -> runOwners.contains(owner) && !sharedByExecutions(owner), this::configuredOwners)) { owned.put(e.getKey(), e.getValue()); } } 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 index 5b546ead9..3a2a23fb5 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -305,16 +306,17 @@ public void generatorRemovedFromTheMainBuild_itsFilesAreCleanedWhereverTheyLive( } @Test - public void buildWhoseEntriesAreAllItsOwn_buildsNoExtraModel() throws Exception { + public void noOpBuild_buildsNoExtraModel() throws Exception { Path mainDir = root.resolve("gen"); Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), profile("client", "", plugin("client", generators(CLIENT, "${project.basedir}/client")))); generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Old.java")); generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); - generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); - assertFalse("this run's own orphan is still cleaned", Files.exists(mainDir.resolve("Old.java"))); - assertEquals(0, modelBuilds()); + + long before = modelBuilds(); + generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); + assertEquals(0, modelBuilds() - before); } @Test @@ -342,6 +344,72 @@ public void twoExecutionsInOneModule_readTheConfiguredGeneratorsOnce() throws Ex assertEquals("one model (no profiles) for the module, shared by both executions", 1, modelBuilds() - before); } + /** Executions {@code core} and {@code ext}, in that order, both configuring {@code DTO} into + * {@code gen} (as one generator over two metadata sets would). */ + private Path coreAndExt() throws IOException { + String ext = "extgenerate" + + generators(DTO, "${project.basedir}/gen") + ""; + return pom(root, "app", "", plugin("core", generators(DTO, "${project.basedir}/gen")) + .replace("", ext + ""), ""); + } + + @Test + public void oneGeneratorInOneDirFromTwoExecutions_neitherSweepsTheOther_acrossBuilds() throws Exception { + Path gen = root.resolve("gen"); + Path pom = coreAndExt(); + for (int build = 1; build <= 2; build++) { + MavenProject module = build(pom); + generate(module, "core", NO_PROFILES, gen(DTO, gen, "A.java")); + assertTrue("build " + build + ": core leaves ext's file", build == 1 || Files.exists(gen.resolve("B.java"))); + generate(module, "ext", NO_PROFILES, gen(DTO, gen, "B.java")); + assertTrue("build " + build, Files.exists(gen.resolve("A.java")) && Files.exists(gen.resolve("B.java"))); + } + verify(build(pom), gen(DTO, gen, "A.java")); + } + + @Test + public void standaloneRunOfOneSharingExecution_deletesNothingOfTheOther() throws Exception { + Path gen = root.resolve("gen"); + Path pom = coreAndExt(); + MavenProject first = build(pom); + generate(first, "core", NO_PROFILES, gen(DTO, gen, "A.java")); + generate(first, "ext", NO_PROFILES, gen(DTO, gen, "B.java")); + + generate(build(pom), "core", NO_PROFILES, gen(DTO, gen, "A.java")); + + assertTrue("generate@core leaves ext's file", Files.exists(gen.resolve("B.java"))); + } + + @Test + public void entityRemovedFromTheLastSharingExecution_isCleanedByTheFullBuild() throws Exception { + Path gen = root.resolve("gen"); + Path pom = coreAndExt(); + MavenProject first = build(pom); + generate(first, "core", NO_PROFILES, gen(DTO, gen, "A.java")); + generate(first, "ext", NO_PROFILES, gen(DTO, gen, "B.java"), gen(DTO, gen, "C.java")); + + MavenProject second = build(pom); + generate(second, "core", NO_PROFILES, gen(DTO, gen, "A.java")); + generate(second, "ext", NO_PROFILES, gen(DTO, gen, "B.java")); + + assertFalse("no execution emits it any more", Files.exists(gen.resolve("C.java"))); + assertTrue(Files.exists(gen.resolve("A.java")) && Files.exists(gen.resolve("B.java"))); + } + + @Test + public void onePathFromOneGeneratorInTwoExecutions_isAnErrorNamingBoth() throws Exception { + Path gen = root.resolve("gen"); + MavenProject module = build(coreAndExt()); + generate(module, "core", NO_PROFILES, gen(DTO, gen, "Same.java")); + try { + generate(module, "ext", NO_PROFILES, gen(DTO, gen, "Same.java")); + fail("expected the same file from two executions to be an error"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("Same.java") + && e.getMessage().contains("'core'") && e.getMessage().contains("'ext'")); + } + } + /** 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 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 index 44b93a6e4..78e7a50ab 100644 --- 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 @@ -414,7 +414,7 @@ public void byteIdenticalReEmissionByTwoGenerators_isOneFile() throws IOExceptio MavenProject module = newBuild(null, Map.of( "codegen-web", configuration(dto), "codegen-data", configuration(repository))); - runExecution(module, "codegen-web", dto, repository); + runExecution(module, "codegen-web", dto); runExecution(module, "codegen-data", repository); assertEquals(MARKER_BODY, Files.readString(Path.of(shared, "Status.java"))); diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index 0417a3430..aa5766d53 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -61,7 +61,12 @@ does not cover deletion, and deletion without evidence is not safe on any port. 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. One path emitted with different content by two generator/output-directory + directory. An owner more than one execution configures (one generator into one + directory over two metadata sets) is judged only by the last of those executions in + declared order, once all of them have run in the build, against what all of them + emitted; a standalone run of one of them never sweeps it, and `mvn metaobjects:verify` + does not report its entries as orphans. The same path emitted by two executions with + the same owner is an error naming both executions, whatever the content. 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 From 09150b3c68fd958731588f10ffb7c8de2cf00985 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 13:21:34 -0400 Subject: [PATCH 12/22] no-mistakes(review): Refuse one generator/outputDir in two JVM executions at build time --- CHANGELOG.md | 9 +- .../migrations/jvm-codegen-hash-manifest.md | 8 ++ docs/features/own-your-codegen.md | 12 +- .../generator/util/OrphanSweep.java | 13 +-- .../generator/util/OrphanSweepTest.java | 24 ++-- .../mojo/AbstractMetaDataMojo.java | 48 ++++++-- .../mojo/MetaDataGeneratorMojo.java | 76 ++----------- .../metaobjects/mojo/MetaDataVerifyMojo.java | 8 +- .../mojo/ConfiguredGeneratorsTest.java | 107 +++++++++--------- .../MetaDataGeneratorMojoManifestTest.java | 2 +- .../ADR-0059-jvm-codegen-hash-manifest.md | 11 +- 11 files changed, 143 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf905a7a..4ab28e911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -874,11 +874,10 @@ executions in one module share the manifest without touching each other's files: 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. When two executions configure the same generator -into the same output directory, only the last of them in declared order removes that -generator's orphans, and only in a build that runs them all, so a standalone -`generate@` deletes nothing of the other's; the same file from both fails the goal. Two -modules have two. User templates, api-docs pages and the `META-INF/services` registration are not +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). diff --git a/docs/features/migrations/jvm-codegen-hash-manifest.md b/docs/features/migrations/jvm-codegen-hash-manifest.md index a9c4e3fbf..efef0d87f 100644 --- a/docs/features/migrations/jvm-codegen-hash-manifest.md +++ b/docs/features/migrations/jvm-codegen-hash-manifest.md @@ -79,6 +79,14 @@ not name the Maven execution, so `mvn metaobjects:generate` (which runs as `defa 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. + 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. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index af2fc5bad..1ec5d7c80 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -606,12 +606,12 @@ the two profiles point one generator at different directories. A generator that 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. -When two executions configure the same generator into the same output directory (one -generator over two sets of metadata, say), their files share one owner. Only the last of -those executions in declared order removes that owner's orphans, and only in a build that -ran every one of them, judging against what all of them emitted; a standalone -`mvn metaobjects:generate@` leaves them alone and says so in one line. The same file -emitted by both executions fails the goal, naming both, whatever its content. +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. 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 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 index 4883278a8..021dbb0b7 100644 --- 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 @@ -11,7 +11,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -137,16 +136,15 @@ public static Verdict classify(Map hashes, Path projectRoot, Pat /** * Whether the sweep may judge an entry owned by {@code owner} ({@code null}: recorded - * with no owner): this run judges its owner's entries ({@code judgedByRun}), or {@code configured} (every + * 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, - Predicate judgedByRun, - Supplier> configured) { - if (owner != null && judgedByRun.test(owner)) return true; + 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)); @@ -160,8 +158,7 @@ public static boolean isCandidate(HashManifest.Owner owner, boolean inRunDir, * manifest yet — there is nothing recorded, so nothing could be an orphan. */ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, Set emitted, - Predicate judgedByRun, - Supplier> configured) { + Set runOwners, Supplier> configured) { if (!HashManifest.exists(genStateDir)) { return new Result(List.of(), List.of(), List.of()); } @@ -182,7 +179,7 @@ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, 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, judgedByRun, configured)) continue; + 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. 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 index 610f7c64e..ec55953c9 100644 --- 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 @@ -94,7 +94,7 @@ public void renameCase_pristineOrphanIsDeletedAndForgotten() throws Exception { 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)::contains, () -> null); + 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")); @@ -115,7 +115,7 @@ public void handEditedOrphan_isRefused_keptAndStillRecorded() throws Exception { 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)::contains, () -> null); + 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")); @@ -134,7 +134,7 @@ public void handEditedOrphan_underAllowOverwrite_isDeleted() throws Exception { 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)::contains, () -> null); + 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")); @@ -153,7 +153,7 @@ public void strangerFile_isNeverTouched() throws Exception { 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)::contains, () -> null); + 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")); @@ -167,7 +167,7 @@ public void noManifestAtAll_sweepIsANoOp() throws Exception { 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)::contains, () -> null); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); assertTrue(result.removed.isEmpty()); assertTrue(result.refused.isEmpty()); @@ -183,7 +183,7 @@ public void alreadyGoneRecordedFile_isForgotten() throws Exception { 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)::contains, () -> null); + OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null); assertFalse(HashManifest.load(genStateDir).containsKey("gen/Deleted.java")); } @@ -209,7 +209,7 @@ public void twoDirectories_neitherSweepsTheOthersFiles() throws Exception { // 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)::contains, () -> null); + 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)); @@ -247,7 +247,7 @@ public void anotherConfiguredGeneratorsEntries_areNeverCandidates_evenInASharedD OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", true); OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), - Set.of(OWNER)::contains, () -> Set.of(OWNER, DTO, REMOVED)); + 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"))); @@ -265,7 +265,7 @@ public void anEntryWhoseOwnerIsConfiguredNowhere_isAnOrphan_whereverItLives() th OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), - Set.of(OWNER)::contains, () -> Set.of(OWNER)); + Set.of(OWNER), () -> Set.of(OWNER)); assertEquals(List.of("gen/Mine.java", "gen/Theirs.java", "gen/Unowned.java", "web/Route.java"), result.removed); @@ -280,7 +280,7 @@ public void withTheModulesConfigurationUnknown_onlyTheRunsOwnEntriesAreCandidate OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false); OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), - Set.of(OWNER)::contains, () -> null); + Set.of(OWNER), () -> null); assertEquals(List.of("gen/Mine.java"), result.removed); assertTrue(Files.exists(outDir.resolve("Unowned.java"))); @@ -301,7 +301,7 @@ public void nestedDirectory_editedOrphanIsJudgedOnceByTheInnermostDirectory() th 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)::contains, () -> null); + genStateDir, projectRoot, List.of(outerKeep, innerOverwrite), Set.of(), Set.of(OWNER), () -> null); assertFalse(Files.exists(orphan)); assertEquals(List.of("inner/Old.java"), result.overwrittenDeleted); @@ -320,7 +320,7 @@ public void nestedDirectory_keepInsideOverwrite_isRefusedNotDeleted() throws Exc 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)::contains, () -> null); + 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); 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 b5a89cdbd..89f031ba4 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 @@ -121,17 +121,23 @@ protected Set configuredOwners() { } /** - * The ids of the module's {@code metaobjects:generate} executions that configure - * {@code owner}, in declared order; empty when none does or that is unknown. The - * plugin-level configuration alone is no execution. + * 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. */ - protected List executionsConfiguring(HashManifest.Owner owner) { - return configured().executions().getOrDefault(owner, List.of()); - } - - /** Whether more than one execution of the module configures {@code owner}. */ - protected boolean sharedByExecutions(HashManifest.Owner owner) { - return executionsConfiguring(owner).size() > 1; + protected void refuseSharedGeneratorDirs() { + List shared = new ArrayList<>(); + configured().executions().forEach((owner, by) -> { + if (by.size() > 1) shared.add("'" + owner + "' in executions " + String.join(" and ", by)); + }); + 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() { @@ -171,6 +177,14 @@ private ConfiguredOwners readConfiguredOwners() { } Set out = 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); @@ -190,10 +204,22 @@ private ConfiguredOwners readConfiguredOwners() { } if (!declared) return ConfiguredOwners.UNKNOWN; Map> byOwner = new HashMap<>(); - executions.forEach((owner, ids) -> byOwner.put(owner, List.copyOf(ids))); + 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); } + 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 (e.getGoals().contains("generate")) ids.add(e.getId()); + } + return ids; + } + /** 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) { 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 6e3d092ed..202df03da 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 @@ -46,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 @@ -72,7 +73,6 @@ protected void executeGenerators(MetaDataLoader loader, List generato // 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); - if (project != null) ranThisBuild().add(executionId()); // 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 @@ -139,15 +139,8 @@ private void sweepOrphans(Path genStateDir, Path projectRoot, Map deferred = new TreeSet<>(Comparator.comparing(HashManifest.Owner::toString)); - OrphanSweep.Result swept = OrphanSweep.sweep(genStateDir, projectRoot, dirs, emitted, - owner -> judges(owner, runOwners, deferred), this::configuredOwners); - for (HashManifest.Owner owner : deferred) { - List by = executionsConfiguring(owner); - getLog().info("MetaData Mojo > Not cleaning up files of '" + owner + "' in this execution: executions " - + String.join(", ", by) + " all configure it, so only '" + by.get(by.size() - 1) - + "' removes its files no longer generated, in a build that runs every one of them."); - } + 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); } @@ -159,48 +152,14 @@ private void sweepOrphans(Path genStateDir, Path projectRoot, Map runOwners, - Set deferred) { - if (!runOwners.contains(owner)) return false; - List by = executionsConfiguring(owner); - if (by.size() < 2) return true; - if (by.get(by.size() - 1).equals(executionId()) && ranThisBuild().containsAll(by)) return true; - deferred.add(owner); - return false; - } - - private String executionId() { - return execution != null ? execution.getExecutionId() : null; - } - - /** The ids of this module's executions of this goal that have run in this build. */ - private Set ranThisBuild() { - @SuppressWarnings("unchecked") - Set ran = (Set) project.getContextValue(RAN_CONTEXT_KEY); - if (ran == null) { - ran = new HashSet<>(); - project.setContextValue(RAN_CONTEXT_KEY, ran); - } - return ran; - } - /** * 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. Two executions - * whose same pair emitted one path collide whatever the content: they are two - * configurations of one generator writing one file. 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. + * 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") @@ -211,18 +170,10 @@ private Set refuseSharedPaths(Map> e if (project != null) project.setContextValue(EMITTED_BY_CONTEXT_KEY, emittedBy); } List shared = new ArrayList<>(); - List twice = new ArrayList<>(); - String self = executionId(); 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(), self)); - if (previous == null) continue; - if (previous.owner().equals(e.getKey())) { - if (!Objects.equals(previous.execution(), self)) { - twice.add(path + " ('" + e.getKey() + "' in executions '" + previous.execution() - + "' and '" + self + "')"); - } - } else if (!previous.hash().equals(e.getValue())) { + 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() + "')"); } } @@ -233,18 +184,12 @@ private Set refuseSharedPaths(Map> e + String.join(", ", shared) + ". Configure only one generator to emit it, in one output " + "directory."); } - if (!twice.isEmpty()) { - throw new MetaDataException("Two executions of " + OWNER + " in this module configure the same " - + "generator into the same output directory and both emitted the same file, so one " - + "overwrites the other's output: " + String.join(", ", twice) + ". Give one of them its " - + "own output directory, or let only one execution emit the file."); - } Set emittedThisBuild = new HashSet<>(emittedBy.keySet()); emittedThisBuild.addAll(emitted); return emittedThisBuild; } - private record Emission(HashManifest.Owner owner, String hash, String execution) {} + private record Emission(HashManifest.Owner owner, String hash) {} /** * Whether this build is the first to record owners: the manifest has entries but none @@ -262,7 +207,6 @@ private boolean migratingOwners(Path genStateDir) { private static final String MIGRATING_CONTEXT_KEY = MetaDataGeneratorMojo.class.getName() + ".migratingOwners"; private static final String EMITTED_BY_CONTEXT_KEY = MetaDataGeneratorMojo.class.getName() + ".emittedBy"; - private static final String RAN_CONTEXT_KEY = MetaDataGeneratorMojo.class.getName() + ".ran"; /** 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. */ 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 d37d74292..dc351d8ab 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 @@ -168,6 +168,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { "meta:verify: unknown mode '" + mode + "'. Valid modes: 'codegen' (default), 'templates'."); } + refuseSharedGeneratorDirs(); verifyCodegen(); } @@ -444,9 +445,7 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException * 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}. So is - * the entry of a pair several executions configure: only the last of them judges it, in - * a build that runs them all, and verify is not that execution. Empty + * 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, @@ -461,8 +460,7 @@ private Map ownedBy(Map hashes, Map e : hashes.entrySet()) { boolean inRunDir = targetHolding(targets, root.resolve(e.getKey()).normalize()) != null; - if (OrphanSweep.isCandidate(owners.get(e.getKey()), inRunDir, - owner -> runOwners.contains(owner) && !sharedByExecutions(owner), this::configuredOwners)) { + if (OrphanSweep.isCandidate(owners.get(e.getKey()), inRunDir, runOwners, this::configuredOwners)) { owned.put(e.getKey(), e.getValue()); } } 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 index 3a2a23fb5..866eb0486 100644 --- 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 @@ -305,20 +305,6 @@ public void generatorRemovedFromTheMainBuild_itsFilesAreCleanedWhereverTheyLive( assertTrue(Files.exists(mainDir.resolve("Dto.java"))); } - @Test - public void noOpBuild_buildsNoExtraModel() throws Exception { - Path mainDir = root.resolve("gen"); - Path pom = pom(root, "app", "", plugin("codegen", generators(DTO, "${project.basedir}/gen")), - profile("client", "", plugin("client", generators(CLIENT, "${project.basedir}/client")))); - generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Old.java")); - generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); - assertFalse("this run's own orphan is still cleaned", Files.exists(mainDir.resolve("Old.java"))); - - long before = modelBuilds(); - generate(build(pom), "codegen", NO_PROFILES, gen(DTO, mainDir, "Dto.java")); - assertEquals(0, modelBuilds() - before); - } - @Test public void twoExecutionsInOneModule_readTheConfiguredGeneratorsOnce() throws Exception { Path mainDir = root.resolve("gen"); @@ -344,70 +330,81 @@ public void twoExecutionsInOneModule_readTheConfiguredGeneratorsOnce() throws Ex assertEquals("one model (no profiles) for the module, shared by both executions", 1, modelBuilds() - before); } - /** Executions {@code core} and {@code ext}, in that order, both configuring {@code DTO} into - * {@code gen} (as one generator over two metadata sets would). */ - private Path coreAndExt() throws IOException { - String ext = "extgenerate" - + generators(DTO, "${project.basedir}/gen") + ""; - return pom(root, "app", "", plugin("core", generators(DTO, "${project.basedir}/gen")) - .replace("", ext + ""), ""); + private static String execution(String id, String generators) { + return "" + id + "generate" + + generators + ""; } @Test - public void oneGeneratorInOneDirFromTwoExecutions_neitherSweepsTheOther_acrossBuilds() throws Exception { + public void oneGeneratorInOneDirFromTwoExecutions_failsBeforeWritingAnything() throws Exception { Path gen = root.resolve("gen"); - Path pom = coreAndExt(); - for (int build = 1; build <= 2; build++) { - MavenProject module = build(pom); - generate(module, "core", NO_PROFILES, gen(DTO, gen, "A.java")); - assertTrue("build " + build + ": core leaves ext's file", build == 1 || Files.exists(gen.resolve("B.java"))); - generate(module, "ext", NO_PROFILES, gen(DTO, gen, "B.java")); - assertTrue("build " + build, Files.exists(gen.resolve("A.java")) && Files.exists(gen.resolve("B.java"))); + 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'")); } - verify(build(pom), gen(DTO, gen, "A.java")); } @Test - public void standaloneRunOfOneSharingExecution_deletesNothingOfTheOther() throws Exception { + public void oneGeneratorInOneDirFromAProfilesExecution_failsNamingTheProfile() throws Exception { Path gen = root.resolve("gen"); - Path pom = coreAndExt(); - MavenProject first = build(pom); - generate(first, "core", NO_PROFILES, gen(DTO, gen, "A.java")); - generate(first, "ext", NO_PROFILES, gen(DTO, gen, "B.java")); - - generate(build(pom), "core", NO_PROFILES, gen(DTO, gen, "A.java")); - - assertTrue("generate@core leaves ext's file", Files.exists(gen.resolve("B.java"))); + 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 entityRemovedFromTheLastSharingExecution_isCleanedByTheFullBuild() throws Exception { + public void twoExecutionsInTwoDirs_eachCleansItsOwnOrphans() throws Exception { Path gen = root.resolve("gen"); - Path pom = coreAndExt(); + 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, gen, "B.java"), gen(DTO, gen, "C.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")); - generate(second, "ext", NO_PROFILES, gen(DTO, gen, "B.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(gen.resolve("C.java"))); - assertTrue(Files.exists(gen.resolve("A.java")) && Files.exists(gen.resolve("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"))); } + /** {@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 onePathFromOneGeneratorInTwoExecutions_isAnErrorNamingBoth() throws Exception { + public void commandLineRunAndTheExecutionInheritingItsConfiguration_inOneBuild_isGreen() throws Exception { Path gen = root.resolve("gen"); - MavenProject module = build(coreAndExt()); - generate(module, "core", NO_PROFILES, gen(DTO, gen, "Same.java")); - try { - generate(module, "ext", NO_PROFILES, gen(DTO, gen, "Same.java")); - fail("expected the same file from two executions to be an error"); - } catch (MetaDataException e) { - assertTrue(e.getMessage(), e.getMessage().contains("Same.java") - && e.getMessage().contains("'core'") && e.getMessage().contains("'ext'")); - } + 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")); } /** A manifest an 8.1.0 pre-release wrote names the Maven execution, or nothing. The first 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 index 78e7a50ab..44b93a6e4 100644 --- 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 @@ -414,7 +414,7 @@ public void byteIdenticalReEmissionByTwoGenerators_isOneFile() throws IOExceptio MavenProject module = newBuild(null, Map.of( "codegen-web", configuration(dto), "codegen-data", configuration(repository))); - runExecution(module, "codegen-web", dto); + runExecution(module, "codegen-web", dto, repository); runExecution(module, "codegen-data", repository); assertEquals(MARKER_BODY, Files.readString(Path.of(shared, "Status.java"))); diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index aa5766d53..d43653c2e 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -61,12 +61,11 @@ does not cover deletion, and deletion without evidence is not safe on any port. 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. An owner more than one execution configures (one generator into one - directory over two metadata sets) is judged only by the last of those executions in - declared order, once all of them have run in the build, against what all of them - emitted; a standalone run of one of them never sweeps it, and `mvn metaobjects:verify` - does not report its entries as orphans. The same path emitted by two executions with - the same owner is an error naming both executions, whatever the content. One path emitted with different content by two generator/output-directory + 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. 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 From 9d1cad4593ac205b3debd20fbbf882aa4eb2a0fa Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 13:31:18 -0400 Subject: [PATCH 13/22] no-mistakes(review): Exclude phase-none executions from JVM duplicate-pair check --- .../migrations/jvm-codegen-hash-manifest.md | 3 +- docs/features/own-your-codegen.md | 5 ++- .../mojo/AbstractMetaDataMojo.java | 40 +++++++++++++++++-- .../mojo/ConfiguredGeneratorsTest.java | 35 ++++++++++++++++ .../ADR-0059-jvm-codegen-hash-manifest.md | 5 ++- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/docs/features/migrations/jvm-codegen-hash-manifest.md b/docs/features/migrations/jvm-codegen-hash-manifest.md index efef0d87f..4f91fef20 100644 --- a/docs/features/migrations/jvm-codegen-hash-manifest.md +++ b/docs/features/migrations/jvm-codegen-hash-manifest.md @@ -85,7 +85,8 @@ neither run could tell the other's output from its own stale files. `metaobjects 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. +of its own, so `mvn metaobjects:generate` beside the execution that inherits it is fine, and +an inherited execution switched off with `none` is not counted. 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. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 1ec5d7c80..338fcad65 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -611,7 +611,10 @@ main build or any profile: their files would share one owner. `generate` and `ve 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. +one configuration run twice. An execution switched off with `none` (how a +module disables one it inherits from a parent POM) is not counted, and its generators are +not configured; 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 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 89f031ba4..b43aebc77 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 @@ -126,13 +126,28 @@ protected Set configuredOwners() { * 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. + * the one execution that inherits it are the same configuration. An execution disabled + * with {@code none} is not counted; 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<>(); - configured().executions().forEach((owner, by) -> { + 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 " @@ -193,7 +208,7 @@ private ConfiguredOwners readConfiguredOwners() { Xpp3Dom pluginLevel = plugin.getConfiguration() instanceof Xpp3Dom dom ? dom : null; out.addAll(configuredIn(pluginLevel)); for (PluginExecution e : plugin.getExecutions()) { - if (!e.getGoals().contains("generate")) continue; + if (!isEnabledGenerate(e)) continue; if (!(e.getConfiguration() instanceof Xpp3Dom execution)) continue; for (HashManifest.Owner owner : configuredIn(pluginLevel == null ? execution : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(execution), pluginLevel))) { @@ -215,11 +230,28 @@ private static Set generateExecutionIds(MavenProject model) { Plugin plugin = model.getPlugin(PLUGIN_KEY); if (plugin == null) return ids; for (PluginExecution e : plugin.getExecutions()) { - if (e.getGoals().contains("generate")) ids.add(e.getId()); + 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) { 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 index 866eb0486..c9b14ceb7 100644 --- 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 @@ -389,6 +389,41 @@ public void twoExecutionsInTwoDirs_eachCleansItsOwnOrphans() throws Exception { assertTrue(Files.exists(gen.resolve("A.java")) && Files.exists(client.resolve("B.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 diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index d43653c2e..e92875189 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -65,7 +65,10 @@ does not cover deletion, and deletion without evidence is not safe on any port. 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. One path emitted with different content by two generator/output-directory + `default-cli` beside the execution that inherits it is one configuration. An execution + disabled with `none` counts nowhere: it owns no pair and configures no + generator. 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 From 3312a07a683fb3af4dba4c5e77e9079953149bd2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 13:36:24 -0400 Subject: [PATCH 14/22] no-mistakes(review): Keep phase-none executions' generators configured for sweep and verify --- .../migrations/jvm-codegen-hash-manifest.md | 5 ++++- docs/features/own-your-codegen.md | 10 ++++++---- .../metaobjects/mojo/AbstractMetaDataMojo.java | 9 ++++++--- .../mojo/ConfiguredGeneratorsTest.java | 16 ++++++++++++++++ .../ADR-0059-jvm-codegen-hash-manifest.md | 5 +++-- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/features/migrations/jvm-codegen-hash-manifest.md b/docs/features/migrations/jvm-codegen-hash-manifest.md index 4f91fef20..1be02cc14 100644 --- a/docs/features/migrations/jvm-codegen-hash-manifest.md +++ b/docs/features/migrations/jvm-codegen-hash-manifest.md @@ -86,7 +86,10 @@ and `metaobjects:verify` now fail before writing anything, naming both execution 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 inherited execution switched off with `none` is not counted. +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. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 338fcad65..513ccb3e8 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -611,10 +611,12 @@ main build or any profile: their files would share one owner. `generate` and `ve 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 switched off with `none` (how a -module disables one it inherits from a parent POM) is not counted, and its generators are -not configured; run explicitly (`mvn metaobjects:generate@`), it is refused when an -enabled execution owns the same generator and output directory. +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 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 b43aebc77..9a3dc4b81 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 @@ -127,7 +127,8 @@ protected Set configuredOwners() { * 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; run explicitly + * 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. */ @@ -208,12 +209,14 @@ private ConfiguredOwners readConfiguredOwners() { Xpp3Dom pluginLevel = plugin.getConfiguration() instanceof Xpp3Dom dom ? dom : null; out.addAll(configuredIn(pluginLevel)); for (PluginExecution e : plugin.getExecutions()) { - if (!isEnabledGenerate(e)) continue; + 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))) { out.add(owner); - executions.computeIfAbsent(owner, k -> new LinkedHashSet<>()).add(e.getId()); + if (isEnabledGenerate(e)) { + executions.computeIfAbsent(owner, k -> new LinkedHashSet<>()).add(e.getId()); + } } } } 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 index c9b14ceb7..d5681bd7e 100644 --- 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 @@ -389,6 +389,22 @@ public void twoExecutionsInTwoDirs_eachCleansItsOwnOrphans() throws Exception { 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 { diff --git a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md index e92875189..63f692f41 100644 --- a/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md +++ b/spec/decisions/ADR-0059-jvm-codegen-hash-manifest.md @@ -66,8 +66,9 @@ does not cover deletion, and deletion without evidence is not safe on any port. `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 - disabled with `none` counts nowhere: it owns no pair and configures no - generator. Invoked explicitly (`generate@`), it is refused when an enabled 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 From 02b6a34d08b2eccee43b434496d579127c772b3a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 17 Oct 2026 14:04:42 -0400 Subject: [PATCH 15/22] no-mistakes(review): Resolve inherited refs by bound target; fix Python @from regression --- CHANGELOG.md | 33 +++ docs/CONFORMANCE.md | 2 +- .../codegen/generators/routes.ts | 41 ++-- .../codegen/templates/drizzle-schema.ts | 19 +- .../codegen/templates/queries.ts | 19 +- .../codegen/templates/relations-block.ts | 20 +- .../showcase/codegen/generators/routes.ts | 41 ++-- .../codegen/templates/drizzle-schema.ts | 19 +- .../showcase/codegen/templates/queries.ts | 19 +- .../codegen/templates/relations-block.ts | 20 +- fixtures/codegen-conformance/README.md | 10 + .../cross-package-binding/README.md | 28 +++ .../expected-bindings.json | 42 ++++ .../cross-package-binding/input/meta.a.json | 126 ++++++++++ .../input/meta.acme.json | 48 ++++ .../cross-package-binding/input/meta.b.json | 38 +++ .../input/meta.root.json | 162 +++++++++++++ .../csharp/MetaObjects.Cli/VerifyCommand.cs | 4 +- .../CrossPackageBindingConformanceTests.cs | 165 +++++++++++++ .../ApiDocs/CSharpApiModelBuilder.cs | 6 +- .../Generators/CallableGenerator.cs | 3 +- .../Generators/DbContextGenerator.cs | 8 +- .../Generators/EntityGenerator.cs | 8 +- .../Generators/ExtractDelegateEmitter.cs | 28 +-- .../Generators/FindInbound.cs | 5 +- .../Generators/M2MNavigation.cs | 12 +- .../Generators/OutputParserGenerator.cs | 3 +- .../Generators/RenderHelperGenerator.cs | 45 +--- .../Generators/ReverseFinders.cs | 12 +- .../MetaObjects.Codegen/PayloadFieldTree.cs | 25 +- .../Runtime/M2MResolver.cs | 5 +- .../MetaObjects.Codegen/ValueObjectNames.cs | 14 +- .../InheritedReferenceBindingTests.cs | 77 ++++++ .../Core/Relationship/M2MFields.cs | 111 +-------- .../Relationship/RelationshipReferences.cs | 25 +- .../MetaObjects/Core/Reporting/ReportShape.cs | 19 +- .../Loader/ValidationPasses.Reporting.cs | 9 +- .../MetaObjects/Loader/ValidationPasses.cs | 48 ++-- .../Persistence/Db/ReferentialActions.cs | 25 +- .../generator/verify/TemplateVerify.java | 53 +--- .../generator/kotlin/FindInbound.kt | 5 +- .../kotlin/KotlinExposedTableGenerator.kt | 6 +- .../generator/kotlin/KotlinGenUtil.kt | 8 + .../generator/kotlin/KotlinM2mSupport.kt | 29 ++- .../kotlin/KotlinRelationsGenerator.kt | 14 +- .../kotlin/KotlinRenderHelperGenerator.kt | 3 +- .../kotlin/KotlinSpringControllerGenerator.kt | 10 +- .../kotlin/KotlinStoredProcGenerator.kt | 8 +- .../kotlin/apidocs/KotlinApiModelBuilder.kt | 5 +- ...otlinCrossPackageBindingConformanceTest.kt | 215 +++++++++++++++++ .../direct/plantuml/PlantUMLWriter.java | 13 +- .../apidocs/JavaApiModelBuilder.java | 5 +- .../generator/spring/FindInbound.java | 7 +- .../spring/LlmTraceHelperGenerator.java | 6 +- .../generator/spring/SpringM2mSupport.java | 10 +- .../generator/spring/SpringNaming.java | 10 + .../spring/SpringRenderHelperGenerator.java | 53 +--- .../spring/SpringRepositoryGenerator.java | 3 +- .../CrossPackageBindingConformanceTest.java | 226 ++++++++++++++++++ .../loader/ReportingValidation.java | 7 +- .../metaobjects/loader/ValidationPhase.java | 28 ++- .../metaobjects/relationship/M2MFields.java | 90 ++----- .../relationship/RelationshipReferences.java | 28 ++- .../metaobjects/reporting/ReportShape.java | 15 +- .../loader/InheritedReferenceBindingTest.java | 114 +++++++++ .../metaobjects/manager/db/M2MResolver.java | 9 +- server/python/src/metaobjects/cli.py | 6 +- .../codegen/generators/extractor_generator.py | 4 +- .../codegen/generators/find_inbound.py | 8 +- .../generators/output_parser_generator.py | 4 +- .../generators/output_prompt_generator.py | 3 +- .../generators/render_helper_generator.py | 35 +-- .../codegen/generators/router_generator.py | 19 +- .../generators/trace_helper_generator.py | 4 +- .../codegen/runtime/api_docs_builder.py | 11 +- .../runtime/extract_delegate_emitter.py | 22 +- .../codegen/runtime/m2m_codegen.py | 9 +- .../src/metaobjects/codegen/value_objects.py | 25 +- .../metaobjects/loader/validate_reporting.py | 10 +- .../metaobjects/loader/validation_passes.py | 19 +- .../core/relationship/derive_m2m_fields.py | 86 +------ .../relationship/relationship_references.py | 15 +- .../meta/core/reporting/report_shape.py | 12 +- .../src/metaobjects/runtime/n2m_resolver.py | 2 +- .../metaobjects/runtime/validator_runner.py | 19 +- .../test_cross_package_binding_conformance.py | 109 +++++++++ .../test_conformance_twin_roundtrip.py | 4 +- .../unit/test_inherited_reference_binding.py | 37 +++ .../unit/test_relationship_references.py | 30 ++- .../src/templates/hooks-file.ts | 2 +- .../codegen-ts/src/cube/build-cube-model.ts | 5 +- .../codegen-ts/src/generators/api-model.ts | 6 +- .../packages/codegen-ts/src/model-walk.ts | 15 +- .../packages/codegen-ts/src/pk-resolver.ts | 41 ++-- .../src/projection/extract-view-spec.ts | 31 +-- .../src/projection/report-resolve.ts | 9 +- .../codegen-ts/src/reference/routes.ts | 41 ++-- .../codegen-ts/src/relation-resolver.ts | 64 ++--- .../src/templates/drizzle-schema.ts | 19 +- .../codegen-ts/src/templates/queries.ts | 19 +- .../src/templates/relations-block.ts | 20 +- .../codegen-ts/src/templates/routes-file.ts | 42 ++-- .../cross-package-binding-conformance.test.ts | 169 +++++++++++++ .../packages/docs-site/src/link-graph.ts | 79 +++--- .../core/relationship/derive-m2m-fields.ts | 63 ++--- .../resolve-relationship-reference.ts | 23 +- .../src/core/reporting/report-shape.ts | 15 +- .../src/loader/reporting-validation.ts | 9 +- .../metadata/src/loader/validation-passes.ts | 6 +- .../test/inherited-root-reference.test.ts | 64 +++++ .../test/reporting-validation.test.ts | 67 ++++++ .../resolve-relationship-reference.test.ts | 21 +- .../migrate-ts/src/referential-actions.ts | 56 ++--- .../packages/runtime-ts/src/extract-object.ts | 12 +- .../runtime-ts/src/relation-resolver.ts | 2 +- .../runtime-ts/src/validator-runner.ts | 12 +- .../test-generators/src/generators/routes.ts | 41 ++-- .../src/templates/drizzle-schema.ts | 19 +- .../test-generators/src/templates/queries.ts | 19 +- .../src/templates/relations-block.ts | 20 +- ...-references-resolve-once-after-the-tree.md | 19 +- 121 files changed, 2571 insertions(+), 1276 deletions(-) create mode 100644 fixtures/codegen-conformance/cross-package-binding/README.md create mode 100644 fixtures/codegen-conformance/cross-package-binding/expected-bindings.json create mode 100644 fixtures/codegen-conformance/cross-package-binding/input/meta.a.json create mode 100644 fixtures/codegen-conformance/cross-package-binding/input/meta.acme.json create mode 100644 fixtures/codegen-conformance/cross-package-binding/input/meta.b.json create mode 100644 fixtures/codegen-conformance/cross-package-binding/input/meta.root.json create mode 100644 server/csharp/MetaObjects.Codegen.Tests/CrossPackageBindingConformanceTests.cs create mode 100644 server/csharp/MetaObjects.Conformance.Tests/InheritedReferenceBindingTests.cs create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossPackageBindingConformanceTest.kt create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CrossPackageBindingConformanceTest.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/loader/InheritedReferenceBindingTest.java create mode 100644 server/python/tests/codegen/test_cross_package_binding_conformance.py create mode 100644 server/python/tests/unit/test_inherited_reference_binding.py create mode 100644 server/typescript/packages/codegen-ts/test/golden/cross-package-binding-conformance.test.ts create mode 100644 server/typescript/packages/metadata/test/inherited-root-reference.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d872e085a..71e0dacba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -867,6 +867,10 @@ loaded, then rewrites each to its canonical spelling before the tree is frozen. object the loader bound, and `MetaRoot.findObject` accepts a canonical value. - 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. **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`, @@ -1331,6 +1335,35 @@ until you regenerate. 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. + A new shared input, `fixtures/codegen-conformance/cross-package-binding/`, carries a decision + table every port's codegen test answers. +- **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. - **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/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index b893508cf..4c26db283 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -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 | ✓ | ✓ | ✓ | ✓ | ✓ | 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..91cdf348d 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,6 +12,8 @@ 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 { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; @@ -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 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/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..91cdf348d 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,6 +12,8 @@ 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 { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; @@ -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 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/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/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs index 478cbc879..922ab8ee8 100644 --- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs +++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs @@ -493,8 +493,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