diff --git a/.fvmrc b/.fvmrc index 827b5564..c257c54c 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.47.5" + "flutter": "3.50.0-0.1.pre" } diff --git a/.github/actions/setup-flutter/action.yml b/.github/actions/setup-flutter/action.yml index c1f02753..9c844291 100644 --- a/.github/actions/setup-flutter/action.yml +++ b/.github/actions/setup-flutter/action.yml @@ -45,6 +45,7 @@ runs: { printf 'flutter-version=%s\n' "$version" printf 'dart-version=%s\n' "$(printf '%s' "$release" | jq -r '.dart')" + printf 'channel=%s\n' "$(printf '%s' "$release" | jq -r '.channel // "stable"')" printf 'sha256=%s\n' "$(printf '%s' "$release" | jq -r '.sha256')" } >> "$GITHUB_OUTPUT" @@ -61,11 +62,12 @@ runs: shell: bash env: FLUTTER_VERSION: ${{ steps.release.outputs.flutter-version }} + FLUTTER_CHANNEL: ${{ steps.release.outputs.channel }} FLUTTER_ARCHIVE_SHA256: ${{ steps.release.outputs.sha256 }} FLUTTER_ARCHIVE: ${{ runner.temp }}/flutter-archive/${{ steps.release.outputs.flutter-version }}/flutter.tar.xz run: | set -euo pipefail - archive_url="https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" + archive_url="https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz" mkdir -p "$(dirname "$FLUTTER_ARCHIVE")" if [ ! -f "$FLUTTER_ARCHIVE" ]; then curl --fail --location --silent --show-error \ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 583dc832..7c28c043 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,23 +15,24 @@ - `example`: sample usage. ## Environment and setup -- Required SDKs: Dart `>=3.9.0 <4.0.0`, Flutter `>=3.35.0` (see `/pubspec.yaml`). +- Required SDKs: Dart `>=3.14.0-315.1.beta <4.0.0`, Flutter `>=3.50.0-0.1.pre` (see `/pubspec.yaml`). - CI pins Flutter to the version in `/.fvmrc`. Add a new version to `/.github/flutter-releases.json` with its checksum before a workflow may install it. - Use from repo root: 1. `dart pub get` - 2. `dart run melos bootstrap` -- Optional one-shot setup script: `./setup.sh` (validates the Flutter SDK, bootstraps the workspace, and installs Node tools when npm is available). + 2. `dart pub global activate melos 8.9.0` (must match the exact root dev dependency) + 3. `dart pub global run melos bootstrap` +- Optional one-shot setup script: `./setup.sh` (validates the Flutter SDK, activates the pinned Melos CLI, bootstraps the workspace, and installs Node tools when npm is available). ## Commands you should run -- Full CI-equivalent local check: `dart run melos run test --no-select` +- Full CI-equivalent local check: `dart pub global run melos run test --no-select` - Runs strict analyze (`dart analyze . --fatal-infos`) and package tests. - Useful targeted commands: - - `dart run melos run analyze` - - `dart run melos run test:dart` - - `dart run melos run test:flutter` - - `dart run melos run build` (when generator-related code changes) - - `dart run melos run test:gen` (for generator changes) - - `dart run melos run validate-jsonschema` (for JSON Schema conformance tooling) + - `dart pub global run melos run analyze` + - `dart pub global run melos run test:dart` + - `dart pub global run melos run test:flutter` + - `dart pub global run melos run build` (when generator-related code changes) + - `dart pub global run melos run test:gen` (for generator changes) + - `dart pub global run melos run validate-jsonschema` (for JSON Schema conformance tooling) ## Change-scope guidance - Keep changes minimal and package-scoped; do not refactor unrelated files. @@ -44,12 +45,12 @@ - `/.github/workflows/release.yml` verifies the tag with `dart scripts/verify_release_tag.dart` and reruns the preflight before it publishes. - Conventional Commits are expected for commit messages. - Versioning and tag-only publishing are documented in `/PUBLISHING.md`. - Use `dart run melos version` to prepare versions; publication runs only from + Use `dart pub global run melos version` to prepare versions; publication runs only from a reviewed `v*` tag through the release workflow. ## Errors encountered during onboarding and workarounds 1. **Error:** `melos: command not found` when running checks in a fresh environment. - **Workaround:** resolve root dependencies with `dart pub get`, then invoke the workspace-local executable via `dart run melos ...`. + **Workaround:** complete the global Melos activation in the setup steps above, then use `dart pub global run melos ...`; a global executable `PATH` entry is not needed. Root dependency resolution alone does not install the global CLI. 2. **Error:** `dart: command not found` in bare sandbox environments. **Workaround:** install Flutter (which includes Dart), then run `./setup.sh`. 3. **Observed CI state:** workflow run may show `conclusion: action_required` with no jobs for PR contexts awaiting approval/permissions. diff --git a/.github/flutter-releases.json b/.github/flutter-releases.json index cd1fb1a7..6c5586df 100644 --- a/.github/flutter-releases.json +++ b/.github/flutter-releases.json @@ -12,6 +12,11 @@ "3.47.5": { "dart": "3.13.4", "sha256": "2132e990f236f8d22e7c6314b29a191a95b10d7cbcfec9b4e2e303d996652cbb" + }, + "3.50.0-0.1.pre": { + "dart": "3.14.0-315.1.beta", + "channel": "beta", + "sha256": "62f0ae902d0feb380e45e257777132c3dd37800278cc69561393bfe2be8da53c" } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28e87c82..99c2dc54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,16 +35,19 @@ jobs: - name: Resolve workspace dependencies run: dart pub get + - name: Activate the pinned Melos CLI + run: dart pub global activate melos 8.9.0 + - name: Check formatting - run: dart format --output=none --set-exit-if-changed . + run: dart run scripts/format.dart --output=none --set-exit-if-changed - name: Analyze and test every package - run: dart run melos run ci --no-select + run: dart pub global run melos run ci --no-select - name: Test numeric parity and JSON Schema imports in Chrome working-directory: packages/ack run: | - dart test --platform chrome \ + dart --enable-experiment=augmentations test --platform chrome \ test/schemas/numeric_platform_parity_test.dart \ test/schemas/extensions/numeric_extensions_test.dart \ test/schemas/extensions/list_schema_extensions_test.dart \ @@ -54,4 +57,8 @@ jobs: - name: Test JSON Schema import bridge in Chrome working-directory: packages/ack_json_schema_builder - run: dart test --platform chrome test/from_json_schema_builder_test.dart + run: dart --enable-experiment=augmentations test --platform chrome test/from_json_schema_builder_test.dart + + - name: Test augmented models in Chrome + working-directory: example + run: dart --enable-experiment=augmentations test --platform chrome test/class_first_models_test.dart diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index 90d097e3..432b8f16 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -25,8 +25,8 @@ env: # against the change severity, so a breaking change requires a major bump. API_BASELINE_VERSION: '1.6.2' # The lowest SDK versions that packages/*/pubspec.yaml accept. - MINIMUM_DART_VERSION: '3.11.0' - MINIMUM_FLUTTER_VERSION: '3.41.2' + MINIMUM_DART_VERSION: '3.14.0-315.1.beta' + MINIMUM_FLUTTER_VERSION: '3.50.0-0.1.pre' jobs: @@ -46,10 +46,8 @@ jobs: - name: Resolve workspace dependencies run: dart pub get - # The complete workspace cannot resolve on the minimum SDK, because - # flutter_test pins an older test_api than ack_generator's analyzer floor - # allows. The pure-Dart members still must resolve, so they get their own - # staged workspace. + # Stage pure-Dart members without Flutter's SDK packages to verify that + # consumers using the standalone minimum Dart SDK can resolve them. - name: Stage the pure-Dart workspace run: dart scripts/stage_min_sdk_workspace.dart "${{ runner.temp }}/min-dart" @@ -86,7 +84,7 @@ jobs: echo "::group::$package" (cd "$package" && dart analyze . --fatal-infos) if [ -d "$package/test" ]; then - (cd "$package" && dart test) + (cd "$package" && dart --enable-experiment=augmentations test) fi echo "::endgroup::" done @@ -123,7 +121,7 @@ jobs: set -euo pipefail flutter pub get flutter analyze --fatal-infos - flutter test + flutter test --enable-experiment=augmentations json-schema-draft7: name: JSON Schema Draft-7 batch validation @@ -193,6 +191,9 @@ jobs: - name: Resolve workspace dependencies run: dart pub get + - name: Activate the pinned Melos CLI + run: dart pub global activate melos 8.9.0 + # `.g.dart` holds the generated Ack models, schemas, and JSON mapping. # A clean rebuild must reproduce them identically. - name: Delete the committed generator output @@ -202,7 +203,7 @@ jobs: | xargs -0 --no-run-if-empty rm -- - name: Regenerate from a clean tree - run: dart run melos run build --no-select + run: dart pub global run melos run build --no-select - name: Require the regenerated output to match the repository run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f25aa6..7445e76c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Breaking & Substitutions +* Target Dart 3.14 / Flutter 3.50 using the available prerelease toolchain. + Class-first model augmentations replace generated mixins and handwritten + schema/parsing forwarders; primary-constructor examples need only fields and + annotations. See the SDK setup and migration guide for experiment flags. + * **Single `.g.dart` part & direct JSON emitter (`#189`)**: `ack_generator:ack_generator` now emits a single `.g.dart` part via `SharedPartBuilder` with direct `toJson()` / `fromJson()` helpers (no `json_serializable` or `json_annotation` @@ -83,6 +88,10 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Fixed +* Preserve child validation on inherited primary-constructor fields and support + model fields named `dynamic` or `override` in generated augmentations. +* Update the pinned API-audit tool to recognize primary constructors and public + names of private named field parameters correctly. * Explicit Draft-7 export rejects unsupported Draft 2020-12 assertions rather than emitting keywords that Draft-7 validators ignore. * Imported schema exports preserve JSON Pointer targets through retrieval URI diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05fec9e9..7d794fb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,13 +6,29 @@ review. By participating, you agree to follow the ## Development setup +The workspace uses Flutter `3.50.0-0.1.pre` / Dart `3.14.0-315.1.beta`. +Install/select the SDK pinned by `.fvmrc` before running commands (for example, +`fvm use` and `fvm dart ...`). Do not use a global older Dart SDK. +The analyzer configuration and Melos commands enable experimental augmentations. + +Melos requires a globally activated CLI matching the exact workspace version. +`./setup.sh` performs the setup below and installs optional Node tooling when +npm is available. To set up manually: + ```bash dart pub get -dart run melos bootstrap +dart pub global activate melos 8.9.0 +dart pub global run melos bootstrap ``` Ack uses a Melos workspace. Run commands from the repository root unless a -package README says otherwise. +package README says otherwise. Use `dart pub global run melos ...` so the +activated CLI does not need to be on `PATH`. + +Use `dart pub global run melos run format` to format authored sources. The beta +SDK's formatter does not preserve `augment` declarations, so this command +excludes generated `.g.dart` files. The generator formats those files itself; +CI checks them by regenerating from a clean tree. ## Before opening a PR @@ -23,14 +39,14 @@ package README says otherwise. 4. Run the relevant checks: ```bash -dart run melos run analyze -dart run melos run test +dart pub global run melos run analyze +dart pub global run melos run test ``` For code generation changes, also run: ```bash -dart run melos run test:gen +dart pub global run melos run test:gen ``` The generator suite is memory-heavy: nine tests tagged `integration` run @@ -38,7 +54,7 @@ real `build_runner` builds in temporary packages. For a quick check while iterating, skip them: ```bash -dart run melos run test:gen:unit +dart pub global run melos run test:gen:unit ``` Run the full `test:gen` before opening the PR. Run one generator suite at a @@ -50,7 +66,7 @@ Tag any new test that runs a real `build_runner` build with For JSON Schema export changes, also run: ```bash -dart run melos run validate-jsonschema +dart pub global run melos run validate-jsonschema ``` ## Commit style diff --git a/PUBLISHING.md b/PUBLISHING.md index 925b73e2..a80fd456 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -48,7 +48,9 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 1. Check pub.dev and tags before selecting the version; published versions and their changelog sections are immutable. -2. Start a release branch from current `origin/main` and resolve dependencies: +2. Complete [development setup](./CONTRIBUTING.md#development-setup), including + the pinned global Melos CLI. Start a release branch from current `origin/main` + and resolve dependencies: ```sh dart pub get @@ -57,13 +59,13 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 3. Preview/prepare a coordinated version without creating commits or tags: ```sh - dart run melos version --manual-version=ack:2.0.0-alpha.1 --yes --no-git-commit-version + dart pub global run melos version --manual-version=ack:2.0.0-alpha.1 --yes --no-git-commit-version ``` Use the named flag, not a positional package argument: the positional form selects a package instead of requesting a coordinated version. Choose the next SemVer value for later releases. Normal automatic selection is also available with - `dart run melos version --yes --no-git-commit-version` after reviewing commits. + `dart pub global run melos version --yes --no-git-commit-version` after reviewing commits. Do not pass `--all`: that would include private packages. Melos fetches tags through Git; a new local branch needs an upstream before this command runs. @@ -85,10 +87,10 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), ```sh dart scripts/verify_release_tag.dart v2.0.0-alpha.1 --skip-ancestry - dart run melos run ci + dart pub global run melos run ci dart scripts/api_check.dart 1.6.2 dart scripts/publish_dry_run.dart - dart run melos run validate-jsonschema:batch + dart pub global run melos run validate-jsonschema:batch ``` `--skip-ancestry` is only for preparing a version before the tag exists. diff --git a/README.md b/README.md index 8c152b00..c478842c 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ final userSchema = Ack.object({ Run the generator: ```bash -dart run build_runner build +dart run build_runner build --enable-experiment=augmentations ``` This emits a `User` class with stored typed fields, validation helpers, and a @@ -159,8 +159,12 @@ named recursion, and discriminated unions. One-way transforms are rejected because a generated model must be encodable. See the [Model Code Generation guide](docs/core-concepts/typesafe-schemas.mdx). +The 2.0 development branch uses Flutter 3.50.0-0.1.pre / Dart 3.14 beta. +Enable the `augmentations` experiment in consumer projects; see the +[SDK setup](docs/getting-started/installation.mdx#dart-314-prerelease-setup). + Already own the model class? Put `@Schemable()` on it to derive a codec schema from -constructor-backed fields (including Dart 3.12+ primary constructors) while +constructor-backed fields (including Dart 3.14 primary constructors) while keeping the class hand-written. A class named `Account` receives a private `_accountSchema` codec and a public `AccountSchema` facade for parsing, encoding, schema export, and nested composition: @@ -171,15 +175,9 @@ import 'package:ack/ack.dart'; part 'account.g.dart'; @Schemable() -final class Account with _$AccountAck { - const Account({required this.name}); - - @Validate.minLength(2) - final String name; - - static final schema = _accountSchema; - static final fromJson = AccountSchema.fromJson; -} +final class const Account({ + @Validate.minLength(2) required final String name, +}); ``` `Account.fromJson({'name': 'Ada'})` (or `Account.schema.parse(...)`) validates @@ -229,37 +227,45 @@ This project uses [Melos](https://github.com/invertase/melos) to manage the mono ### Setup +Select the SDK pinned by `.fvmrc` first; see +[development setup](./CONTRIBUTING.md#development-setup). Melos requires a +globally activated CLI matching the exact workspace version. `./setup.sh` +performs these steps and optionally installs the Node tooling: + ```bash -# Resolve the workspace-local Melos dependency +# Resolve workspace dependencies dart pub get +# Activate the pinned CLI (no global executable PATH entry is needed) +dart pub global activate melos 8.9.0 + # Bootstrap the workspace (installs dependencies for all packages) -dart run melos bootstrap +dart pub global run melos bootstrap ``` ### Common commands (run from root) ```bash # Run tests across all packages -dart run melos run test +dart pub global run melos run test # Format code across all packages -dart run melos run format +dart pub global run melos run format # Analyze code across all packages -dart run melos run analyze +dart pub global run melos run analyze # Check for outdated dependencies -dart run melos run deps-outdated +dart pub global run melos run deps-outdated # Run build_runner for packages that need it (e.g., ack_generator, example) -dart run melos run build +dart pub global run melos run build # Clean build artifacts -dart run melos run clean +dart pub global run melos run clean # Propose/apply version and changelog updates -dart run melos version +dart pub global run melos version # Dry-run pub.dev validation for every package, requiring zero warnings dart scripts/publish_dry_run.dart @@ -272,13 +278,13 @@ Publishing runs only from a `v*` tag through GitHub Actions. See ```bash # JSON Schema validation (JSON Schema Draft-7 compatibility) -dart run melos run validate-jsonschema +dart pub global run melos run validate-jsonschema # API compatibility check (for semantic versioning) -dart run melos run api-check -- 1.1.0 +dart pub global run melos run api-check -- 1.1.0 # See all available scripts -dart run melos run --list +dart pub global run melos run --list ``` Additional development documentation is available in the `tools/` directory. @@ -294,6 +300,6 @@ Contributions are welcome. Follow these steps: 1. Fork the repository 2. Create a feature branch 3. Add your changes -4. Run tests with `dart run melos run test` +4. Run tests with `dart pub global run melos run test` 5. Follow [Conventional Commits](https://www.conventionalcommits.org/) in your commit messages 6. Submit a pull request diff --git a/analysis_options.yaml b/analysis_options.yaml index c80f7ac5..36082fc5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,8 @@ include: package:lints/recommended.yaml analyzer: + enable-experiment: + - augmentations exclude: - "**/*.g.dart" - "**/*.mocks.dart" @@ -36,8 +38,6 @@ dart_code_metrics: prefer-prefixed-global-constants: false avoid-returning-widgets: false prefer-trailing-comma: false - # Enum dot-shorthands require Dart 3.10; the workspace SDK floor is 3.9. - prefer-shorthands-with-enums: false # Schema classes extend a non-const AckSchema base, so they cannot declare # const constructors. prefer-declaring-const-constructor: false diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 48ec9316..ae07090d 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -322,7 +322,7 @@ validation namespace. After adding a `part '.g.dart';` directive, run: ```bash -dart run build_runner build +dart run build_runner build --enable-experiment=augmentations ``` ### `@Schemable()` on a top-level schema @@ -383,16 +383,16 @@ constructors remain responsible for defensively copying direct inputs. **Target**: Public, constructable classes **Generates**: A public `Schema` facade backed by a private raw -`wireSchema` and typed codec (`_Schema`), plus the `_$ClassAck` mixin -for `toJson`, `safeToJson`, `copyWith`, and deep collection-aware `==` / +`wireSchema` and typed codec (`_Schema`), plus a class augmentation +for a `fromJson` factory, static `parse`, `safeParse`, `schema`, `toJson`, `safeToJson`, `copyWith`, and deep collection-aware `==` / `hashCode` / `toString` -Ack infers schema fields from constructor-backed fields (including Dart 3.12+ +Ack infers schema fields from constructor-backed fields (including Dart 3.14 primary constructors). Required parameters, nullable types, optional parameters, `JsonMaybe`, and constructor defaults determine field presence. Use `caseStyle` for model-wide JSON naming and `@AckKey(...)` on the field -or primary constructor parameter for a wire-name override. Instantiable models -and implicit union branches must apply the generated mixin. In `copyWith`, +or primary constructor parameter for a wire-name override. No mixins or static +forwarders are needed. In `copyWith`, omission keeps the current value and explicit `null` clears a nullable field. `copyWith` returns a typed interface, so a wrongly typed argument is a compile-time error. @@ -428,16 +428,10 @@ import 'package:ack/ack.dart'; part 'account.g.dart'; @Schemable(caseStyle: AckCaseStyle.snake) -final class Account with _$AccountAck { - const Account({required this.displayName, this.role = 'member'}); - - @Validate.minLength(2) - final String displayName; - final String role; - - static final schema = _accountSchema; - static final fromJson = AccountSchema.fromJson; -} +final class const Account({ + @Validate.minLength(2) required final String displayName, + final String role = 'member', +}); final account = Account.schema.parse({'display_name': 'Ada'})!; print(account.toJson()); @@ -446,20 +440,21 @@ print(Account.schema.toJsonSchema()); The facade exposes `schema`, `wireSchema`, `parse`, `safeParse`, `fromJson`, `encode`, `safeEncode`, `toJsonSchema`, and `toSchemaModel`. The backing -`_accountSchema` is library-private (`static final schema = _accountSchema;` -attaches it directly to `Account.schema`) and no public lower-camel alias is +`_accountSchema` is library-private (`Account.schema` exposes it automatically) +and no public lower-camel alias is generated. Set `schemaName: 'WireAccountSchema'` to choose the exact facade class name. -Nested class-first fields compose through `AddressSchema.schema` (or -`Address.schema` when declared on `Address`). Imports with combinators can -expose just `show Address` when `Address.schema` is declared, or -`show Address, AddressSchema` otherwise. A schema-first `@Schemable()` -declaration may also compose the facade explicitly, and class-first models can -use schema-first generated model types on a clean build. +Nested class-first fields compose through generated `Address.schema`. Imports +and barrels may expose just `Address`. Explicit `AddressSchema.schema` +references also require the facade to be visible. A schema-first `@Schemable()` +declaration may compose either API, and class-first models can use schema-first +generated model types on a clean build. Ack owns generated member names; +remove handwritten forwarders and rename colliding members or import prefixes. A class-first `@Schemable()` cannot share a class with `@JsonSerializable()`, -and `name` is rejected on a class. Class-first value roots, automatic recursive +and `name` is rejected on a class. Generic annotated classes and generic union +branches are unsupported. Class-first value roots, automatic recursive graphs, and undiscriminated `anyOf` shapes are unsupported; use a schema-first `@Schemable()` schema and named `Ack.lazy` for those cases. See diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 1aa65379..661bb9b5 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -8,7 +8,7 @@ from a Dart class, or start from a `.schema.json` document. | Starting point | Trigger | Generated output | | --- | --- | --- | | A top-level Ack schema (`final userSchema = ...`) | `@Schemable()` | Immutable `User` class, `User.schema`, `parse`/`safeParse`, `fromJson`/`toJson`, `copyWith` in `.g.dart` | -| A Dart class or sealed union (`final class Account`) | `@Schemable()` | `_accountSchema` codec, `AccountSchema` facade, and `_$AccountAck` mixin in `.g.dart` | +| A Dart class or sealed union (`final class Account`) | `@Schemable()` | `_accountSchema` codec, `AccountSchema` facade, and class augmentation in `.g.dart` | | A JSON Schema asset (`lib/**/*.schema.json`) | `ack_generator:ack_json_schema` in `build.yaml` | `.schema.dart` + `.schema.g.dart` with typed `@Schemable()` models or validated value models | `@Schemable()`, `@AckKey`, and the `@Validate.*` constraint and presence @@ -20,6 +20,9 @@ runtime type returned by factories such as `Ack.string()` and `Ack.object()`. ## Install the generator +Use the [Dart 3.14 prerelease setup](../getting-started/installation.mdx#dart-314-prerelease-setup), +including the augmentation experiment flag. + ```bash dart pub add ack dart pub add --dev ack_generator build_runner @@ -34,7 +37,7 @@ part 'models.g.dart'; Run the generator after adding or changing a model: ```bash -dart run build_runner build +dart run build_runner build --enable-experiment=augmentations ``` ## A working example of both directions @@ -56,28 +59,13 @@ final orderSchema = Ack.object({ // Class-first: write Account; Ack generates _accountSchema and AccountSchema. @Schemable(caseStyle: AckCaseStyle.snake) -final class Account with _$AccountAck { - const Account({ - required this.displayName, - required this.email, - required this.middleName, - this.website, - this.role = 'member', - }); - - @Validate.minLength(2) - final String displayName; - - @Validate.email() - final String email; - - final String? middleName; - final Uri? website; - final String role; - - static final schema = _accountSchema; - static final fromJson = AccountSchema.fromJson; -} +final class const Account({ + @Validate.minLength(2) required final String displayName, + @Validate.email() required final String email, + required final String? middleName, + final Uri? website, + final String role = 'member', +}); ``` After generation, both directions have a typed parsing and JSON boundary: @@ -173,7 +161,7 @@ This generates a sealed `Pet` base plus `Cat` and `Dog` branches. ## Class-first: `@Schemable()` on a class Use class-first generation when the Dart class is the primary artifact. Ack -reads constructor-backed fields (including Dart 3.12+ primary constructors) and +reads constructor-backed fields (including primary constructors) and generates a codec schema whose runtime value is your class: ```dart @@ -182,10 +170,7 @@ final class Profile({ @Validate.minLength(2) required final String displayName, @Validate.email() required final String email, final String role = 'member', -}) with _$ProfileAck { - static final schema = _profileSchema; - static final fromJson = ProfileSchema.fromJson; -} +}); ``` Ack generates a library-private `_profileSchema` codec variable and a public @@ -201,8 +186,7 @@ Ack generates a library-private `_profileSchema` codec variable and a public Every instantiable `@Schemable` class and implicit sealed-union branch must be declared `final class`, initialize its fields through an unnamed generative -or primary constructor, contain only final stored fields, and apply its -generated `_$ClassAck` mixin. The mixin supplies `toJson()`, `safeToJson()`, a +or primary constructor, and contain only final stored fields. An augmentation supplies `toJson()`, `safeToJson()`, a typed `copyWith` where omission keeps the current value, explicit `null` clears a nullable field, and a wrongly typed argument is a compile-time error, and deep collection-aware `==`, `hashCode`, and @@ -214,36 +198,17 @@ rewrite a hand-written constructor, so direct construction and collection replacements passed to `copyWith` must defensively copy mutable inputs in the source class when that guarantee is required. -Generated `toJson()` and `safeToJson()` methods delegate through that same -facade. Ack cannot inject static members or constructors into a hand-written -class, so the recommended conventions are one-line static tear-offs/fields: - -```dart -static final schema = _profileSchema; -static final fromJson = ProfileSchema.fromJson; -``` - -`static final schema = _profileSchema;` (or `ProfileSchema.schema`) attaches the -generated codec directly to `Profile.schema` so callers can write -`Profile.schema.parse(...)`, `Profile.schema.toJsonSchema()`, or -`Ack.list(Profile.schema)`, and other libraries only need to import `Profile`. -Both lines are optional: if omitted, `ProfileSchema` still provides every static -entry point and nested `@Schemable()` models still compose automatically. -Generation rejects a `schema` static whose type produces another class. - -If a framework specifically requires a factory constructor for `fromJson`, use: +Augmentations add `Profile.fromJson` as a factory constructor, static `parse` +and `safeParse`, and a static `schema` getter. No mixin, handwritten factory, +or static forwarder is needed. The `ProfileSchema` facade remains available +for encoding and schema export. All entry points use the same Ack codec. -```dart -factory Profile.fromJson(Map json) => - ProfileSchema.fromJson(json); -``` - -An explicit function-field type is also valid but normally unnecessary: - -```dart -static final Profile Function(Map) fromJson = - ProfileSchema.fromJson; -``` +Ack owns these generated member names. Remove handwritten schema/parsing +forwarders; colliding members or import prefixes fail with a source-located +diagnostic. For a JSON property named `schema`, use another Dart field name +with `@AckKey('schema')`. Generic `@Schemable` classes and generic union +branches are unsupported; use a concrete model or an external domain type +with a type-owned schema. Use `@Schemable(description: ...)` and a single-line `@description` tag in a `///` or `/** */` doc comment on classes, fields, or constructor parameters to @@ -260,7 +225,7 @@ It does not change a nested model's own description. ```dart /// Tracks one unit of work. This prose is not exported. @Schemable(description: 'A task the person can complete.') -final class Task with _$TaskAck { +final class Task { const Task({required this.id, required this.title}); final String id; @@ -306,7 +271,7 @@ the annotation that changes null acceptance: ```dart @Schemable() -final class Example with _$ExampleAck { +final class Example { const Example({this.label}); @Validate.optional() @@ -336,15 +301,13 @@ Use `JsonMaybe` when a `PATCH` request needs to distinguish an omitted field ```dart @Schemable() -final class ProfilePatch with _$ProfilePatchAck { +final class ProfilePatch { const ProfilePatch({ this.bio = const JsonMaybe.absent(), }); final JsonMaybe bio; - static final schema = _profilePatchSchema; - static final fromJson = ProfilePatchSchema.fromJson; } // Omitted key -> JsonAbsent(); toJson() returns {} @@ -394,7 +357,7 @@ its schema in the following priority order: `AckSchema` variable, getter, or zero-argument function in the current library. -A type you own can declare its schema once, as a static member named +A domain type without `@Schemable` can declare its schema once, as a static member named `schema`. Any field of that type then resolves to it with no annotation on the field, also as a `List` or `Set` item and as a `Map` value: @@ -410,7 +373,7 @@ final class Slot { } @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.header, this.footer, required this.children}); final Slot header; // Slot.schema @@ -512,7 +475,7 @@ AckSchema colorSchema() => Ack.string().codec( ); @Schemable() -final class Theme with _$ThemeAck { +final class Theme { const Theme({required this.primary}); final Color primary; @@ -532,18 +495,18 @@ super parameters: ```dart @Schemable(discriminatorKey: 'type') -sealed class Pet with _$PetAck { +sealed class Pet { const Pet({required this.id}); final String id; } @Schemable(discriminatorValue: 'cat') -final class Cat extends Pet with _$CatAck { +final class Cat extends Pet { const Cat({required super.id, required this.lives}); final int lives; } -final class Dog extends Pet with _$DogAck { +final class Dog extends Pet { const Dog({required super.id, required this.breed}); final String breed; } @@ -581,19 +544,19 @@ inputs without validating them. ### Reusing generated schemas -Nested class-first fields compose through the target facade (or `Address.schema` -when declared on the class) automatically: +Nested class-first fields compose through the generated `Address.schema` +automatically: ```dart import 'address.dart' as address; @Schemable() -final class Order with _$OrderAck { +final class Order { const Order({required this.shipping}); final address.Address shipping; } -// Generated field schema: address.AddressSchema.schema +// Generated field schema: address.Address.schema ``` Schema-first declarations can use `Address.schema` or the facade explicitly: @@ -617,16 +580,15 @@ visible in the annotated library itself. A type reached only through a `typedef` from another library, or a codec's runtime type from a library you did not import, fails generation with the library to import. -When importing with `show` / `hide` combinators, if a class-first model declares -`static final schema = _addressSchema;`, importing libraries only need to expose -`Address`: +When importing with `show` / `hide` combinators or through a barrel, a +class-first model only needs to expose `Address`: ```dart import 'address.dart' show Address; ``` -If `Address` does not declare a static `schema`, expose both the hand-written -model and its generated facade: +Only explicit facade references such as `AddressSchema.schema` also require +the facade to be visible: ```dart import 'address.dart' show Address, AddressSchema; @@ -669,7 +631,7 @@ targets: 2. Add `ack_generator` and `build_runner` to dev dependencies. 3. Declare `part '.g.dart';` in each annotated library. 4. Put `@Schemable()` on a top-level schema or on a class. -5. Run `dart run build_runner build`. +5. Run `dart run build_runner build --enable-experiment=augmentations`. ## Migrating from 1.x diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 102a32ce..8b7757a0 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -24,6 +24,38 @@ dependencies: ack: ^2.0.0-alpha.1 ``` +## Dart 3.14 prerelease setup + +This Ack 2.0 development branch is pinned to **Flutter 3.50.0-0.1.pre**, +which bundles **Dart 3.14.0-315.1.beta**. The stable 3.50.0 / 3.14.0 releases +are not available yet. Use `sdk: '>=3.14.0-315.1.beta <4.0.0'` for this +prerelease; the stable target is `sdk: '>=3.14.0 <4.0.0'` once released. +Primary constructors are enabled by default. Augmentations remain experimental +and must be enabled in consumer projects: + +```yaml title="analysis_options.yaml" +analyzer: + enable-experiment: + - augmentations +``` + +```bash +dart run build_runner build --enable-experiment=augmentations +dart --enable-experiment=augmentations test +# For a Dart application: +dart --enable-experiment=augmentations run bin/main.dart +# For a Flutter application: +flutter run --enable-experiment=augmentations +flutter test --enable-experiment=augmentations +``` + +`@Schemable()` classes use ordinary `part '.g.dart';` directives. +Generated `augment final class` / `augment sealed class` declarations add +members to the source class; they do not change its primary constructor. +The pinned beta formatter cannot preserve these declarations. Do not run +`dart format` over generated `.g.dart` parts; Ack formats its generated output. +In this repository, `dart pub global run melos run format` selects authored sources only. + ## Optional code generator To generate models from Ack schemas or Ack schemas from hand-written models, @@ -42,7 +74,7 @@ dependencies: dev_dependencies: ack_generator: ^2.0.0-alpha.1 - build_runner: ^2.4.0 + build_runner: ^2.16.2 ``` The generator works in both directions with one annotation, `@Schemable()`, @@ -65,21 +97,15 @@ final userSchema = Ack.object({ }); @Schemable() -final class Account with _$AccountAck { - const Account({required this.name}); - - @Validate.minLength(2) - final String name; - - static final schema = _accountSchema; - static final fromJson = AccountSchema.fromJson; -} +final class const Account({ + @Validate.minLength(2) required final String name, +}); ``` Run the generator: ```bash -dart run build_runner build +dart run build_runner build --enable-experiment=augmentations ``` See [Model Code Generation](../core-concepts/typesafe-schemas.mdx) for working @@ -91,4 +117,4 @@ Import `package:ack/ack.dart` and you're ready — the [Quickstart Tutorial](./q ## Requirements -- Dart SDK: `>=3.9.0 <4.0.0` for `ack` and `ack_generator` +- Dart SDK: `>=3.14.0-315.1.beta <4.0.0` for `ack` and `ack_generator` diff --git a/docs/getting-started/migration-guide.mdx b/docs/getting-started/migration-guide.mdx index f0c092f2..9e23fa41 100644 --- a/docs/getting-started/migration-guide.mdx +++ b/docs/getting-started/migration-guide.mdx @@ -13,7 +13,7 @@ Ack is organized into five coordinated packages that share one version number an | Package | Role | Key Capabilities | | --- | --- | --- | | **`ack`** | Core runtime & annotations | • Fluent schema builders: `Ack.string()`, `Ack.integer()`, `Ack.double()`, `Ack.number()`, `Ack.boolean()`, `Ack.list()`, `Ack.map()`, `Ack.object()`, `Ack.any()`, `Ack.literal()`, `Ack.enumString()`, `Ack.enumValues()`, `Ack.anyOf()`, `Ack.discriminated()`, `Ack.lazy()`
• Bidirectional `CodecSchema`: `Ack.date()`, `Ack.datetime()`, `Ack.uri()`, `Ack.duration()`, `.codec()`, `.transform()`
• Two-way string normalizers: `.trim()`, `.toLowerCase()`, `.toUpperCase()`
• `JsonMaybe` (`JsonAbsent` / `JsonValue`) for tri-state `PATCH` payloads
• Full JSON Schema Draft 2020-12 runtime (`Ack.fromJsonSchema()`) and exporter (`toJsonSchema()`, `toSchemaModel()`, `toJsonSchemaDraft7()`)
• Sealed `SchemaResult` (`Ok` / `Fail`) and sealed `SchemaError` hierarchy
• Unified codegen annotations in `package:ack/ack.dart`: `@Schemable()`, `@AckKey()`, and `@Validate.*` | -| **`ack_generator`** | Two-way code generator | • **Schema-first (`@Schemable()` on a top-level schema)**: Generates an immutable model class, `Model.schema`, `parse`/`safeParse`, `fromJson`/`toJson`/`safeToJson`, typed `copyWith`, and deep `==`/`hashCode` in `.g.dart`
• **Class-first (`@Schemable()` on a `final class` or `sealed class`)**: Supports standard and Dart 3.12+ primary constructors; generates `_Schema`, `Schema`, and `_$Ack` in `.g.dart`
• **`.schema.json` codegen (`ack_generator:ack_json_schema`)**: Compiles `lib/**/*.schema.json` assets into `.schema.dart` + `.schema.g.dart` | +| **`ack_generator`** | Two-way code generator | • **Schema-first (`@Schemable()` on a top-level schema)**: Generates an immutable model class, `Model.schema`, `parse`/`safeParse`, `fromJson`/`toJson`/`safeToJson`, typed `copyWith`, and deep `==`/`hashCode` in `.g.dart`
• **Class-first (`@Schemable()` on a `final class` or `sealed class`)**: Supports standard and Dart 3.14 primary constructors; generates `_Schema`, `Schema`, and class augmentations in `.g.dart`
• **`.schema.json` codegen (`ack_generator:ack_json_schema`)**: Compiles `lib/**/*.schema.json` assets into `.schema.dart` + `.schema.g.dart` | | **`ack_mcp_dart`** | MCP tool adapter | • `server.registerAckTool()`, `registerAckInputTool()`, `registerAckOutputTool()`, and `result.decodeAckOutput()` for `mcp_dart` | | **`ack_firebase_ai`** | Firebase AI (Gemini) adapter | • `schema.toFirebaseAiSchema()` for Gemini structured output and tool declarations | | **`ack_json_schema_builder`** | `json_schema_builder` bridge | • Bidirectional `schema.toJsonSchemaBuilder()` and `jsbSchema.toAckSchema()` | @@ -100,6 +100,32 @@ All top-level validation and presence classes were removed in `2.0.0`. Replace t --- +## Dart 3.14: remove model glue + +Use the [prerelease SDK setup](./installation.mdx#dart-314-prerelease-setup). +Remove `with _$ModelAck` and handwritten `schema`, `fromJson`, `parse`, and +`safeParse` forwarders from every class-first model and union branch. +Regenerate the single `.g.dart` part. Ack now owns these members and adds a +`fromJson` factory, static parsing methods, and `Model.schema` directly through +an augmentation. Colliding members or import prefixes must be renamed. +Generic annotated classes and generic union branches are unsupported; use +concrete models or external domain types with type-owned schemas. + +Primary constructors are optional; existing generative constructors still work. +For concise immutable models, use declaring parameters: + +```dart +@Schemable() +final class const Account({ + @Validate.minLength(2) required final String name, + final String? nickname, +}); +``` + +Parsing and encoding still cross the Ack validation boundary. Direct +construction is unchecked; `toJson` validates on encoding. Primary constructors +do not automatically freeze mutable collections passed by callers. + ## 4. Before & After Migration Examples ### Class-First Model Migration @@ -135,20 +161,13 @@ import 'package:ack/ack.dart'; part 'account.g.dart'; @Schemable(caseStyle: AckCaseStyle.snake) -final class Account with _$AccountAck { - const Account({required this.userName, required this.createdAt}); - +final class const Account({ @AckKey('username') @Validate.minLength(3) @Validate.matches(r'^[a-z0-9_]+$') - final String userName; - - @Validate.dateTime() - final String createdAt; - - static final schema = _accountSchema; - static final fromJson = AccountSchema.fromJson; -} + required final String userName, + @Validate.dateTime() required final String createdAt, +}); ``` ### Schema-First Model Migration diff --git a/docs/guides/creating-schema-converter-packages.mdx b/docs/guides/creating-schema-converter-packages.mdx index 26b67119..86ec7ab9 100644 --- a/docs/guides/creating-schema-converter-packages.mdx +++ b/docs/guides/creating-schema-converter-packages.mdx @@ -141,9 +141,9 @@ repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' # Add flutter if target SDK requires it - # flutter: '>=3.35.0' + # flutter: '>=3.50.0-0.1.pre' dependencies: ack: ^2.0.0-alpha.1 diff --git a/docs/guides/ecosystem-integrations.mdx b/docs/guides/ecosystem-integrations.mdx index eb14b660..a57101c8 100644 --- a/docs/guides/ecosystem-integrations.mdx +++ b/docs/guides/ecosystem-integrations.mdx @@ -20,7 +20,7 @@ import 'package:ack/ack.dart'; part 'weather_models.g.dart'; @Schemable(description: 'Parameters for querying current weather.') -final class WeatherQuery with _$WeatherQueryAck { +final class WeatherQuery { const WeatherQuery({required this.city, this.units = 'celsius'}); /// @description City and country, e.g. "Tokyo, JP". @@ -29,19 +29,15 @@ final class WeatherQuery with _$WeatherQueryAck { final String units; - static final schema = _weatherQuerySchema; - static final fromJson = WeatherQuerySchema.fromJson; } @Schemable(description: 'Current weather report.') -final class WeatherReport with _$WeatherReportAck { +final class WeatherReport { const WeatherReport({required this.city, required this.temperatureC}); final String city; final double temperatureC; - static final schema = _weatherReportSchema; - static final fromJson = WeatherReportSchema.fromJson; } ``` diff --git a/docs/guides/json-schema-integration.mdx b/docs/guides/json-schema-integration.mdx index d6503680..c26a9971 100644 --- a/docs/guides/json-schema-integration.mdx +++ b/docs/guides/json-schema-integration.mdx @@ -184,7 +184,7 @@ targets: `ack_generator:ack_json_schema` emits `.schema.dart` (declaring `part '.schema.g.dart';`), which `ack_generator:ack_generator` compiles -in the same `dart run build_runner build` pass: +in the same `dart run build_runner build --enable-experiment=augmentations` pass: ```dart import 'person.schema.dart'; diff --git a/docs/guides/schema-converter-quickstart.mdx b/docs/guides/schema-converter-quickstart.mdx index cc8bbddd..cb7245fb 100644 --- a/docs/guides/schema-converter-quickstart.mdx +++ b/docs/guides/schema-converter-quickstart.mdx @@ -37,8 +37,8 @@ version: 1.0.0-beta.1 repository: https://github.com/btwld/ack environment: - sdk: '>=3.9.0 <4.0.0' - # flutter: '>=3.35.0' # Uncomment if needed + sdk: '>=3.14.0-315.1.beta <4.0.0' + # flutter: '>=3.50.0-0.1.pre' # Uncomment if needed dependencies: ack: ^2.0.0-alpha.1 diff --git a/example/README.md b/example/README.md index 2673dcb5..67d509ef 100644 --- a/example/README.md +++ b/example/README.md @@ -23,8 +23,8 @@ codec schemas. Annotated examples declare a single `.g.dart` part. [`lib/codecs_example.dart`](lib/codecs_example.dart) - Hand-written class-first models and sealed unions in [`lib/class_first_models.dart`](lib/class_first_models.dart), including the - required `_$ClassAck` mixin, `AccountSchema.schema` / `wireSchema`, and union - facades backed by private codecs + primary constructors, generated class augmentations, `Account.schema`, and + schema facades with `wireSchema` for JSON Schema export ## Try both generation directions @@ -45,9 +45,12 @@ void main() { ## Running the examples +Complete the repository's [development setup](../CONTRIBUTING.md#development-setup) +first, including the pinned SDK and global Melos CLI. From the repository root: + ```bash -dart run melos bootstrap +dart pub global run melos bootstrap cd example -dart run build_runner build -dart test +dart run build_runner build --enable-experiment=augmentations +dart --enable-experiment=augmentations test ``` diff --git a/example/lib/additional_properties_example.g.dart b/example/lib/additional_properties_example.g.dart index b099903a..3006f817 100644 --- a/example/lib/additional_properties_example.g.dart +++ b/example/lib/additional_properties_example.g.dart @@ -74,10 +74,10 @@ final class UserConfig { bool operator ==(Object other) => identical(this, other) || (other is UserConfig && - runtimeType == other.runtimeType && - deepEquals(username, other.username) && - deepEquals(email, other.email) && - deepEquals(additionalProperties, other.additionalProperties)); + this.runtimeType == other.runtimeType && + deepEquals(this.username, other.username) && + deepEquals(this.email, other.email) && + deepEquals(this.additionalProperties, other.additionalProperties)); @override int get hashCode => Object.hashAll([ @@ -208,10 +208,10 @@ final class ApiRequest { bool operator ==(Object other) => identical(this, other) || (other is ApiRequest && - runtimeType == other.runtimeType && - deepEquals(method, other.method) && - deepEquals(url, other.url) && - deepEquals(additionalProperties, other.additionalProperties)); + this.runtimeType == other.runtimeType && + deepEquals(this.method, other.method) && + deepEquals(this.url, other.url) && + deepEquals(this.additionalProperties, other.additionalProperties)); @override int get hashCode => Object.hashAll([ @@ -343,10 +343,10 @@ final class FeatureFlags { bool operator ==(Object other) => identical(this, other) || (other is FeatureFlags && - runtimeType == other.runtimeType && - deepEquals(appVersion, other.appVersion) && - deepEquals(environment, other.environment) && - deepEquals(additionalProperties, other.additionalProperties)); + this.runtimeType == other.runtimeType && + deepEquals(this.appVersion, other.appVersion) && + deepEquals(this.environment, other.environment) && + deepEquals(this.additionalProperties, other.additionalProperties)); @override int get hashCode => Object.hashAll([ @@ -471,8 +471,8 @@ final class DynamicData { bool operator ==(Object other) => identical(this, other) || (other is DynamicData && - runtimeType == other.runtimeType && - deepEquals(additionalProperties, other.additionalProperties)); + this.runtimeType == other.runtimeType && + deepEquals(this.additionalProperties, other.additionalProperties)); @override int get hashCode => diff --git a/example/lib/class_first_models.dart b/example/lib/class_first_models.dart index ce014f05..6629e752 100644 --- a/example/lib/class_first_models.dart +++ b/example/lib/class_first_models.dart @@ -7,46 +7,26 @@ part 'class_first_models.g.dart'; caseStyle: AckCaseStyle.snake, description: "A signed-in user's account.", ) -final class Account with _$AccountAck { - const Account({ - required this.displayName, - this.website, - this.role = 'member', - }); - +final class const Account({ /// The name shown to other users. /// @description The name shown to other users. - @Validate.minLength(2) - final String displayName; - final Uri? website; - final String role; - - static final schema = _accountSchema; - static final fromJson = AccountSchema.fromJson; -} + @Validate.minLength(2) required final String displayName, + final Uri? website, + final String role = 'member', +}); /// A pet, identified by its `type`. /// @description A pet, identified by its `type`. @Schemable(discriminatorKey: 'type') -sealed class Pet with _$PetAck { - const Pet({required this.id}); - - final String id; -} +sealed class const Pet({required final String id}); /// A cat with a limited number of lives. /// @description A cat with a limited number of lives. @Schemable(discriminatorValue: 'cat') -final class Cat extends Pet with _$CatAck { - const Cat({required super.id, required this.lives}); - - @Validate.min(1) - @Validate.max(9) - final int lives; -} - -final class Dog extends Pet with _$DogAck { - const Dog({required super.id, required this.breed}); +final class const Cat({ + required super.id, + @Validate.min(1) @Validate.max(9) required final int lives, +}) extends Pet; - final String breed; -} +final class const Dog({required super.id, required final String breed}) + extends Pet; diff --git a/example/lib/class_first_models.g.dart b/example/lib/class_first_models.g.dart index a9bbdbda..8cea2ed1 100644 --- a/example/lib/class_first_models.g.dart +++ b/example/lib/class_first_models.g.dart @@ -9,6 +9,19 @@ part of 'class_first_models.dart'; // ignore_for_file: type=lint // coverage:ignore-file +// Private aliases preserve public underlying types while avoiding model member +// shadowing. Their visibility is intentional; no private runtime type escapes. +// ignore_for_file: library_private_types_in_public_api + +typedef _AckObject = Object; +typedef _AckDynamic = dynamic; +typedef _AckString = String; +typedef _AckMap = Map; +typedef _AckBool = bool; +typedef _AckInt = int; +typedef _AckSchema = AckSchema; +typedef _AckSchemaResult = SchemaResult; + final _catObject = Ack.object({ 'type': Ack.literal('cat').optional(), 'id': Ack.string(), @@ -75,6 +88,22 @@ Map _$CatToRuntime(Cat model) => { 'type': 'cat', }; +typedef _$CatSchemaApi = CatSchema; +typedef _$CatCopyWithApi<$Result> = $CatCopyWith<$Result>; + +bool _$CatEquals(Cat self, Object other) => + identical(self, other) || + (other is Cat && + self.runtimeType == other.runtimeType && + deepEquals(self.id, other.id) && + deepEquals(self.lives, other.lives)); + +int _$CatHashCode(Cat self) => Object.hashAll([ + self.runtimeType, + deepHashCode(self.id), + deepHashCode(self.lives), +]); + /// Creates copies of [Cat] with selected fields replaced. abstract interface class $CatCopyWith<$Result> { /// Returns a copy with the given fields replaced. @@ -94,46 +123,40 @@ final class _$CatCopyWith implements $CatCopyWith { Cat(id: id ?? _source.id, lives: lives ?? _source.lives); } -mixin _$CatAck { - /// Creates a copy of this model with selected fields replaced. - $CatCopyWith get copyWith => _$CatCopyWith(this as Cat); +augment final class Cat { + /// Creates a copy with selected fields replaced. + _$CatCopyWithApi get copyWith => _$CatCopyWith(this); - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! Cat || runtimeType != other.runtimeType) { - return false; - } - final self = this as Cat; - return deepEquals(self.id, other.id) && deepEquals(self.lives, other.lives); - } + _AckBool operator ==(_AckObject other) => _$CatEquals(this, other); - @override - int get hashCode { - final self = this as Cat; - return Object.hashAll([ - runtimeType, - deepHashCode(self.id), - deepHashCode(self.lives), - ]); - } + _AckInt get hashCode => _$CatHashCode(this); - @override - String toString() { - final self = this as Cat; - return 'Cat(id: ${self.id}, lives: ${self.lives})'; - } + _AckString toString() => 'Cat(id: $id, lives: $lives)'; + + static _AckSchema<_AckMap<_AckString, _AckObject?>, Cat> get schema => + _$CatSchemaApi.schema; + + /// Validates decoded JSON and creates a model. + factory Cat.fromJson(_AckMap<_AckString, _AckDynamic> json) => + _$CatSchemaApi.fromJson(json); + + /// Validates an untrusted value and creates a model. + static Cat parse(_AckObject? value, {_AckString? debugName}) => + _$CatSchemaApi.parse(value, debugName: debugName); + + /// Validates an untrusted value without throwing on validation failure. + static _AckSchemaResult safeParse( + _AckObject? value, { + _AckString? debugName, + }) => _$CatSchemaApi.safeParse(value, debugName: debugName); /// Validates this model and encodes it for JSON. - /// - /// Throws an `AckException` when validation fails. - Map toJson() => - Map.from(CatSchema.encode(this as Cat)); + _AckMap<_AckString, _AckDynamic> toJson() => + _AckMap<_AckString, _AckDynamic>.from(_$CatSchemaApi.encode(this)); - /// Validates this model and encodes it for JSON, returning the validation - /// failure instead of throwing. - SchemaResult> safeToJson() => - CatSchema.safeEncode(this as Cat); + /// Validates this model and encodes it without throwing on validation failure. + _AckSchemaResult<_AckMap<_AckString, _AckObject?>> safeToJson() => + _$CatSchemaApi.safeEncode(this); } final _dogObject = Ack.object({ @@ -203,6 +226,22 @@ Map _$DogToRuntime(Dog model) => { 'type': 'Dog', }; +typedef _$DogSchemaApi = DogSchema; +typedef _$DogCopyWithApi<$Result> = $DogCopyWith<$Result>; + +bool _$DogEquals(Dog self, Object other) => + identical(self, other) || + (other is Dog && + self.runtimeType == other.runtimeType && + deepEquals(self.id, other.id) && + deepEquals(self.breed, other.breed)); + +int _$DogHashCode(Dog self) => Object.hashAll([ + self.runtimeType, + deepHashCode(self.id), + deepHashCode(self.breed), +]); + /// Creates copies of [Dog] with selected fields replaced. abstract interface class $DogCopyWith<$Result> { /// Returns a copy with the given fields replaced. @@ -222,46 +261,40 @@ final class _$DogCopyWith implements $DogCopyWith { Dog(id: id ?? _source.id, breed: breed ?? _source.breed); } -mixin _$DogAck { - /// Creates a copy of this model with selected fields replaced. - $DogCopyWith get copyWith => _$DogCopyWith(this as Dog); +augment final class Dog { + /// Creates a copy with selected fields replaced. + _$DogCopyWithApi get copyWith => _$DogCopyWith(this); - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! Dog || runtimeType != other.runtimeType) { - return false; - } - final self = this as Dog; - return deepEquals(self.id, other.id) && deepEquals(self.breed, other.breed); - } + _AckBool operator ==(_AckObject other) => _$DogEquals(this, other); - @override - int get hashCode { - final self = this as Dog; - return Object.hashAll([ - runtimeType, - deepHashCode(self.id), - deepHashCode(self.breed), - ]); - } + _AckInt get hashCode => _$DogHashCode(this); - @override - String toString() { - final self = this as Dog; - return 'Dog(id: ${self.id}, breed: ${self.breed})'; - } + _AckString toString() => 'Dog(id: $id, breed: $breed)'; + + static _AckSchema<_AckMap<_AckString, _AckObject?>, Dog> get schema => + _$DogSchemaApi.schema; + + /// Validates decoded JSON and creates a model. + factory Dog.fromJson(_AckMap<_AckString, _AckDynamic> json) => + _$DogSchemaApi.fromJson(json); + + /// Validates an untrusted value and creates a model. + static Dog parse(_AckObject? value, {_AckString? debugName}) => + _$DogSchemaApi.parse(value, debugName: debugName); + + /// Validates an untrusted value without throwing on validation failure. + static _AckSchemaResult safeParse( + _AckObject? value, { + _AckString? debugName, + }) => _$DogSchemaApi.safeParse(value, debugName: debugName); /// Validates this model and encodes it for JSON. - /// - /// Throws an `AckException` when validation fails. - Map toJson() => - Map.from(DogSchema.encode(this as Dog)); + _AckMap<_AckString, _AckDynamic> toJson() => + _AckMap<_AckString, _AckDynamic>.from(_$DogSchemaApi.encode(this)); - /// Validates this model and encodes it for JSON, returning the validation - /// failure instead of throwing. - SchemaResult> safeToJson() => - DogSchema.safeEncode(this as Dog); + /// Validates this model and encodes it without throwing on validation failure. + _AckSchemaResult<_AckMap<_AckString, _AckObject?>> safeToJson() => + _$DogSchemaApi.safeEncode(this); } final _petObject = Ack.discriminated( @@ -330,17 +363,33 @@ abstract final class PetSchema { AckSchemaModelExtension(_petSchema).toSchemaModel(); } -mixin _$PetAck { +typedef _$PetSchemaApi = PetSchema; + +augment sealed class Pet { + static _AckSchema<_AckMap<_AckString, _AckObject?>, Pet> get schema => + _$PetSchemaApi.schema; + + /// Validates decoded JSON and creates a model. + factory Pet.fromJson(_AckMap<_AckString, _AckDynamic> json) => + _$PetSchemaApi.fromJson(json); + + /// Validates an untrusted value and creates a model. + static Pet parse(_AckObject? value, {_AckString? debugName}) => + _$PetSchemaApi.parse(value, debugName: debugName); + + /// Validates an untrusted value without throwing on validation failure. + static _AckSchemaResult safeParse( + _AckObject? value, { + _AckString? debugName, + }) => _$PetSchemaApi.safeParse(value, debugName: debugName); + /// Validates this model and encodes it for JSON. - /// - /// Throws an `AckException` when validation fails. - Map toJson() => - Map.from(PetSchema.encode(this as Pet)); + _AckMap<_AckString, _AckDynamic> toJson() => + _AckMap<_AckString, _AckDynamic>.from(_$PetSchemaApi.encode(this)); - /// Validates this model and encodes it for JSON, returning the validation - /// failure instead of throwing. - SchemaResult> safeToJson() => - PetSchema.safeEncode(this as Pet); + /// Validates this model and encodes it without throwing on validation failure. + _AckSchemaResult<_AckMap<_AckString, _AckObject?>> safeToJson() => + _$PetSchemaApi.safeEncode(this); } final _accountObject = Ack.object({ @@ -414,6 +463,24 @@ Map _$AccountToRuntime(Account model) => { 'role': model.role, }; +typedef _$AccountSchemaApi = AccountSchema; +typedef _$AccountCopyWithApi<$Result> = $AccountCopyWith<$Result>; + +bool _$AccountEquals(Account self, Object other) => + identical(self, other) || + (other is Account && + self.runtimeType == other.runtimeType && + deepEquals(self.displayName, other.displayName) && + deepEquals(self.website, other.website) && + deepEquals(self.role, other.role)); + +int _$AccountHashCode(Account self) => Object.hashAll([ + self.runtimeType, + deepHashCode(self.displayName), + deepHashCode(self.website), + deepHashCode(self.role), +]); + final class _AccountCopyWithUnset { const _AccountCopyWithUnset(); } @@ -433,6 +500,9 @@ final class _$AccountCopyWith implements $AccountCopyWith { static const _AccountCopyWithUnset _ackCopyWithUnset = _AccountCopyWithUnset(); + static bool _ackIsCopyWithUnset(Object? value) => + identical(value, _ackCopyWithUnset); + final Account _source; @override @@ -442,54 +512,44 @@ final class _$AccountCopyWith implements $AccountCopyWith { String? role, }) => Account( displayName: displayName ?? _source.displayName, - website: identical(website, _ackCopyWithUnset) - ? _source.website - : website as Uri?, + website: _ackIsCopyWithUnset(website) ? _source.website : website as Uri?, role: role ?? _source.role, ); } -mixin _$AccountAck { - /// Creates a copy of this model with selected fields replaced. - $AccountCopyWith get copyWith => _$AccountCopyWith(this as Account); +augment final class Account { + /// Creates a copy with selected fields replaced. + _$AccountCopyWithApi get copyWith => _$AccountCopyWith(this); - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! Account || runtimeType != other.runtimeType) { - return false; - } - final self = this as Account; - return deepEquals(self.displayName, other.displayName) && - deepEquals(self.website, other.website) && - deepEquals(self.role, other.role); - } + _AckBool operator ==(_AckObject other) => _$AccountEquals(this, other); - @override - int get hashCode { - final self = this as Account; - return Object.hashAll([ - runtimeType, - deepHashCode(self.displayName), - deepHashCode(self.website), - deepHashCode(self.role), - ]); - } + _AckInt get hashCode => _$AccountHashCode(this); - @override - String toString() { - final self = this as Account; - return 'Account(displayName: ${self.displayName}, website: ${self.website}, role: ${self.role})'; - } + _AckString toString() => + 'Account(displayName: $displayName, website: $website, role: $role)'; + + static _AckSchema<_AckMap<_AckString, _AckObject?>, Account> get schema => + _$AccountSchemaApi.schema; + + /// Validates decoded JSON and creates a model. + factory Account.fromJson(_AckMap<_AckString, _AckDynamic> json) => + _$AccountSchemaApi.fromJson(json); + + /// Validates an untrusted value and creates a model. + static Account parse(_AckObject? value, {_AckString? debugName}) => + _$AccountSchemaApi.parse(value, debugName: debugName); + + /// Validates an untrusted value without throwing on validation failure. + static _AckSchemaResult safeParse( + _AckObject? value, { + _AckString? debugName, + }) => _$AccountSchemaApi.safeParse(value, debugName: debugName); /// Validates this model and encodes it for JSON. - /// - /// Throws an `AckException` when validation fails. - Map toJson() => - Map.from(AccountSchema.encode(this as Account)); + _AckMap<_AckString, _AckDynamic> toJson() => + _AckMap<_AckString, _AckDynamic>.from(_$AccountSchemaApi.encode(this)); - /// Validates this model and encodes it for JSON, returning the validation - /// failure instead of throwing. - SchemaResult> safeToJson() => - AccountSchema.safeEncode(this as Account); + /// Validates this model and encodes it without throwing on validation failure. + _AckSchemaResult<_AckMap<_AckString, _AckObject?>> safeToJson() => + _$AccountSchemaApi.safeEncode(this); } diff --git a/example/lib/pet.g.dart b/example/lib/pet.g.dart index 36ea4f93..639cbbda 100644 --- a/example/lib/pet.g.dart +++ b/example/lib/pet.g.dart @@ -115,8 +115,8 @@ final class Cat extends Pet { bool operator ==(Object other) => identical(this, other) || (other is Cat && - runtimeType == other.runtimeType && - deepEquals(lives, other.lives)); + this.runtimeType == other.runtimeType && + deepEquals(this.lives, other.lives)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(lives)]); @@ -201,8 +201,8 @@ final class Dog extends Pet { bool operator ==(Object other) => identical(this, other) || (other is Dog && - runtimeType == other.runtimeType && - deepEquals(breed, other.breed)); + this.runtimeType == other.runtimeType && + deepEquals(this.breed, other.breed)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(breed)]); diff --git a/example/lib/schema_types_discriminated.g.dart b/example/lib/schema_types_discriminated.g.dart index 416653d1..87f21876 100644 --- a/example/lib/schema_types_discriminated.g.dart +++ b/example/lib/schema_types_discriminated.g.dart @@ -115,8 +115,8 @@ final class Cat extends Pet { bool operator ==(Object other) => identical(this, other) || (other is Cat && - runtimeType == other.runtimeType && - deepEquals(lives, other.lives)); + this.runtimeType == other.runtimeType && + deepEquals(this.lives, other.lives)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(lives)]); @@ -207,9 +207,9 @@ final class Dog extends Pet { bool operator ==(Object other) => identical(this, other) || (other is Dog && - runtimeType == other.runtimeType && - deepEquals(bark, other.bark) && - deepEquals(additionalProperties, other.additionalProperties)); + this.runtimeType == other.runtimeType && + deepEquals(this.bark, other.bark) && + deepEquals(this.additionalProperties, other.additionalProperties)); @override int get hashCode => Object.hashAll([ diff --git a/example/lib/schema_types_edge_cases.g.dart b/example/lib/schema_types_edge_cases.g.dart index d393991a..284b0425 100644 --- a/example/lib/schema_types_edge_cases.g.dart +++ b/example/lib/schema_types_edge_cases.g.dart @@ -78,11 +78,11 @@ final class Product { bool operator ==(Object other) => identical(this, other) || (other is Product && - runtimeType == other.runtimeType && - deepEquals(name, other.name) && - deepEquals(tags, other.tags) && - deepEquals(scores, other.scores) && - deepEquals(flags, other.flags)); + this.runtimeType == other.runtimeType && + deepEquals(this.name, other.name) && + deepEquals(this.tags, other.tags) && + deepEquals(this.scores, other.scores) && + deepEquals(this.flags, other.flags)); @override int get hashCode => Object.hashAll([ @@ -205,9 +205,9 @@ final class Grid { bool operator ==(Object other) => identical(this, other) || (other is Grid && - runtimeType == other.runtimeType && - deepEquals(name, other.name) && - deepEquals(matrix, other.matrix)); + this.runtimeType == other.runtimeType && + deepEquals(this.name, other.name) && + deepEquals(this.matrix, other.matrix)); @override int get hashCode => @@ -317,11 +317,11 @@ final class Address { bool operator ==(Object other) => identical(this, other) || (other is Address && - runtimeType == other.runtimeType && - deepEquals(street, other.street) && - deepEquals(city, other.city) && - deepEquals(zipCode, other.zipCode) && - deepEquals(country, other.country)); + this.runtimeType == other.runtimeType && + deepEquals(this.street, other.street) && + deepEquals(this.city, other.city) && + deepEquals(this.zipCode, other.zipCode) && + deepEquals(this.country, other.country)); @override int get hashCode => Object.hashAll([ @@ -450,11 +450,11 @@ final class Person { bool operator ==(Object other) => identical(this, other) || (other is Person && - runtimeType == other.runtimeType && - deepEquals(name, other.name) && - deepEquals(email, other.email) && - deepEquals(address, other.address) && - deepEquals(age, other.age)); + this.runtimeType == other.runtimeType && + deepEquals(this.name, other.name) && + deepEquals(this.email, other.email) && + deepEquals(this.address, other.address) && + deepEquals(this.age, other.age)); @override int get hashCode => Object.hashAll([ @@ -575,11 +575,11 @@ final class Employee { bool operator ==(Object other) => identical(this, other) || (other is Employee && - runtimeType == other.runtimeType && - deepEquals(name, other.name) && - deepEquals(employeeId, other.employeeId) && - deepEquals(homeAddress, other.homeAddress) && - deepEquals(workAddress, other.workAddress)); + this.runtimeType == other.runtimeType && + deepEquals(this.name, other.name) && + deepEquals(this.employeeId, other.employeeId) && + deepEquals(this.homeAddress, other.homeAddress) && + deepEquals(this.workAddress, other.workAddress)); @override int get hashCode => Object.hashAll([ @@ -716,12 +716,12 @@ final class Modifier { bool operator ==(Object other) => identical(this, other) || (other is Modifier && - runtimeType == other.runtimeType && - deepEquals(requiredField, other.requiredField) && - deepEquals(optionalField, other.optionalField) && - deepEquals(nullableField, other.nullableField) && - deepEquals(optionalNullable, other.optionalNullable) && - deepEquals(nullableOptional, other.nullableOptional)); + this.runtimeType == other.runtimeType && + deepEquals(this.requiredField, other.requiredField) && + deepEquals(this.optionalField, other.optionalField) && + deepEquals(this.nullableField, other.nullableField) && + deepEquals(this.optionalNullable, other.optionalNullable) && + deepEquals(this.nullableOptional, other.nullableOptional)); @override int get hashCode => Object.hashAll([ @@ -779,6 +779,9 @@ final class _$ModifierCopyWith implements $ModifierCopyWith { static const _ModifierCopyWithUnset _ackCopyWithUnset = _ModifierCopyWithUnset(); + static bool _ackIsCopyWithUnset(Object? value) => + identical(value, _ackCopyWithUnset); + final Modifier _source; @override @@ -790,16 +793,16 @@ final class _$ModifierCopyWith implements $ModifierCopyWith { Object? nullableOptional = _ackCopyWithUnset, }) => Modifier( requiredField: requiredField ?? _source.requiredField, - optionalField: identical(optionalField, _ackCopyWithUnset) + optionalField: _ackIsCopyWithUnset(optionalField) ? _source.optionalField : optionalField as String?, - nullableField: identical(nullableField, _ackCopyWithUnset) + nullableField: _ackIsCopyWithUnset(nullableField) ? _source.nullableField : nullableField as String?, - optionalNullable: identical(optionalNullable, _ackCopyWithUnset) + optionalNullable: _ackIsCopyWithUnset(optionalNullable) ? _source.optionalNullable : optionalNullable as String?, - nullableOptional: identical(nullableOptional, _ackCopyWithUnset) + nullableOptional: _ackIsCopyWithUnset(nullableOptional) ? _source.nullableOptional : nullableOptional as String?, ); @@ -886,11 +889,11 @@ final class TaggedItem { bool operator ==(Object other) => identical(this, other) || (other is TaggedItem && - runtimeType == other.runtimeType && - deepEquals(name, other.name) && - deepEquals(requiredTags, other.requiredTags) && - deepEquals(optionalTags, other.optionalTags) && - deepEquals(nullableTags, other.nullableTags)); + this.runtimeType == other.runtimeType && + deepEquals(this.name, other.name) && + deepEquals(this.requiredTags, other.requiredTags) && + deepEquals(this.optionalTags, other.optionalTags) && + deepEquals(this.nullableTags, other.nullableTags)); @override int get hashCode => Object.hashAll([ @@ -950,6 +953,9 @@ final class _$TaggedItemCopyWith implements $TaggedItemCopyWith { static const _TaggedItemCopyWithUnset _ackCopyWithUnset = _TaggedItemCopyWithUnset(); + static bool _ackIsCopyWithUnset(Object? value) => + identical(value, _ackCopyWithUnset); + final TaggedItem _source; @override @@ -961,10 +967,10 @@ final class _$TaggedItemCopyWith implements $TaggedItemCopyWith { }) => TaggedItem( name: name ?? _source.name, requiredTags: requiredTags ?? _source.requiredTags, - optionalTags: identical(optionalTags, _ackCopyWithUnset) + optionalTags: _ackIsCopyWithUnset(optionalTags) ? _source.optionalTags : optionalTags as List?, - nullableTags: identical(nullableTags, _ackCopyWithUnset) + nullableTags: _ackIsCopyWithUnset(nullableTags) ? _source.nullableTags : nullableTags as List?, ); @@ -1029,9 +1035,9 @@ final class ContactList { bool operator ==(Object other) => identical(this, other) || (other is ContactList && - runtimeType == other.runtimeType && - deepEquals(name, other.name) && - deepEquals(addresses, other.addresses)); + this.runtimeType == other.runtimeType && + deepEquals(this.name, other.name) && + deepEquals(this.addresses, other.addresses)); @override int get hashCode => Object.hashAll([ @@ -1131,7 +1137,7 @@ final class Empty { @override bool operator ==(Object other) => identical(this, other) || - (other is Empty && runtimeType == other.runtimeType); + (other is Empty && this.runtimeType == other.runtimeType); @override int get hashCode => Object.hashAll([runtimeType]); @@ -1216,8 +1222,8 @@ final class Minimal { bool operator ==(Object other) => identical(this, other) || (other is Minimal && - runtimeType == other.runtimeType && - deepEquals(id, other.id)); + this.runtimeType == other.runtimeType && + deepEquals(this.id, other.id)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(id)]); @@ -1305,8 +1311,8 @@ final class NamedItem { bool operator ==(Object other) => identical(this, other) || (other is NamedItem && - runtimeType == other.runtimeType && - deepEquals(name, other.name)); + this.runtimeType == other.runtimeType && + deepEquals(this.name, other.name)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(name)]); @@ -1393,8 +1399,8 @@ final class Item { bool operator ==(Object other) => identical(this, other) || (other is Item && - runtimeType == other.runtimeType && - deepEquals(id, other.id)); + this.runtimeType == other.runtimeType && + deepEquals(this.id, other.id)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(id)]); @@ -1483,8 +1489,8 @@ final class MyCustomSchema123 { bool operator ==(Object other) => identical(this, other) || (other is MyCustomSchema123 && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); diff --git a/example/lib/schema_types_primitives.dart b/example/lib/schema_types_primitives.dart index cb061eb0..0684f60d 100644 --- a/example/lib/schema_types_primitives.dart +++ b/example/lib/schema_types_primitives.dart @@ -58,20 +58,16 @@ final optionalStatusSchema = Ack.literal('active').optional(); final nullableRoleSchema = Ack.enumString(['admin', 'user']).nullable(); @Schemable() -final defaultedEnumSchema = Ack.enumValues( - UserRole.values, -).withDefault(UserRole.guest); +final defaultedEnumSchema = Ack.enumValues(UserRole.values) + .withDefault(UserRole.guest); -final optionalNullableLiteralSchema = Ack.literal( - 'pending', -).optional().nullable(); +final optionalNullableLiteralSchema = Ack.literal('pending') + .optional() + .nullable(); @Schemable() -final chainedEnumStringSchema = Ack.enumString([ - 'read', - 'write', - 'execute', -]).withDefault('read'); +final chainedEnumStringSchema = Ack.enumString(['read', 'write', 'execute']) + .withDefault('read'); // Test refine - this should work @Schemable() diff --git a/example/lib/schema_types_primitives.g.dart b/example/lib/schema_types_primitives.g.dart index 86b6f22f..f98a135b 100644 --- a/example/lib/schema_types_primitives.g.dart +++ b/example/lib/schema_types_primitives.g.dart @@ -66,8 +66,8 @@ final class Password { bool operator ==(Object other) => identical(this, other) || (other is Password && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -154,8 +154,8 @@ final class Age { bool operator ==(Object other) => identical(this, other) || (other is Age && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -242,8 +242,8 @@ final class Price { bool operator ==(Object other) => identical(this, other) || (other is Price && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -330,8 +330,8 @@ final class Active { bool operator ==(Object other) => identical(this, other) || (other is Active && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -419,8 +419,8 @@ final class Tags { bool operator ==(Object other) => identical(this, other) || (other is Tags && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -509,8 +509,8 @@ final class Scores { bool operator ==(Object other) => identical(this, other) || (other is Scores && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -600,8 +600,8 @@ final class StatusLiteral { bool operator ==(Object other) => identical(this, other) || (other is StatusLiteral && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -689,8 +689,8 @@ final class Role { bool operator ==(Object other) => identical(this, other) || (other is Role && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -779,8 +779,8 @@ final class UserRoleModel { bool operator ==(Object other) => identical(this, other) || (other is UserRoleModel && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -870,8 +870,8 @@ final class StatusEnum { bool operator ==(Object other) => identical(this, other) || (other is StatusEnum && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -960,8 +960,8 @@ final class OptionalStatus { bool operator ==(Object other) => identical(this, other) || (other is OptionalStatus && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -1052,8 +1052,8 @@ final class DefaultedEnum { bool operator ==(Object other) => identical(this, other) || (other is DefaultedEnum && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -1144,8 +1144,8 @@ final class ChainedEnumString { bool operator ==(Object other) => identical(this, other) || (other is ChainedEnumString && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -1236,8 +1236,8 @@ final class RefinedAge { bool operator ==(Object other) => identical(this, other) || (other is RefinedAge && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); diff --git a/example/lib/schema_types_simple.g.dart b/example/lib/schema_types_simple.g.dart index 17265be9..e4a08d4d 100644 --- a/example/lib/schema_types_simple.g.dart +++ b/example/lib/schema_types_simple.g.dart @@ -68,10 +68,10 @@ final class User { bool operator ==(Object other) => identical(this, other) || (other is User && - runtimeType == other.runtimeType && - deepEquals(name, other.name) && - deepEquals(age, other.age) && - deepEquals(active, other.active)); + this.runtimeType == other.runtimeType && + deepEquals(this.name, other.name) && + deepEquals(this.age, other.age) && + deepEquals(this.active, other.active)); @override int get hashCode => Object.hashAll([ diff --git a/example/lib/schema_types_transforms.dart b/example/lib/schema_types_transforms.dart index 66abedcf..37c23771 100644 --- a/example/lib/schema_types_transforms.dart +++ b/example/lib/schema_types_transforms.dart @@ -44,7 +44,6 @@ final profileSchema = Ack.object({ encode: (color) => color.value, ), ), - 'tagList': Ack.list( - Ack.string(), - ).codec(decode: TagList.new, encode: (tags) => tags.value), + 'tagList': Ack.list(Ack.string()) + .codec(decode: TagList.new, encode: (tags) => tags.value), }); diff --git a/example/lib/schema_types_transforms.g.dart b/example/lib/schema_types_transforms.g.dart index 393adba1..4c80ada6 100644 --- a/example/lib/schema_types_transforms.g.dart +++ b/example/lib/schema_types_transforms.g.dart @@ -66,8 +66,8 @@ final class ColorModel { bool operator ==(Object other) => identical(this, other) || (other is ColorModel && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -190,18 +190,18 @@ final class Profile { bool operator ==(Object other) => identical(this, other) || (other is Profile && - runtimeType == other.runtimeType && - deepEquals(homepage, other.homepage) && - deepEquals(birthday, other.birthday) && - deepEquals(lastLogin, other.lastLogin) && - deepEquals(timeout, other.timeout) && - deepEquals(links, other.links) && - deepEquals(favoriteColor, other.favoriteColor) && - deepEquals(slug, other.slug) && - deepEquals(accent, other.accent) && - deepEquals(colors, other.colors) && - deepEquals(customColors, other.customColors) && - deepEquals(tagList, other.tagList)); + this.runtimeType == other.runtimeType && + deepEquals(this.homepage, other.homepage) && + deepEquals(this.birthday, other.birthday) && + deepEquals(this.lastLogin, other.lastLogin) && + deepEquals(this.timeout, other.timeout) && + deepEquals(this.links, other.links) && + deepEquals(this.favoriteColor, other.favoriteColor) && + deepEquals(this.slug, other.slug) && + deepEquals(this.accent, other.accent) && + deepEquals(this.colors, other.colors) && + deepEquals(this.customColors, other.customColors) && + deepEquals(this.tagList, other.tagList)); @override int get hashCode => Object.hashAll([ diff --git a/example/lib/user_with_color.g.dart b/example/lib/user_with_color.g.dart index bb79fb27..93c86ef8 100644 --- a/example/lib/user_with_color.g.dart +++ b/example/lib/user_with_color.g.dart @@ -66,8 +66,8 @@ final class ColorModel { bool operator ==(Object other) => identical(this, other) || (other is ColorModel && - runtimeType == other.runtimeType && - deepEquals(value, other.value)); + this.runtimeType == other.runtimeType && + deepEquals(this.value, other.value)); @override int get hashCode => Object.hashAll([runtimeType, deepHashCode(value)]); @@ -156,9 +156,9 @@ final class Profile { bool operator ==(Object other) => identical(this, other) || (other is Profile && - runtimeType == other.runtimeType && - deepEquals(bio, other.bio) && - deepEquals(website, other.website)); + this.runtimeType == other.runtimeType && + deepEquals(this.bio, other.bio) && + deepEquals(this.website, other.website)); @override int get hashCode => @@ -195,14 +195,15 @@ final class _$ProfileCopyWith implements $ProfileCopyWith { static const _ProfileCopyWithUnset _ackCopyWithUnset = _ProfileCopyWithUnset(); + static bool _ackIsCopyWithUnset(Object? value) => + identical(value, _ackCopyWithUnset); + final Profile _source; @override Profile call({String? bio, Object? website = _ackCopyWithUnset}) => Profile( bio: bio ?? _source.bio, - website: identical(website, _ackCopyWithUnset) - ? _source.website - : website as Uri?, + website: _ackIsCopyWithUnset(website) ? _source.website : website as Uri?, ); } @@ -286,15 +287,15 @@ final class UserWithColor { bool operator ==(Object other) => identical(this, other) || (other is UserWithColor && - runtimeType == other.runtimeType && - deepEquals(firstName, other.firstName) && - deepEquals(lastName, other.lastName) && - deepEquals(age, other.age) && - deepEquals(profile, other.profile) && - deepEquals(color, other.color) && - deepEquals(favoriteColor, other.favoriteColor) && - deepEquals(pet, other.pet) && - deepEquals(pets, other.pets)); + this.runtimeType == other.runtimeType && + deepEquals(this.firstName, other.firstName) && + deepEquals(this.lastName, other.lastName) && + deepEquals(this.age, other.age) && + deepEquals(this.profile, other.profile) && + deepEquals(this.color, other.color) && + deepEquals(this.favoriteColor, other.favoriteColor) && + deepEquals(this.pet, other.pet) && + deepEquals(this.pets, other.pets)); @override int get hashCode => Object.hashAll([ @@ -378,6 +379,9 @@ final class _$UserWithColorCopyWith static const _UserWithColorCopyWithUnset _ackCopyWithUnset = _UserWithColorCopyWithUnset(); + static bool _ackIsCopyWithUnset(Object? value) => + identical(value, _ackCopyWithUnset); + final UserWithColor _source; @override @@ -396,7 +400,7 @@ final class _$UserWithColorCopyWith age: age ?? _source.age, profile: profile ?? _source.profile, color: color ?? _source.color, - favoriteColor: identical(favoriteColor, _ackCopyWithUnset) + favoriteColor: _ackIsCopyWithUnset(favoriteColor) ? _source.favoriteColor : favoriteColor as ColorModel?, pet: pet ?? _source.pet, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 0d0c188b..91c91265 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: none resolution: workspace environment: - sdk: '>=3.11.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: @@ -14,13 +14,7 @@ dependencies: dev_dependencies: ack_generator: ^2.0.0-alpha.1 - build_runner: ^2.4.0 + build_runner: ^2.16.2 dart_code_metrics_presets: ^2.22.0 - lints: ^5.0.0 + lints: ^6.1.0 test: ^1.25.15 -# Override dependencies to use local packages -# dependency_overrides: -# ack: -# path: ../packages/ack -# ack_generator: -# path: ../packages/ack_generator diff --git a/example/test/class_first_models_test.dart b/example/test/class_first_models_test.dart index 32e7dd07..e3766ad8 100644 --- a/example/test/class_first_models_test.dart +++ b/example/test/class_first_models_test.dart @@ -13,6 +13,9 @@ void main() { expect(AccountSchema.toJsonSchema()['type'], 'object'); expect(AccountSchema.toSchemaModel(), isNotNull); expect(AccountSchema.wireSchema, isNotNull); + expect(Account.schema, same(AccountSchema.schema)); + expect(Account.parse({'display_name': 'Ada'}), account); + expect(Account.safeParse({'display_name': 'A'}).isFail, isTrue); }); test('explicit descriptions and tags become schema descriptions', () { @@ -34,7 +37,7 @@ void main() { }); test('class-first sealed models dispatch through a discriminator', () { - final pet = PetSchema.parse({'type': 'cat', 'id': 'c1', 'lives': 9}); + final pet = Pet.fromJson({'type': 'cat', 'id': 'c1', 'lives': 9}); expect(pet, isA()); expect(pet.toJson(), {'type': 'cat', 'id': 'c1', 'lives': 9}); diff --git a/llms.txt b/llms.txt index f79f7a60..a8d4da39 100644 --- a/llms.txt +++ b/llms.txt @@ -1,5 +1,10 @@ # Ack +Ack 2.0 development uses Flutter 3.50.0-0.1.pre / Dart 3.14.0-315.1.beta. +Class-first generation requires `augmentations` in analyzer.enable-experiment, +`build_runner build --enable-experiment=augmentations`, and Dart/Flutter runtime +commands. Primary constructors are enabled by default. + > A schema validation library for Dart and Flutter with a fluent runtime API, bidirectional codecs, JSON Schema Draft 2020-12 import/export, and two-way model/schema code generation. Ack validates untrusted boundary data with schemas built through the `Ack` @@ -52,7 +57,7 @@ Codecs decode a boundary value into a runtime value and encode it back. ## Model code generation Both directions use `@Schemable()`, a single `part '.g.dart';` directive, -and `dart run build_runner build`. On a top-level schema it is schema-first; on +and `dart run build_runner build --enable-experiment=augmentations`. On a top-level schema it is schema-first; on a class it is class-first; on a `library;` directive `@Schemable(schemas: [...])` registers shared custom schemas. `name` applies only to a schema; class options apply only to a class, and `schemas` applies to a class or `library;` directive. @@ -75,7 +80,7 @@ final userSchema = Ack.object({ }); ``` -Run `dart run build_runner build`. `addressSchema` and `userSchema` generate +Run `dart run build_runner build --enable-experiment=augmentations`. `addressSchema` and `userSchema` generate `Address` and `User`. A custom `@Schemable(name: 'Member')` value is used exactly. Generated object models provide: @@ -92,7 +97,7 @@ Generated object models provide: - a public static `User.$ack` adapter for nested generated models; - an unmodifiable `additionalProperties` map for passthrough objects. -For class-first generation (supporting both standard constructors and Dart 3.12+ +For class-first generation (supporting both standard constructors and Dart 3.14 primary constructors): ```dart @@ -101,21 +106,16 @@ import 'package:ack/ack.dart'; part 'account.g.dart'; @Schemable(caseStyle: AckCaseStyle.snake) -final class Account with _$AccountAck { - const Account({required this.displayName, this.role = 'member'}); - - @Validate.minLength(2) - final String displayName; - final String role; - - static final schema = _accountSchema; - static final fromJson = AccountSchema.fromJson; -} +final class const Account({ + @Validate.minLength(2) required final String displayName, + final String role = 'member', +}); ``` This generates an `AccountSchema` facade backed by a private `_accountObject` -wire schema and `_accountSchema` codec. Instantiable models apply the -`_$AccountAck` mixin for `toJson`, `safeToJson`, `copyWith`, and deep +wire schema and `_accountSchema` codec. The generated +class augmentation adds a `fromJson` factory, static `parse` / `safeParse` / +`schema`, `toJson`, `safeToJson`, `copyWith`, and deep equality. The facade exposes `schema`, `wireSchema`, `parse`, `safeParse`, `fromJson`, `encode`, `safeEncode`, `toJsonSchema`, and `toSchemaModel`. `schemaName:` overrides the exact public UpperCamelCase facade name; no public @@ -184,14 +184,17 @@ static `schema` or a `@Schemable(schemas: [...])` entry resolves to it instead o Extension types work the same way when they implement `Object`. The schema must produce the field type, or generation fails. A class-first model -may expose its generated codec as `static final schema = _accountSchema;` (or +exposes its generated codec automatically as `Account.schema` (or `AccountSchema.schema`). Schema-first objects may reference the same members (`'header': Slot.schema`, `'open': Command.schema()`). -Nested class-first models compose through `AddressSchema.schema` (or -`Address.schema` when declared on `Address`), preserving import prefixes. Import -combinators and barrels can expose just `Address` when `Address.schema` is -declared, or both `Address` and `AddressSchema` otherwise. Schema-first +Nested class-first models compose through generated `Address.schema`, +preserving import prefixes. Import combinators and barrels need only expose +`Address`; explicit facade references also require `AddressSchema`. Ack owns +the schema/parsing/serialization/value member names; remove handwritten +forwarders and rename colliding members or import prefixes. Generic annotated +classes and union branches are unsupported. External domain types may still +provide their own type-owned schemas. Schema-first declarations may explicitly use `Address.schema` or the facade, and class-first fields may use schema-first generated model types; both directions work from a clean build. Nullable/list/set wrappers are preserved. Automatic recursive @@ -229,7 +232,7 @@ and constraint annotations (`@Validate.optional()`, `@Validate.required()`, constructor defaults, case styles, `AckUnknownPropertyPolicy`, and same-library sealed discriminated unions. It rejects `dynamic`, nullable list or set elements, non-String map keys, recursive class-first graphs, class-first value -roots, undiscriminated `anyOf` models, and missing `_$ClassAck` mixins. +roots, undiscriminated `anyOf` models, and generated-member collisions. Open JSON and presence-aware fields are inferred from their Dart types. `Object` means a JSON-safe value (finite numbers, strings, booleans, lists, and diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 6a5b33c2..df0bf4ab 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -2,6 +2,14 @@ ### Breaking +* Target Dart 3.14 / Flutter 3.50; development is pinned to + Dart `3.14.0-315.1.beta` / Flutter `3.50.0-0.1.pre` until stable releases exist. +* Class-first models use experimental augmentations instead of `_$ModelAck` + mixins. Remove mixins and handwritten schema/parsing forwarders; generation adds + `fromJson`, `parse`, `safeParse`, and `schema` directly. Both primary and + conventional constructors remain supported. Enable `augmentations` in + analyzer, build_runner, and runtime/test commands. + * Remove legacy 1.x generator annotations (`@AckType`, `@AckInfer`, `@AckModel`, `@AckField`, `JsonKeyCaseStyle`, `AckFieldPresence`, and `@Schemable.generatedJson`). Use `@Schemable()` on both top-level schemas and diff --git a/packages/ack/lib/src/annotations/ack_field.dart b/packages/ack/lib/src/annotations/ack_field.dart index 991f8854..148b373f 100644 --- a/packages/ack/lib/src/annotations/ack_field.dart +++ b/packages/ack/lib/src/annotations/ack_field.dart @@ -5,12 +5,9 @@ import 'package:meta/meta_meta.dart'; /// /// ```dart /// @Schemable() -/// final class Account with _$AccountAck { -/// const Account({required this.imageUrl}); -/// -/// @AckKey('avatar') -/// final Uri imageUrl; -/// } +/// final class const Account({ +/// @AckKey('avatar') required final Uri imageUrl, +/// }); /// ``` @Target({TargetKind.field, TargetKind.parameter}) final class AckKey { diff --git a/packages/ack/lib/src/annotations/constraints.dart b/packages/ack/lib/src/annotations/constraints.dart index 8893d76e..8a4aae04 100644 --- a/packages/ack/lib/src/annotations/constraints.dart +++ b/packages/ack/lib/src/annotations/constraints.dart @@ -7,12 +7,10 @@ import 'package:meta/meta_meta.dart'; /// /// ```dart /// @Schemable() -/// final class User({ +/// final class const User({ /// @Validate.email() required final String email, /// @Validate.minLength(2) required final String name, -/// }) with _$UserAck { -/// static final schema = _userSchema; -/// } +/// }); /// ``` @Target({TargetKind.field, TargetKind.parameter}) final class Validate { diff --git a/packages/ack/lib/src/constraints/datetime_constraint.dart b/packages/ack/lib/src/constraints/datetime_constraint.dart index f3d90579..c41be5ae 100644 --- a/packages/ack/lib/src/constraints/datetime_constraint.dart +++ b/packages/ack/lib/src/constraints/datetime_constraint.dart @@ -24,12 +24,12 @@ class DateTimeConstraint extends Constraint const DateTimeConstraint._({ required this.reference, - required bool isMinimum, + required this._isMinimum, required this.jsonSchemaFormat, required this.formattedReference, required super.constraintKey, required super.description, - }) : _isMinimum = isMinimum; + }); /// Creates a date-formatted minimum constraint for `Ack.date()`. factory DateTimeConstraint.minDate(DateTime date) { diff --git a/packages/ack/lib/src/constraints/pattern_constraint.dart b/packages/ack/lib/src/constraints/pattern_constraint.dart index 3ef81336..30ca054d 100644 --- a/packages/ack/lib/src/constraints/pattern_constraint.dart +++ b/packages/ack/lib/src/constraints/pattern_constraint.dart @@ -227,7 +227,7 @@ class PatternConstraint extends Constraint return { ...baseContext, 'allowedValues': allowedValues, - if (closestMatch != null) 'closestMatchSuggestion': closestMatch, + 'closestMatchSuggestion': ?closestMatch, }; } diff --git a/packages/ack/lib/src/constraints/validators.dart b/packages/ack/lib/src/constraints/validators.dart index cdf66efa..9604e3dc 100644 --- a/packages/ack/lib/src/constraints/validators.dart +++ b/packages/ack/lib/src/constraints/validators.dart @@ -94,8 +94,7 @@ class ObjectNoAdditionalPropertiesConstraint extends Constraint ObjectNoAdditionalPropertiesConstraint({required this.unexpectedPropertyKey}) : super( constraintKey: 'object_additional_properties_disallowed', - description: - 'Object must not contain properties beyond those defined in the schema.', + description: 'Object must not contain properties beyond those defined in the schema.', ); @override diff --git a/packages/ack/lib/src/json_schema/json_schema_formats.dart b/packages/ack/lib/src/json_schema/json_schema_formats.dart index 95be9ac8..5874bf75 100644 --- a/packages/ack/lib/src/json_schema/json_schema_formats.dart +++ b/packages/ack/lib/src/json_schema/json_schema_formats.dart @@ -398,9 +398,8 @@ bool _isVirama(int rune) => const { }.contains(rune); bool _isEmail(String value, {required bool international}) { - final match = RegExp( - r'^("(?:[^"\\]|\\[\s\S])*"|[^@]+)@(.+)$', - ).firstMatch(value); + final match = RegExp(r'^("(?:[^"\\]|\\[\s\S])*"|[^@]+)@(.+)$') + .firstMatch(value); if (match == null) return false; final local = match[1]!; final domain = match[2]!; @@ -481,9 +480,8 @@ bool _isUri( } final literal = value.substring(firstBracket + 1, lastBracket); if (!_isIpv6(literal) && - !RegExp( - r"^[vV][0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+$", - ).hasMatch(literal)) { + !RegExp(r"^[vV][0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+$") + .hasMatch(literal)) { return false; } } diff --git a/packages/ack/lib/src/models/ack_model_adapter.dart b/packages/ack/lib/src/models/ack_model_adapter.dart index 341ea16e..af5afce6 100644 --- a/packages/ack/lib/src/models/ack_model_adapter.dart +++ b/packages/ack/lib/src/models/ack_model_adapter.dart @@ -14,15 +14,13 @@ final class AckModelAdapter< > { /// Creates an adapter for a generated model. /// - /// [schema] is a callback rather than a stored schema value so generated + /// `schema` is a callback rather than a stored schema value so generated /// models can safely participate in recursive and mutually-recursive graphs. const AckModelAdapter({ - required AckSchema Function() schema, - required Model Function(Runtime value) fromRuntime, - required Runtime Function(Model value) toRuntime, - }) : _schema = schema, - _fromRuntime = fromRuntime, - _toRuntime = toRuntime; + required this._schema, + required this._fromRuntime, + required this._toRuntime, + }); final AckSchema Function() _schema; final Model Function(Runtime value) _fromRuntime; diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 85e8b02b..d77a39c9 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -205,9 +205,8 @@ sealed class AckSchemaModel { common = common.copyWith(defaultValue: keywords['default']); } - return _rebuildWithCommon( - common, - )._withJsonSchemaKeywords(keywords, commonHandled); + return _rebuildWithCommon(common) + ._withJsonSchemaKeywords(keywords, commonHandled); } AckSchemaModel _withJsonSchemaKeywords( @@ -300,21 +299,20 @@ sealed class AckSchemaModel { final class AckRawSchemaModel extends AckSchemaModel { const AckRawSchemaModel({ required this.schema, - Set> indexedSchemas = const {}, + this._indexedSchemas = const {}, super.title, super.description, super.nullable, super.defaultValue, super.warnings, super.extensions, - }) : _indexedSchemas = indexedSchemas; + }); AckRawSchemaModel._( _AckSchemaModelCommon common, { required this.schema, - required Set> indexedSchemas, - }) : _indexedSchemas = indexedSchemas, - super._(common); + required this._indexedSchemas, + }) : super._(common); final Map schema; final Set> _indexedSchemas; diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index a884814a..457d9c60 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -136,8 +136,7 @@ final class _SchemaModelBuilder { ...wrapped.warnings, AckSchemaModelWarning( code: 'default_not_export_safe', - message: - 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', + message: 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', ), ]); } @@ -460,8 +459,7 @@ final class _SchemaModelBuilder { warnings: const [ AckSchemaModelWarning( code: 'ack_instance_json_boundary', - message: - 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', + message: 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', ), ], ); @@ -485,8 +483,7 @@ final class _SchemaModelBuilder { warnings: const [ AckSchemaModelWarning( code: 'ack_any_json_boundary', - message: - 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', + message: 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', ), ], ); @@ -568,8 +565,7 @@ final class _SchemaModelBuilder { ...model.warnings, AckSchemaModelWarning( code: 'lazy_runtime_checks_not_export_safe', - message: - 'Ack.lazy constraints and refinements were omitted because JSON Schema refs cannot safely carry runtime-only validation checks.', + message: 'Ack.lazy constraints and refinements were omitted because JSON Schema refs cannot safely carry runtime-only validation checks.', context: { 'constraintCount': constraintCount, 'refinementCount': refinementCount, @@ -672,8 +668,7 @@ AckSchemaModel _applyConstraints( ...next.warnings, AckSchemaModelWarning( code: 'codec_runtime_constraint_not_exported', - message: - 'Codec constraints were omitted because they validate runtime values, not the encoded boundary values the exported schema describes.', + message: 'Codec constraints were omitted because they validate runtime values, not the encoded boundary values the exported schema describes.', context: {'constraints': omittedConstraintKeys}, ), ]); @@ -699,8 +694,7 @@ AckSchemaModel _applyDateTimeConstraint( ...model.warnings, AckSchemaModelWarning( code: 'datetime_constraint_not_draft7', - message: - 'DateTime range constraints are not emitted because JSON Schema Draft-7 has no standard format range keywords.', + message: 'DateTime range constraints are not emitted because JSON Schema Draft-7 has no standard format range keywords.', context: { 'constraint': constraint.comparisonType, 'reference': constraint.formattedReference, diff --git a/packages/ack/lib/src/schemas/any_schema.dart b/packages/ack/lib/src/schemas/any_schema.dart index d5a8bdbc..2157b15b 100644 --- a/packages/ack/lib/src/schemas/any_schema.dart +++ b/packages/ack/lib/src/schemas/any_schema.dart @@ -39,8 +39,7 @@ final class AnySchema extends AckSchema if (_jsonSafeOrNull(value) == null) { return SchemaResult.fail( SchemaValidationError( - message: - 'Expected a JSON-safe value composed of finite numbers, strings, booleans, lists, and string-keyed maps.', + message: 'Expected a JSON-safe value composed of finite numbers, strings, booleans, lists, and string-keyed maps.', context: context, ), ); diff --git a/packages/ack/lib/src/schemas/codec_schema.dart b/packages/ack/lib/src/schemas/codec_schema.dart index 4a83b31d..684d7f59 100644 --- a/packages/ack/lib/src/schemas/codec_schema.dart +++ b/packages/ack/lib/src/schemas/codec_schema.dart @@ -50,18 +50,16 @@ final class CodecSchema CodecSchema._({ required this.inputSchema, required this.outputSchema, - required Runtime Function(Object value) decoder, - required Object Function(Runtime value)? encoder, - required Object decoderIdentity, + required this._decoder, + required this._encoder, + required this._decoderIdentity, this.projectsConstraintsToBoundary = false, super.isNullable, super.isOptional, super.description, super.constraints, super.refinements, - }) : _decoder = decoder, - _encoder = encoder, - _decoderIdentity = decoderIdentity; + }); /// Creates a codec while preserving the input schema's runtime type. static CodecSchema create< diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 862250ec..cb061ec6 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -105,9 +105,8 @@ final class DiscriminatedObjectSchema final discValueRaw = mapValue[discriminatorKey]; if (discValueRaw is! String) { - final constraintError = InvalidTypeConstraint( - expectedType: String, - ).validate(discValueRaw); + final constraintError = InvalidTypeConstraint(expectedType: String) + .validate(discValueRaw); return SchemaResult.fail( SchemaConstraintsError( @@ -124,9 +123,8 @@ final class DiscriminatedObjectSchema if (!schemas.containsKey(discValueRaw)) { final allowed = schemas.keys.toList(growable: false); - final enumError = PatternConstraint.enumString( - allowed, - ).validate(discValueRaw); + final enumError = PatternConstraint.enumString(allowed) + .validate(discValueRaw); return SchemaResult.fail( SchemaConstraintsError( @@ -266,12 +264,11 @@ final class DiscriminatedObjectSchema } return effectiveDiscriminatedBranch( - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - branchSchema: branchSchema, - optionalDiscriminator: optionalDiscriminator, - ) - as AckSchema; + discriminatorKey: discriminatorKey, + discriminatorValue: discriminatorValue, + branchSchema: branchSchema, + optionalDiscriminator: optionalDiscriminator, + ) as AckSchema; } @override diff --git a/packages/ack/lib/src/schemas/enum_schema.dart b/packages/ack/lib/src/schemas/enum_schema.dart index 8e5da20c..1c0dc68c 100644 --- a/packages/ack/lib/src/schemas/enum_schema.dart +++ b/packages/ack/lib/src/schemas/enum_schema.dart @@ -60,7 +60,7 @@ final class EnumSchema extends AckSchema context: { 'received': value, 'allowedValues': allowed, - if (closest != null) 'closestMatchSuggestion': closest, + 'closestMatchSuggestion': ?closest, }, ); diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index edf13037..4c497afc 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -48,17 +48,15 @@ final class ImportedJsonSchema extends AckSchema ImportedJsonSchema._( this._root, { - required List<_ImportedNode> externalRoots, - required List<_ImportedNode> indexedNodes, + required this._externalRoots, + required this._indexedNodes, required this.assertFormats, bool? isNullable, super.isOptional, String? description, super.constraints, super.refinements, - }) : _externalRoots = externalRoots, - _indexedNodes = indexedNodes, - super( + }) : super( isNullable: isNullable ?? _checkImportedNode(_root, null) == null, description: description ?? _root.keywords['description'] as String?, ); diff --git a/packages/ack/lib/src/schemas/object_schema.dart b/packages/ack/lib/src/schemas/object_schema.dart index 58a79dcd..ccc285ec 100644 --- a/packages/ack/lib/src/schemas/object_schema.dart +++ b/packages/ack/lib/src/schemas/object_schema.dart @@ -77,9 +77,8 @@ final class ObjectSchema extends AckSchema onFail: errors.add, ); } else if (!schema.isOptional) { - final ce = ObjectRequiredPropertiesConstraint( - missingPropertyKey: key, - ).validate(mapValue); + final ce = ObjectRequiredPropertiesConstraint(missingPropertyKey: key) + .validate(mapValue); if (ce != null) { errors.add( SchemaConstraintsError( @@ -198,9 +197,8 @@ final class ObjectSchema extends AckSchema ), ); } else { - final ce = ObjectRequiredPropertiesConstraint( - missingPropertyKey: key, - ).validate(mapValue); + final ce = ObjectRequiredPropertiesConstraint(missingPropertyKey: key) + .validate(mapValue); if (ce != null) { errors.add( SchemaConstraintsError(constraints: [ce], context: propertyCtx), diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index d37446ea..458d32ec 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -95,7 +95,7 @@ sealed class AckSchema { /// Creates the shared configuration for a concrete schema. /// - /// Direct concrete-schema constructors retain [constraints] and [refinements] + /// Direct concrete-schema constructors retain `constraints` and `refinements` /// without copying so they can remain `const`. Prefer the `Ack` factories and /// fluent methods, which own the collections they create; callers using a /// concrete constructor must not mutate supplied collections afterward. @@ -103,10 +103,9 @@ sealed class AckSchema { this.isNullable = false, this.isOptional = false, this.description, - List> constraints = const [], - List> refinements = const [], - }) : _constraints = constraints, - _refinements = refinements; + this._constraints = const [], + this._refinements = const [], + }); // --------------------------------------------------------------------------- // Subclass-facing internal lifecycle diff --git a/packages/ack/lib/src/schemas/string_schema.dart b/packages/ack/lib/src/schemas/string_schema.dart index 1d1f0b52..6a5ceeb7 100644 --- a/packages/ack/lib/src/schemas/string_schema.dart +++ b/packages/ack/lib/src/schemas/string_schema.dart @@ -55,8 +55,8 @@ final class StringSchema extends AckSchema super.description, super.constraints, super.refinements, - List<_StringNormalizerStep> normalizers = const [], - }) : _normalizers = normalizers; + this._normalizers = const [], + }); @internal int? get firstPostNormalizerConstraintIndex => diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index 86fc8be2..1012557e 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -14,7 +14,7 @@ topics: - serialization environment: - sdk: '>=3.11.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: meta: ^1.15.0 @@ -26,7 +26,7 @@ dev_dependencies: test: ^1.25.15 coverage: ^1.11.1 dart_code_metrics_presets: ^2.22.0 - lints: ^5.0.0 + lints: ^6.1.0 path: ^1.9.0 # Melos workspace configuration - managed by workspace diff --git a/packages/ack/test/annotations/dart_core_names_test.dart b/packages/ack/test/annotations/dart_core_names_test.dart index fcde020c..f4be5a83 100644 --- a/packages/ack/test/annotations/dart_core_names_test.dart +++ b/packages/ack/test/annotations/dart_core_names_test.dart @@ -15,19 +15,16 @@ final class _Endpoint { } void main() { - test( - 'package:ack/ack.dart and annotations.dart leave dart:core Pattern, Uri, and DateTime visible', - () { - final Pattern pattern = RegExp('a'); - const annotation = Schemable(name: 'Banana'); - final endpoint = _Endpoint( - uri: Uri.parse('https://example.com'), - createdAt: DateTime.utc(2026, 10, 1), - ); + test('package:ack/ack.dart and annotations.dart leave dart:core Pattern, Uri, and DateTime visible', () { + final Pattern pattern = RegExp('a'); + const annotation = Schemable(name: 'Banana'); + final endpoint = _Endpoint( + uri: Uri.parse('https://example.com'), + createdAt: DateTime.utc(2026, 10, 1), + ); - expect(pattern.allMatches(annotation.name!.toLowerCase()), hasLength(3)); - expect(endpoint.uri.host, 'example.com'); - expect(endpoint.createdAt.year, 2026); - }, - ); + expect(pattern.allMatches(annotation.name!.toLowerCase()), hasLength(3)); + expect(endpoint.uri.host, 'example.com'); + expect(endpoint.createdAt.year, 2026); + }); } diff --git a/packages/ack/test/annotations/exports_test.dart b/packages/ack/test/annotations/exports_test.dart index d1c34a77..a159b1f8 100644 --- a/packages/ack/test/annotations/exports_test.dart +++ b/packages/ack/test/annotations/exports_test.dart @@ -45,44 +45,41 @@ void main() { expect(AckUnknownPropertyPolicy.values, hasLength(3)); }); - test( - 'constraint names from package:ack/ack.dart and annotations.dart leave room for app types', - () { - const uuid = Uuid('7c9e6679'); - const email = Email('ada@example.com'); - const url = Url('https://example.com'); - const date = Date('2026-10-01'); - const min = Min(1); - const max = Max(10); - const pattern = Pattern('^a'); - const contact = SingleImportContact(email, uuid); + test('constraint names from package:ack/ack.dart and annotations.dart leave room for app types', () { + const uuid = Uuid('7c9e6679'); + const email = Email('ada@example.com'); + const url = Url('https://example.com'); + const date = Date('2026-10-01'); + const min = Min(1); + const max = Max(10); + const pattern = Pattern('^a'); + const contact = SingleImportContact(email, uuid); - expect( - [ - uuid.value, - email.address, - url.href, - date.iso, - min.value, - max.value, - pattern.source, - contact.email.address, - contact.id.value, - ], - [ - '7c9e6679', - 'ada@example.com', - 'https://example.com', - '2026-10-01', - 1, - 10, - '^a', - 'ada@example.com', - '7c9e6679', - ], - ); - }, - ); + expect( + [ + uuid.value, + email.address, + url.href, + date.iso, + min.value, + max.value, + pattern.source, + contact.email.address, + contact.id.value, + ], + [ + '7c9e6679', + 'ada@example.com', + 'https://example.com', + '2026-10-01', + 1, + 10, + '^a', + 'ada@example.com', + '7c9e6679', + ], + ); + }); test( 'a prefixed annotations import exposes Schemable, AckKey, and Validate', diff --git a/packages/ack/test/consolidation_test.dart b/packages/ack/test/consolidation_test.dart index fac156c6..af447637 100644 --- a/packages/ack/test/consolidation_test.dart +++ b/packages/ack/test/consolidation_test.dart @@ -408,10 +408,8 @@ void main() { }); test('AnyOf encode runs root runtime refinements', () { - final schema = Ack.anyOf([ - Ack.string(), - Ack.integer(), - ]).refine((value) => value != 'blocked', message: 'blocked'); + final schema = Ack.anyOf([Ack.string(), Ack.integer()]) + .refine((value) => value != 'blocked', message: 'blocked'); expect(schema.safeParse('blocked').isFail, true); expect(schema.safeEncode('blocked').isFail, true); diff --git a/packages/ack/test/documentation/api_docs_examples_test.dart b/packages/ack/test/documentation/api_docs_examples_test.dart index 24bf5bfd..d0585653 100644 --- a/packages/ack/test/documentation/api_docs_examples_test.dart +++ b/packages/ack/test/documentation/api_docs_examples_test.dart @@ -167,9 +167,9 @@ void main() { }); test('list with constraints example', () { - final emailListSchema = Ack.list( - Ack.string().email(), - ).minItems(1).maxItems(5); + final emailListSchema = Ack.list(Ack.string().email()) + .minItems(1) + .maxItems(5); final validEmails = ['user@example.com', 'admin@example.com']; final result = emailListSchema.safeParse(validEmails); diff --git a/packages/ack/test/documentation/best_practices_test.dart b/packages/ack/test/documentation/best_practices_test.dart index ca7f27f7..b28008de 100644 --- a/packages/ack/test/documentation/best_practices_test.dart +++ b/packages/ack/test/documentation/best_practices_test.dart @@ -210,10 +210,8 @@ void main() { 'email': Ack.string().email().describe('User email address'), 'age': Ack.integer().min(13).max(120).describe('User age in years'), 'preferences': Ack.object({ - 'theme': Ack.enumString([ - 'light', - 'dark', - ]).describe('UI theme preference'), + 'theme': Ack.enumString(['light', 'dark']) + .describe('UI theme preference'), 'notifications': Ack.boolean().describe( 'Email notifications enabled', ), diff --git a/packages/ack/test/documentation/core_concepts_schemas_examples_test.dart b/packages/ack/test/documentation/core_concepts_schemas_examples_test.dart index 850a5fa3..08f6dcff 100644 --- a/packages/ack/test/documentation/core_concepts_schemas_examples_test.dart +++ b/packages/ack/test/documentation/core_concepts_schemas_examples_test.dart @@ -84,9 +84,10 @@ void main() { final tagsSchema = Ack.list(Ack.string()); expect(tagsSchema.safeParse(['news', 'tech']).isOk, isTrue); - final itemsSchema = Ack.list( - Ack.string(), - ).minLength(1).maxLength(3).unique(); + final itemsSchema = Ack.list(Ack.string()) + .minLength(1) + .maxLength(3) + .unique(); expect(itemsSchema.safeParse(['a', 'b']).isOk, isTrue); expect(itemsSchema.safeParse([]).isFail, isTrue); @@ -435,14 +436,12 @@ void main() { expect(upperSchema.safeParse('hello').getOrThrow(), equals('HELLO')); final userWithAgeSchema = - Ack.object({ - 'name': Ack.string(), - 'birthYear': Ack.integer(), - }).transform((data) { - final birthYear = data['birthYear'] as int; - final age = DateTime.now().year - birthYear; - return {...data, 'age': age}; - }); + Ack.object({'name': Ack.string(), 'birthYear': Ack.integer()}) + .transform((data) { + final birthYear = data['birthYear'] as int; + final age = DateTime.now().year - birthYear; + return {...data, 'age': age}; + }); final transformed = userWithAgeSchema.safeParse({ diff --git a/packages/ack/test/documentation/documentation_source_integrity_test.dart b/packages/ack/test/documentation/documentation_source_integrity_test.dart index a550a8db..d911e549 100644 --- a/packages/ack/test/documentation/documentation_source_integrity_test.dart +++ b/packages/ack/test/documentation/documentation_source_integrity_test.dart @@ -98,9 +98,8 @@ void main() { isTrue, ); expect( - File( - '../../docs/guides/creating-schema-converter-packages.mdx', - ).existsSync(), + File('../../docs/guides/creating-schema-converter-packages.mdx') + .existsSync(), isTrue, ); expect(serializedConfig, contains('/guides/schema-converter-quickstart')); diff --git a/packages/ack/test/documentation/getting_started_installation_examples_test.dart b/packages/ack/test/documentation/getting_started_installation_examples_test.dart index af20843b..35ef1c4a 100644 --- a/packages/ack/test/documentation/getting_started_installation_examples_test.dart +++ b/packages/ack/test/documentation/getting_started_installation_examples_test.dart @@ -18,9 +18,8 @@ void main() { }); test('generator setup documents both generation directions', () async { - final content = await File( - '../../docs/getting-started/installation.mdx', - ).readAsString(); + final content = await File('../../docs/getting-started/installation.mdx') + .readAsString(); expect(content, isNot(contains('ack_annotations'))); expect(content, contains('@Schemable()')); diff --git a/packages/ack/test/documentation/guides_json_schema_integration_examples_test.dart b/packages/ack/test/documentation/guides_json_schema_integration_examples_test.dart index dc253186..bb035f31 100644 --- a/packages/ack/test/documentation/guides_json_schema_integration_examples_test.dart +++ b/packages/ack/test/documentation/guides_json_schema_integration_examples_test.dart @@ -18,9 +18,10 @@ void main() { 'email': Ack.string().email().describe("User's email address"), 'role': Ack.enumValues(UserRole.values).withDefault(UserRole.user), 'isActive': Ack.boolean().withDefault(true), - 'tags': Ack.list( - Ack.string(), - ).unique().describe('List of user tags').nullable(), + 'tags': Ack.list(Ack.string()) + .unique() + .describe('List of user tags') + .nullable(), 'age': Ack.integer().min(0).max(120).nullable().describe("User's age"), }).describe('Represents a user in the system'); } @@ -83,11 +84,9 @@ void main() { isTrue, ); - final enumBranch = - anyOf.firstWhere( - (e) => e is Map && e['type'] == 'string', - ) - as Map; + final enumBranch = anyOf.firstWhere( + (e) => e is Map && e['type'] == 'string', + ) as Map; expect(enumBranch['enum'], equals(['admin', 'user', 'guest'])); }); @@ -114,11 +113,9 @@ void main() { isTrue, ); - final unionBranch = - outerAnyOf.firstWhere( - (e) => e is Map && e['anyOf'] is List, - ) - as Map; + final unionBranch = outerAnyOf.firstWhere( + (e) => e is Map && e['anyOf'] is List, + ) as Map; final innerAnyOf = unionBranch['anyOf'] as List; expect(innerAnyOf, hasLength(2)); }); diff --git a/packages/ack/test/documentation/overview_doc_examples_test.dart b/packages/ack/test/documentation/overview_doc_examples_test.dart index 74d2158d..b84e7403 100644 --- a/packages/ack/test/documentation/overview_doc_examples_test.dart +++ b/packages/ack/test/documentation/overview_doc_examples_test.dart @@ -174,14 +174,12 @@ void main() { test('transformation example adds derived age field', () { final personSchema = - Ack.object({ - 'name': Ack.string(), - 'birthYear': Ack.integer(), - }).transform((data) { - final birthYear = data['birthYear'] as int; - final age = DateTime.now().year - birthYear; - return {...data, 'age': age}; - }); + Ack.object({'name': Ack.string(), 'birthYear': Ack.integer()}) + .transform((data) { + final birthYear = data['birthYear'] as int; + final age = DateTime.now().year - birthYear; + return {...data, 'age': age}; + }); final result = personSchema.safeParse({ 'name': 'Taylor', diff --git a/packages/ack/test/documentation/social_media_demos_test.dart b/packages/ack/test/documentation/social_media_demos_test.dart index 3f397d0f..5c4c887b 100644 --- a/packages/ack/test/documentation/social_media_demos_test.dart +++ b/packages/ack/test/documentation/social_media_demos_test.dart @@ -14,9 +14,10 @@ void main() { return {...data, 'age': age}; }); - final result = - userSchema.parse({'name': 'John Doe', 'birthYear': 1990}) - as Map; + final result = userSchema.parse({ + 'name': 'John Doe', + 'birthYear': 1990, + }) as Map; expect(result['name'], equals('John Doe')); expect(result['birthYear'], equals(1990)); diff --git a/packages/ack/test/integration/discriminated_child_transform_test.dart b/packages/ack/test/integration/discriminated_child_transform_test.dart index 03996a17..bd4857e4 100644 --- a/packages/ack/test/integration/discriminated_child_transform_test.dart +++ b/packages/ack/test/integration/discriminated_child_transform_test.dart @@ -129,13 +129,11 @@ void main() { test('invalid discriminator fails before branch transform runs', () { var transformCalled = false; final catSchema = - Ack.object({ - 'type': Ack.literal('cat'), - 'name': Ack.string(), - }).transform((map) { - transformCalled = true; - return Cat(map['name'] as String); - }); + Ack.object({'type': Ack.literal('cat'), 'name': Ack.string()}) + .transform((map) { + transformCalled = true; + return Cat(map['name'] as String); + }); final schema = Ack.discriminated( discriminatorKey: 'type', @@ -151,13 +149,11 @@ void main() { test('invalid branch payload fails before transform output', () { var transformCalled = false; final catSchema = - Ack.object({ - 'type': Ack.literal('cat'), - 'name': Ack.string(), - }).transform((map) { - transformCalled = true; - return Cat(map['name'] as String); - }); + Ack.object({'type': Ack.literal('cat'), 'name': Ack.string()}) + .transform((map) { + transformCalled = true; + return Cat(map['name'] as String); + }); final schema = Ack.discriminated( discriminatorKey: 'type', @@ -173,12 +169,10 @@ void main() { test('branch transform exceptions wrapped as SchemaTransformError', () { final catSchema = - Ack.object({ - 'type': Ack.literal('cat'), - 'name': Ack.string(), - }).transform((map) { - throw FormatException('bad data'); - }); + Ack.object({'type': Ack.literal('cat'), 'name': Ack.string()}) + .transform((map) { + throw FormatException('bad data'); + }); final schema = Ack.discriminated( discriminatorKey: 'type', diff --git a/packages/ack/test/json_schema_conformance_test.dart b/packages/ack/test/json_schema_conformance_test.dart index 70bc5462..8917fd5f 100644 --- a/packages/ack/test/json_schema_conformance_test.dart +++ b/packages/ack/test/json_schema_conformance_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; + import 'package:ack/ack.dart'; import 'package:test/test.dart'; @@ -361,9 +362,8 @@ void main() { }); test('list-with-description', () { - final ackSchema = Ack.list( - Ack.string(), - ).describe('A list of string values'); + final ackSchema = Ack.list(Ack.string()) + .describe('A list of string values'); final ackOutput = ackSchema.toJsonSchema(); final zodReference = loadReferenceFixture('list-with-description'); expect(ackOutput, equals(zodReference)); @@ -531,10 +531,8 @@ void main() { }); test('anyof-with-description', () { - final ackSchema = Ack.anyOf([ - Ack.string(), - Ack.boolean(), - ]).describe('Either a string or boolean value'); + final ackSchema = Ack.anyOf([Ack.string(), Ack.boolean()]) + .describe('Either a string or boolean value'); final ackOutput = ackSchema.toJsonSchema(); final zodReference = loadReferenceFixture('anyof-with-description'); expect(ackOutput, equals(zodReference)); @@ -677,18 +675,16 @@ void main() { }); test('enum-with-default', () { - final ackSchema = Ack.enumValues( - UserRole.values, - ).withDefault(UserRole.user); + final ackSchema = Ack.enumValues(UserRole.values) + .withDefault(UserRole.user); final ackOutput = ackSchema.toJsonSchema(); final zodReference = loadReferenceFixture('enum-with-default'); expect(ackOutput, equals(zodReference)); }); test('enum-with-description', () { - final ackSchema = Ack.enumValues( - Status.values, - ).describe('Status of the entity'); + final ackSchema = Ack.enumValues(Status.values) + .describe('Status of the entity'); final ackOutput = ackSchema.toJsonSchema(); final zodReference = loadReferenceFixture('enum-with-description'); expect(ackOutput, equals(zodReference)); @@ -809,11 +805,11 @@ Map _canonicalAnySchema(Map schema) { 'object', 'array', ]) - {'type': type, if (description != null) 'description': description}, + {'type': type, 'description': ?description}, ]; return { - if (description != null) 'description': description, + 'description': ?description, if (schema.containsKey('default')) 'default': schema['default'], 'anyOf': nullable ? [ diff --git a/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart b/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart index 72e4c742..cd233803 100644 --- a/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart +++ b/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart @@ -13,14 +13,9 @@ void main() { 'unevaluatedProperties.json', 'unevaluatedItems.json', ]) - for (final group - in (jsonDecode( - File( - '$fixtureRoot/draft2020-12/$filename', - ).readAsStringSync(), - ) - as List) - .cast>()) + for (final group in (jsonDecode( + File('$fixtureRoot/draft2020-12/$filename').readAsStringSync(), + ) as List).cast>()) if (filename == 'dynamicRef.json' || (group['description'] as String).contains(r'$dynamicRef')) {...group, 'description': '$filename: ${group['description']}'}, diff --git a/packages/ack/test/json_schema_format_conformance_test.dart b/packages/ack/test/json_schema_format_conformance_test.dart index bebdfbf4..25cc7bd9 100644 --- a/packages/ack/test/json_schema_format_conformance_test.dart +++ b/packages/ack/test/json_schema_format_conformance_test.dart @@ -52,22 +52,16 @@ void main() { }); group('official 2020-12 format-assertion vocabulary', () { - final groups = - (jsonDecode( - File( - '$root/optional/format-assertion.json', - ).readAsStringSync(), - ) - as List) - .cast>(); + final groups = (jsonDecode( + File('$root/optional/format-assertion.json').readAsStringSync(), + ) as List).cast>(); final documents = { for (final value in ['true', 'false']) Uri.parse( 'http://localhost:1234/draft2020-12/format-assertion-$value.json', ): jsonDecode( - File( - '$root/../remotes/draft2020-12/format-assertion-$value.json', - ).readAsStringSync(), + File('$root/../remotes/draft2020-12/format-assertion-$value.json') + .readAsStringSync(), )!, }; for (final group in groups) { diff --git a/packages/ack/test/json_schema_import_conformance_test.dart b/packages/ack/test/json_schema_import_conformance_test.dart index 698fabfc..d0831b28 100644 --- a/packages/ack/test/json_schema_import_conformance_test.dart +++ b/packages/ack/test/json_schema_import_conformance_test.dart @@ -5,13 +5,10 @@ import 'package:ack/ack.dart'; import 'package:test/test.dart'; void main() { - final groups = - jsonDecode( - File( - 'test/fixtures/json_schema_import/draft2020-12.json', - ).readAsStringSync(), - ) - as List; + final groups = jsonDecode( + File('test/fixtures/json_schema_import/draft2020-12.json') + .readAsStringSync(), + ) as List; group('official draft 2020-12 supported subset', () { for (final group in groups.cast>()) { diff --git a/packages/ack/test/json_schema_import_public_api_test.dart b/packages/ack/test/json_schema_import_public_api_test.dart index f3ccf798..253988ce 100644 --- a/packages/ack/test/json_schema_import_public_api_test.dart +++ b/packages/ack/test/json_schema_import_public_api_test.dart @@ -8,9 +8,8 @@ import 'package:test/test.dart'; Future _compileFixture(String name) { final temporary = Directory.systemTemp.createTempSync('ack-import-api-'); addTearDown(() => temporary.deleteSync(recursive: true)); - final source = File( - 'test/fixtures/public_api/$name.dart.txt', - ).copySync('${temporary.path}/consumer.dart'); + final source = File('test/fixtures/public_api/$name.dart.txt') + .copySync('${temporary.path}/consumer.dart'); return Process.run(Platform.resolvedExecutable, [ 'compile', diff --git a/packages/ack/test/json_schema_import_test.dart b/packages/ack/test/json_schema_import_test.dart index bac3b1f8..bfec1b3e 100644 --- a/packages/ack/test/json_schema_import_test.dart +++ b/packages/ack/test/json_schema_import_test.dart @@ -214,9 +214,9 @@ void main() { expect(schema.safeParse({'text': 'Hello'}).isOk, isTrue); expect(schema.safeParse({'text': 1}).isFail, isTrue); expect( - Ack.fromJsonSchema( - schema.toJsonSchema(), - ).safeParse({'text': 1}).isFail, + Ack.fromJsonSchema(schema.toJsonSchema()) + .safeParse({'text': 1}) + .isFail, isTrue, ); }, @@ -1107,79 +1107,73 @@ void main() { }, ); - test( - 'supports Draft-07 dialect URIs under the supported subset and round-trips format assertions', - () { - final schema = Ack.fromJsonSchema({ - r'$schema': 'http://json-schema.org/draft-07/schema#', - 'type': 'object', - 'properties': { - 'email': {'type': 'string', 'format': 'email'}, - }, - 'unevaluatedProperties': false, - }, assertFormats: true); + test('supports Draft-07 dialect URIs under the supported subset and round-trips format assertions', () { + final schema = Ack.fromJsonSchema({ + r'$schema': 'http://json-schema.org/draft-07/schema#', + 'type': 'object', + 'properties': { + 'email': {'type': 'string', 'format': 'email'}, + }, + 'unevaluatedProperties': false, + }, assertFormats: true); - expect(schema.safeParse({'email': 'alice@example.com'}).isOk, isTrue); - expect( - schema.safeParse({'email': 'alice@example.com', 'extra': 123}).isFail, - isTrue, - ); - expect(schema.safeParse({'email': 'invalid'}).isFail, isTrue); + expect(schema.safeParse({'email': 'alice@example.com'}).isOk, isTrue); + expect( + schema.safeParse({'email': 'alice@example.com', 'extra': 123}).isFail, + isTrue, + ); + expect(schema.safeParse({'email': 'invalid'}).isFail, isTrue); - final roundTripped = Ack.fromJsonSchema(schema.toJsonSchema()); - expect( - roundTripped.safeParse({'email': 'alice@example.com'}).isOk, - isTrue, - ); - expect( - roundTripped.safeParse({ - 'email': 'alice@example.com', - 'extra': 123, - }).isFail, - isTrue, - ); - expect(roundTripped.safeParse({'email': 'invalid'}).isFail, isTrue); - }, - ); + final roundTripped = Ack.fromJsonSchema(schema.toJsonSchema()); + expect( + roundTripped.safeParse({'email': 'alice@example.com'}).isOk, + isTrue, + ); + expect( + roundTripped.safeParse({ + 'email': 'alice@example.com', + 'extra': 123, + }).isFail, + isTrue, + ); + expect(roundTripped.safeParse({'email': 'invalid'}).isFail, isTrue); + }); - test( - 'ImportedJsonSchema equality and hashCode include baseUri and external documents', - () { - final docA = Ack.fromJsonSchema( - {r'$ref': 'other.json'}, - baseUri: Uri.parse('https://example.test/a/root.json'), - documents: { - Uri.parse('https://example.test/a/other.json'): {'type': 'string'}, - }, - ); - final docB = Ack.fromJsonSchema( - {r'$ref': 'other.json'}, - baseUri: Uri.parse('https://example.test/a/root.json'), - documents: { - Uri.parse('https://example.test/a/other.json'): {'type': 'integer'}, - }, - ); - final docSameAsA = Ack.fromJsonSchema( - {r'$ref': 'other.json'}, - baseUri: Uri.parse('https://example.test/a/root.json'), - documents: { - Uri.parse('https://example.test/a/other.json'): {'type': 'string'}, - }, - ); - final docDifferentBase = Ack.fromJsonSchema( - {r'$ref': 'other.json'}, - baseUri: Uri.parse('https://example.test/b/root.json'), - documents: { - Uri.parse('https://example.test/b/other.json'): {'type': 'string'}, - }, - ); + test('ImportedJsonSchema equality and hashCode include baseUri and external documents', () { + final docA = Ack.fromJsonSchema( + {r'$ref': 'other.json'}, + baseUri: Uri.parse('https://example.test/a/root.json'), + documents: { + Uri.parse('https://example.test/a/other.json'): {'type': 'string'}, + }, + ); + final docB = Ack.fromJsonSchema( + {r'$ref': 'other.json'}, + baseUri: Uri.parse('https://example.test/a/root.json'), + documents: { + Uri.parse('https://example.test/a/other.json'): {'type': 'integer'}, + }, + ); + final docSameAsA = Ack.fromJsonSchema( + {r'$ref': 'other.json'}, + baseUri: Uri.parse('https://example.test/a/root.json'), + documents: { + Uri.parse('https://example.test/a/other.json'): {'type': 'string'}, + }, + ); + final docDifferentBase = Ack.fromJsonSchema( + {r'$ref': 'other.json'}, + baseUri: Uri.parse('https://example.test/b/root.json'), + documents: { + Uri.parse('https://example.test/b/other.json'): {'type': 'string'}, + }, + ); - expect(docA, equals(docSameAsA)); - expect(docA.hashCode, equals(docSameAsA.hashCode)); - expect(docA, isNot(equals(docB))); - expect(docA, isNot(equals(docDifferentBase))); - }, - ); + expect(docA, equals(docSameAsA)); + expect(docA.hashCode, equals(docSameAsA.hashCode)); + expect(docA, isNot(equals(docB))); + expect(docA, isNot(equals(docDifferentBase))); + }); test( 'custom vocabularies can disable applicator and unevaluated keywords', @@ -1240,54 +1234,48 @@ void main() { }, ); - test( - 'export rewrites references targeting the root retrieval URI when root declares a different \$id', - () { - final schema = Ack.fromJsonSchema({ - r'$id': 'https://example.test/canonical/schema.json', - r'$defs': { - 'Value': {'type': 'string'}, - }, - r'$ref': 'https://example.test/retrieval/root.json#/\$defs/Value', - }, baseUri: Uri.parse('https://example.test/retrieval/root.json')); - - final exported = schema.toJsonSchema(); - expect( - exported[r'$ref'], - 'https://example.test/canonical/schema.json#/\$defs/Value', - ); - final reimported = Ack.fromJsonSchema(exported); - expect(reimported.safeParse('hello').isOk, isTrue); - expect(reimported.safeParse(42).isFail, isTrue); - }, - ); + test('export rewrites references targeting the root retrieval URI when root declares a different \$id', () { + final schema = Ack.fromJsonSchema({ + r'$id': 'https://example.test/canonical/schema.json', + r'$defs': { + 'Value': {'type': 'string'}, + }, + r'$ref': 'https://example.test/retrieval/root.json#/\$defs/Value', + }, baseUri: Uri.parse('https://example.test/retrieval/root.json')); - test( - 'assertFormats validates numeric formats on numbers and ignores them on strings', - () { - final int32Schema = Ack.fromJsonSchema({ - 'format': 'int32', - }, assertFormats: true); - expect(int32Schema.safeParse(2147483647).isOk, isTrue); - expect(int32Schema.safeParse(-2147483648).isOk, isTrue); - expect(int32Schema.safeParse(2147483648).isFail, isTrue); - expect(int32Schema.safeParse(1.5).isFail, isTrue); - expect(int32Schema.safeParse('not-a-number').isOk, isTrue); - - final uint64Schema = Ack.fromJsonSchema({ - 'format': 'uint64', - }, assertFormats: true); - expect(uint64Schema.safeParse(0).isOk, isTrue); - expect(uint64Schema.safeParse(-1).isFail, isTrue); - expect(uint64Schema.safeParse(18446744073709551616.0).isFail, isTrue); + final exported = schema.toJsonSchema(); + expect( + exported[r'$ref'], + 'https://example.test/canonical/schema.json#/\$defs/Value', + ); + final reimported = Ack.fromJsonSchema(exported); + expect(reimported.safeParse('hello').isOk, isTrue); + expect(reimported.safeParse(42).isFail, isTrue); + }); - final floatSchema = Ack.fromJsonSchema({ - 'format': 'float', - }, assertFormats: true); - expect(floatSchema.safeParse(1.5).isOk, isTrue); - expect(floatSchema.safeParse(1e40).isFail, isTrue); - }, - ); + test('assertFormats validates numeric formats on numbers and ignores them on strings', () { + final int32Schema = Ack.fromJsonSchema({ + 'format': 'int32', + }, assertFormats: true); + expect(int32Schema.safeParse(2147483647).isOk, isTrue); + expect(int32Schema.safeParse(-2147483648).isOk, isTrue); + expect(int32Schema.safeParse(2147483648).isFail, isTrue); + expect(int32Schema.safeParse(1.5).isFail, isTrue); + expect(int32Schema.safeParse('not-a-number').isOk, isTrue); + + final uint64Schema = Ack.fromJsonSchema({ + 'format': 'uint64', + }, assertFormats: true); + expect(uint64Schema.safeParse(0).isOk, isTrue); + expect(uint64Schema.safeParse(-1).isFail, isTrue); + expect(uint64Schema.safeParse(18446744073709551616.0).isFail, isTrue); + + final floatSchema = Ack.fromJsonSchema({ + 'format': 'float', + }, assertFormats: true); + expect(floatSchema.safeParse(1.5).isOk, isTrue); + expect(floatSchema.safeParse(1e40).isFail, isTrue); + }); test( 'date-time format assertion enforces RFC 3339 leap-second calendar dates', @@ -1304,36 +1292,33 @@ void main() { }, ); - test( - 'nullable imported root relocates root pointers inside referenced extension containers and ignores dormant malformed refs', - () { - final schema = Ack.fromJsonSchema({ - 'type': 'object', - 'properties': { - 'value': {'type': 'string'}, - 'viaExtension': {r'$ref': '#/x-ext/target'}, - }, - 'x-ext': { - 'target': {r'$ref': '#/properties/value'}, - }, - r'$defs': { - 'Dormant': {r'$ref': '#/%FF'}, - }, - }).nullable(); + test('nullable imported root relocates root pointers inside referenced extension containers and ignores dormant malformed refs', () { + final schema = Ack.fromJsonSchema({ + 'type': 'object', + 'properties': { + 'value': {'type': 'string'}, + 'viaExtension': {r'$ref': '#/x-ext/target'}, + }, + 'x-ext': { + 'target': {r'$ref': '#/properties/value'}, + }, + r'$defs': { + 'Dormant': {r'$ref': '#/%FF'}, + }, + }).nullable(); - final exported = schema.toJsonSchema(); - final reimported = Ack.fromJsonSchema(exported); - expect(reimported.safeParse(null).isOk, isTrue); - expect( - reimported.safeParse({'value': 'a', 'viaExtension': 'b'}).isOk, - isTrue, - ); - expect( - reimported.safeParse({'value': 'a', 'viaExtension': 123}).isFail, - isTrue, - ); - }, - ); + final exported = schema.toJsonSchema(); + final reimported = Ack.fromJsonSchema(exported); + expect(reimported.safeParse(null).isOk, isTrue); + expect( + reimported.safeParse({'value': 'a', 'viaExtension': 'b'}).isOk, + isTrue, + ); + expect( + reimported.safeParse({'value': 'a', 'viaExtension': 123}).isFail, + isTrue, + ); + }); }); } diff --git a/packages/ack/test/polish_test.dart b/packages/ack/test/polish_test.dart index 5566ee6b..66e188dc 100644 --- a/packages/ack/test/polish_test.dart +++ b/packages/ack/test/polish_test.dart @@ -85,9 +85,8 @@ void main() { }); test('DefaultSchema emits JSON-safe nested map default', () { - final schema = Ack.object({ - 'name': Ack.string(), - }).withDefault({'name': 'guest'}); + final schema = Ack.object({'name': Ack.string()}) + .withDefault({'name': 'guest'}); final json = schema.toJsonSchema(); expect(json['default'], {'name': 'guest'}); }); @@ -145,9 +144,8 @@ void main() { }); test('DefaultSchema default survives a jsonEncode round-trip', () { - final schema = Ack.object({ - 'name': Ack.string(), - }).withDefault({'name': 'guest'}); + final schema = Ack.object({'name': Ack.string()}) + .withDefault({'name': 'guest'}); final json = schema.toJsonSchema(); // Should not throw. final encoded = jsonEncode(json); diff --git a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart index d5514d39..0b1b941c 100644 --- a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart @@ -77,9 +77,8 @@ void main() { .transform((value) => int.parse(value)) .withDefault(7); final constrained = Ack.integer().min(10).withDefault(5); - final invalidEnum = Ack.enumValues([ - _Role.admin, - ]).withDefault(_Role.member); + final invalidEnum = Ack.enumValues([_Role.admin]) + .withDefault(_Role.member); expect(transformed.toJsonSchema(), isNot(contains('default'))); expect(constrained.toJsonSchema(), isNot(contains('default'))); @@ -175,64 +174,55 @@ void main() { }, ); - test( - 'uses existing composition models for Draft-7 lowered imported JSON Schema', - () { - final schema = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); + test('uses existing composition models for Draft-7 lowered imported JSON Schema', () { + final schema = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); - final model = schema.toSchemaModelDraft7(); + final model = schema.toSchemaModelDraft7(); - expect(model, isA()); - final allOf = model as AckAllOfSchemaModel; - expect(allOf.schemas, hasLength(1)); - expect(allOf.schemas.single, isA()); - expect(model.toJsonSchema(), schema.toJsonSchemaDraft7()); - }, - ); + expect(model, isA()); + final allOf = model as AckAllOfSchemaModel; + expect(allOf.schemas, hasLength(1)); + expect(allOf.schemas.single, isA()); + expect(model.toJsonSchema(), schema.toJsonSchemaDraft7()); + }); - test( - 'rejects an imported definition reused as a lazy target in Draft-7 lowering', - () { - final imported = Ack.fromJsonSchema(true).nullable(value: false); - final schema = Ack.object({ - 'a': imported, - 'b': Ack.lazy('_ack_import_0_0', () => imported), - }); + test('rejects an imported definition reused as a lazy target in Draft-7 lowering', () { + final imported = Ack.fromJsonSchema(true).nullable(value: false); + final schema = Ack.object({ + 'a': imported, + 'b': Ack.lazy('_ack_import_0_0', () => imported), + }); - expect( - schema.toJsonSchemaDraft7, - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('collides with an imported definition'), - ), + expect( + schema.toJsonSchemaDraft7, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('collides with an imported definition'), ), - ); - }, - ); + ), + ); + }); - test( - 'rejects an import generated name occupied by an earlier lazy in Draft-7 lowering', - () { - final imported = Ack.fromJsonSchema(true); - final schema = Ack.object({ - 'a': Ack.lazy('_ack_import_0_0', Ack.string), - 'b': imported, - }); + test('rejects an import generated name occupied by an earlier lazy in Draft-7 lowering', () { + final imported = Ack.fromJsonSchema(true); + final schema = Ack.object({ + 'a': Ack.lazy('_ack_import_0_0', Ack.string), + 'b': imported, + }); - expect( - schema.toJsonSchemaDraft7, - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Imported definition collides with'), - ), + expect( + schema.toJsonSchemaDraft7, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Imported definition collides with'), ), - ); - }, - ); + ), + ); + }); test('rejects nullable list item schemas at construction', () { expect( @@ -335,9 +325,8 @@ void main() { final schema = Ack.discriminated( discriminatorKey: 'type', schemas: { - 'cat': Ack.object({ - 'name': Ack.string(), - }).transform((map) => map), + 'cat': Ack.object({'name': Ack.string()}) + .transform((map) => map), }, ); @@ -452,9 +441,8 @@ void main() { .minLength(2) .withDefault('ack') .toSchemaModel(); - final boundary = Ack.preserveBoundary( - Ack.string().minLength(2), - ).toSchemaModel(); + final boundary = Ack.preserveBoundary(Ack.string().minLength(2)) + .toSchemaModel(); expect(withDefault.toJsonSchema(), { 'type': 'string', @@ -479,9 +467,9 @@ void main() { expect(_keywordValues(model.toJsonSchema(), 'minimum'), isEmpty); } expect( - Ack.object({ - 't': Ack.duration().min(const Duration(seconds: 5)), - }).toSchemaModel().toJsonSchema(), + Ack.object({'t': Ack.duration().min(const Duration(seconds: 5))}) + .toSchemaModel() + .toJsonSchema(), containsPair( 'properties', containsPair('t', containsPair('minimum', 5000)), @@ -502,10 +490,8 @@ void main() { final boolean = Ack.boolean().withConstraint( const _TestSchemaModelConstraint(), ); - final anyOf = Ack.anyOf([ - Ack.string(), - Ack.integer(), - ]).withConstraint(const _TestSchemaModelConstraint()); + final anyOf = Ack.anyOf([Ack.string(), Ack.integer()]) + .withConstraint(const _TestSchemaModelConstraint()); expect(boolean.toSchemaModel().extensions['x-test-marker'], isTrue); expect(anyOf.toSchemaModel().extensions['x-test-marker'], isTrue); diff --git a/packages/ack/test/schemas/any_of_null_and_default_test.dart b/packages/ack/test/schemas/any_of_null_and_default_test.dart index d9976595..2d434b5e 100644 --- a/packages/ack/test/schemas/any_of_null_and_default_test.dart +++ b/packages/ack/test/schemas/any_of_null_and_default_test.dart @@ -44,10 +44,8 @@ void main() { 'should prefer member schema null acceptance over AnyOfSchema nullable', () { // When a member schema is nullable, it should match first - final schema = Ack.anyOf([ - Ack.string().nullable(), - Ack.integer(), - ]).nullable(); // AnyOfSchema is also nullable + final schema = Ack.anyOf([Ack.string().nullable(), Ack.integer()]) + .nullable(); // AnyOfSchema is also nullable final result = schema.safeParse(null); expect(result.isOk, isTrue); @@ -117,10 +115,8 @@ void main() { group('AnyOf defaults', () { test('should apply anyOf default when input is null', () { const defaultValue = 'default string'; - final schema = Ack.anyOf([ - Ack.string(), - Ack.integer(), - ]).withDefault(defaultValue); + final schema = Ack.anyOf([Ack.string(), Ack.integer()]) + .withDefault(defaultValue); final result = schema.safeParse(null); expect(result.isOk, isTrue); @@ -129,10 +125,8 @@ void main() { test('should prefer default over nullable member schema', () { const defaultValue = 'default string'; - final schema = Ack.anyOf([ - Ack.string().nullable(), - Ack.integer(), - ]).withDefault(defaultValue); + final schema = Ack.anyOf([Ack.string().nullable(), Ack.integer()]) + .withDefault(defaultValue); final result = schema.safeParse(null); expect(result.isOk, isTrue); @@ -155,10 +149,8 @@ void main() { test('should fail when default is invalid for all member schemas', () { // Use a list as default - neither integer nor double accept lists const defaultValue = [1, 2, 3]; - final schema = Ack.anyOf([ - Ack.integer(), - Ack.double(), - ]).withDefault(defaultValue); + final schema = Ack.anyOf([Ack.integer(), Ack.double()]) + .withDefault(defaultValue); final result = schema.safeParse(null); expect(result.isFail, isTrue); diff --git a/packages/ack/test/schemas/codec_can_encode_test.dart b/packages/ack/test/schemas/codec_can_encode_test.dart index aae71669..eab8e501 100644 --- a/packages/ack/test/schemas/codec_can_encode_test.dart +++ b/packages/ack/test/schemas/codec_can_encode_test.dart @@ -4,9 +4,8 @@ import 'package:test/test.dart'; void main() { group('CodecSchema.canEncode', () { test('is false for a transform, which has no encoder', () { - final schema = Ack.object({ - 'id': Ack.string(), - }).transform((value) => value['id']! as String); + final schema = Ack.object({'id': Ack.string()}) + .transform((value) => value['id']! as String); expect(schema.canEncode, isFalse); expect(schema.safeEncode('a').isFail, isTrue); diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index c932f1d9..921c54ec 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -417,9 +417,8 @@ void main() { final defaultOnlySchema = Ack.discriminated>( discriminatorKey: 'type', schemas: { - 'cat': Ack.object({ - 'name': Ack.string(), - }).withDefault(const {'name': 'Default'}), + 'cat': Ack.object({'name': Ack.string()}) + .withDefault(const {'name': 'Default'}), }, ); diff --git a/packages/ack/test/schemas/extensions/list_schema_extensions_test.dart b/packages/ack/test/schemas/extensions/list_schema_extensions_test.dart index 2ffaf2df..fc34efcd 100644 --- a/packages/ack/test/schemas/extensions/list_schema_extensions_test.dart +++ b/packages/ack/test/schemas/extensions/list_schema_extensions_test.dart @@ -146,9 +146,8 @@ void main() { }); test('should handle nested structures', () { - final schema = Ack.list( - Ack.object({'items': Ack.list(Ack.integer())}), - ).unique(); + final schema = Ack.list(Ack.object({'items': Ack.list(Ack.integer())})) + .unique(); final result = schema.safeParse([ { diff --git a/packages/ack/test/schemas/extensions/string_transformers_test.dart b/packages/ack/test/schemas/extensions/string_transformers_test.dart index 655dcc47..946318af 100644 --- a/packages/ack/test/schemas/extensions/string_transformers_test.dart +++ b/packages/ack/test/schemas/extensions/string_transformers_test.dart @@ -327,20 +327,17 @@ void main() { }); group('Two-way StringSchema normalizers (C3)', () { - test( - 'trim, toLowerCase, and toUpperCase return StringSchema and chain directly', - () { - final StringSchema schema = Ack.string() - .trim() - .toLowerCase() - .minLength(3) - .email(); + test('trim, toLowerCase, and toUpperCase return StringSchema and chain directly', () { + final StringSchema schema = Ack.string() + .trim() + .toLowerCase() + .minLength(3) + .email(); - expect(schema.parse(' USER@EXAMPLE.COM '), 'user@example.com'); - expect(schema.safeParse(' a ').isFail, isTrue); - expect(schema.safeParse(' not-an-email ').isFail, isTrue); - }, - ); + expect(schema.parse(' USER@EXAMPLE.COM '), 'user@example.com'); + expect(schema.safeParse(' a ').isFail, isTrue); + expect(schema.safeParse(' not-an-email ').isFail, isTrue); + }); test( 'declaration order is preserved between normalizers and constraints', diff --git a/packages/ack/test/schemas/map_schema_test.dart b/packages/ack/test/schemas/map_schema_test.dart index 47ff96f4..01194392 100644 --- a/packages/ack/test/schemas/map_schema_test.dart +++ b/packages/ack/test/schemas/map_schema_test.dart @@ -133,9 +133,8 @@ void main() { }); test('applies refinements to the whole map', () { - final schema = Ack.map( - Ack.string(), - ).refine((map) => map.length <= 1, message: 'At most one entry.'); + final schema = Ack.map(Ack.string()) + .refine((map) => map.length <= 1, message: 'At most one entry.'); expect(schema.safeParse({'a': 'x'}).isOk, isTrue); expect(schema.safeParse({'a': 'x', 'b': 'y'}).isFail, isTrue); @@ -147,9 +146,9 @@ void main() { expect(Ack.map(Ack.string()), isNot(Ack.map(Ack.string().nullable()))); expect(Ack.map(Ack.string()), isNot(Ack.map(Ack.string()).nullable())); - final copied = Ack.map( - Ack.string(), - ).describe('Labels').copyWith(description: 'Tags'); + final copied = Ack.map(Ack.string()) + .describe('Labels') + .copyWith(description: 'Tags'); expect(copied.description, 'Tags'); expect(copied.valueSchema, Ack.string()); diff --git a/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart b/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart index 2350427d..320011e5 100644 --- a/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart +++ b/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart @@ -102,10 +102,8 @@ void main() { group('AnyOfSchema', () { test('non-nullable merges constraints in JSON Schema', () { - final schema = Ack.anyOf([ - Ack.string(), - Ack.integer(), - ]).withConstraint(const _TestAckSchemaModelConstraint()); + final schema = Ack.anyOf([Ack.string(), Ack.integer()]) + .withConstraint(const _TestAckSchemaModelConstraint()); final jsonSchema = schema.toJsonSchema(); @@ -115,10 +113,9 @@ void main() { }); test('nullable merges constraints at composition level', () { - final schema = Ack.anyOf([ - Ack.string(), - Ack.integer(), - ]).nullable().withConstraint(const _TestAckSchemaModelConstraint()); + final schema = Ack.anyOf([Ack.string(), Ack.integer()]) + .nullable() + .withConstraint(const _TestAckSchemaModelConstraint()); final jsonSchema = schema.toJsonSchema(); diff --git a/packages/ack/test/schemas/optional_nullable_semantics_test.dart b/packages/ack/test/schemas/optional_nullable_semantics_test.dart index ff2ec30a..475fa8d5 100644 --- a/packages/ack/test/schemas/optional_nullable_semantics_test.dart +++ b/packages/ack/test/schemas/optional_nullable_semantics_test.dart @@ -252,8 +252,7 @@ void main() { expect( ageType, equals('integer'), - reason: - 'Optional field should NOT include "null" unless also marked nullable', + reason: 'Optional field should NOT include "null" unless also marked nullable', ); // But it should NOT be in the required array @@ -272,8 +271,7 @@ void main() { expect( jsonSchema['type'], equals('string'), - reason: - 'Optional schema should NOT include null type unless also nullable', + reason: 'Optional schema should NOT include null type unless also nullable', ); }); @@ -366,21 +364,18 @@ void main() { ); }); - test( - 'should return SchemaConstraintsError for direct constraint violation', - () { - final schema = Ack.string().minLength(5); + test('should return SchemaConstraintsError for direct constraint violation', () { + final schema = Ack.string().minLength(5); - final result = schema.safeParse('hi'); + final result = schema.safeParse('hi'); - expect(result.isFail, isTrue); - expect( - result.getError(), - isA(), - reason: - 'Direct constraint violation should return SchemaConstraintsError', - ); - }, - ); + expect(result.isFail, isTrue); + expect( + result.getError(), + isA(), + reason: + 'Direct constraint violation should return SchemaConstraintsError', + ); + }); }); } diff --git a/packages/ack/test/schemas/parse_result_immutability_test.dart b/packages/ack/test/schemas/parse_result_immutability_test.dart index c20afd8d..5eef762a 100644 --- a/packages/ack/test/schemas/parse_result_immutability_test.dart +++ b/packages/ack/test/schemas/parse_result_immutability_test.dart @@ -218,9 +218,8 @@ void main() { }); test('list refinement works on unmodifiable list', () { - final schema = Ack.list( - Ack.string(), - ).refine((list) => list.isNotEmpty, message: 'must not be empty'); + final schema = Ack.list(Ack.string()) + .refine((list) => list.isNotEmpty, message: 'must not be empty'); final result = schema.safeParse(['a', 'b', 'c']); expect(result.isOk, isTrue); diff --git a/packages/ack_firebase_ai/README.md b/packages/ack_firebase_ai/README.md index 458a184c..8e7239bd 100644 --- a/packages/ack_firebase_ai/README.md +++ b/packages/ack_firebase_ai/README.md @@ -192,5 +192,5 @@ dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart From the workspace root, the same generator is available through Melos: ```bash -dart run melos run firebase-ai-fixtures +dart pub global run melos run firebase-ai-fixtures ``` diff --git a/packages/ack_firebase_ai/analysis_options.yaml b/packages/ack_firebase_ai/analysis_options.yaml index 72cd0fb5..f1f1ae59 100644 --- a/packages/ack_firebase_ai/analysis_options.yaml +++ b/packages/ack_firebase_ai/analysis_options.yaml @@ -3,6 +3,8 @@ include: ../../analysis_options.yaml analyzer: + exclude: + - build/** language: # This package enforces stricter typing than the workspace default. strict-casts: true diff --git a/packages/ack_firebase_ai/pubspec.yaml b/packages/ack_firebase_ai/pubspec.yaml index 91f21d18..3645f622 100644 --- a/packages/ack_firebase_ai/pubspec.yaml +++ b/packages/ack_firebase_ai/pubspec.yaml @@ -12,8 +12,8 @@ topics: - validation environment: - sdk: '>=3.11.0 <4.0.0' - flutter: '>=3.41.2' + sdk: '>=3.14.0-315.1.beta <4.0.0' + flutter: '>=3.50.0-0.1.pre' dependencies: @@ -30,5 +30,5 @@ dev_dependencies: firebase_core_platform_interface: 7.1.0 flutter_test: sdk: flutter - lints: ^5.0.0 + lints: ^6.1.0 test: ^1.25.15 diff --git a/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart b/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart index 6a270a83..ff579023 100644 --- a/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart +++ b/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart @@ -147,9 +147,9 @@ List<_LiveFirebaseAiSchemaCase> _liveFirebaseAiSchemaCases() => [ name: 'any JSON-compatible payload', schema: Ack.object({ 'id': Ack.string().uuid(), - 'labels': Ack.list( - Ack.enumString(['alpha', 'beta', 'stable']), - ).minLength(2).maxLength(2), + 'labels': Ack.list(Ack.enumString(['alpha', 'beta', 'stable'])) + .minLength(2) + .maxLength(2), 'payload': Ack.any(), }, additionalProperties: true).describe('Flexible event response'), prompt: diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart index c2cf081c..92900eb4 100644 --- a/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart +++ b/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart @@ -51,10 +51,9 @@ final class FirebaseAiNativeSchemaCase { required this.source, required this.features, required this.comparisonEnum, - required Map Function() buildJsonSchema, + required Map Function() this._buildJsonSchema, }) : statusEnum = FirebaseAiNativeSchemaStatus.generated, - unsupportedReason = null, - _buildJsonSchema = buildJsonSchema; + unsupportedReason = null; const FirebaseAiNativeSchemaCase.unsupported({ required this.id, @@ -124,9 +123,8 @@ String firebaseAiPackageVersion() { break; } - final match = RegExp( - r'^\s+version:\s+"?([^"\s]+)"?\s*$', - ).firstMatch(line); + final match = RegExp(r'^\s+version:\s+"?([^"\s]+)"?\s*$') + .firstMatch(line); if (match != null) return match.group(1)!; } } diff --git a/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart index 7b3e8ba5..d57ad042 100644 --- a/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart +++ b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart @@ -112,8 +112,8 @@ final class _FirebaseAiNativeSchemaFixtures { required this.fixtureCount, required this.featureCoverage, required this.fixtureDirectory, - required Map fixtures, - }) : _fixtures = fixtures; + required this._fixtures, + }); final String sourcePackage; final String sourceVersion; diff --git a/packages/ack_firebase_ai/test/to_firebase_ai_response_json_schema_test.dart b/packages/ack_firebase_ai/test/to_firebase_ai_response_json_schema_test.dart index 0cd48261..87e7694c 100644 --- a/packages/ack_firebase_ai/test/to_firebase_ai_response_json_schema_test.dart +++ b/packages/ack_firebase_ai/test/to_firebase_ai_response_json_schema_test.dart @@ -93,8 +93,8 @@ final class _FirebaseAiResponseJsonSchemaFixtures { required this.ids, required this.fixtureCount, required this.featureCoverage, - required Map fixtures, - }) : _fixtures = fixtures; + required this._fixtures, + }); final List ids; final int fixtureCount; diff --git a/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart b/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart index 05fd11ce..c7477ce9 100644 --- a/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart +++ b/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart @@ -31,9 +31,8 @@ void _writeAckAdapterFixtures() { for (final schemaCase in cases) { final fixtureName = '${schemaCase.id}.json'; final fixturePath = '$_fixtureDirectory/$fixtureName'; - File( - fixturePath, - ).writeAsStringSync('${_prettyJson(schemaCase.buildJsonSchema())}\n'); + File(fixturePath) + .writeAsStringSync('${_prettyJson(schemaCase.buildJsonSchema())}\n'); manifestFixtures.add({ 'id': schemaCase.id, @@ -87,9 +86,8 @@ void _writeNativeFixtures(FirebaseAiNativeSchemaFixtureFamily family) { if (schemaCase.isGenerated) { fixtureName = '${schemaCase.id}.json'; final fixturePath = '$fixtureDirectory/$fixtureName'; - File( - fixturePath, - ).writeAsStringSync('${_prettyJson(schemaCase.buildJsonSchema())}\n'); + File(fixturePath) + .writeAsStringSync('${_prettyJson(schemaCase.buildJsonSchema())}\n'); } manifestFixtures.add({ @@ -99,7 +97,7 @@ void _writeNativeFixtures(FirebaseAiNativeSchemaFixtureFamily family) { 'features': schemaCase.features, 'status': schemaCase.status, 'comparison': schemaCase.comparison, - if (fixtureName != null) 'fixture': fixtureName, + 'fixture': ?fixtureName, if (schemaCase.unsupportedReason != null) 'unsupportedReason': schemaCase.unsupportedReason, }); diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index b65d3b97..e662db25 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -2,6 +2,14 @@ ### Breaking +* Target Dart 3.14 / Flutter 3.50; development is pinned to + Dart `3.14.0-315.1.beta` / Flutter `3.50.0-0.1.pre` until stable releases exist. +* Class-first models use experimental augmentations instead of `_$ModelAck` + mixins. Remove mixins and handwritten schema/parsing forwarders; generation adds + `fromJson`, `parse`, `safeParse`, and `schema` directly. Both primary and + conventional constructors remain supported. Enable `augmentations` in + analyzer, build_runner, and runtime/test commands. + * Requires `ack: ^2.0.0-alpha.1`. * **Single-pass `.g.dart` builder (`#189`)**: `ack_generator:ack_generator` is now a `SharedPartBuilder` that emits `.g.dart` directly with generated @@ -19,8 +27,7 @@ * Support `@Validate.*` presence and constraint annotations (`@Validate.optional()`, `@Validate.required()`, `@Validate.notNull()`, `@Validate.notEmpty()` on `String`/`List`/`Set`, etc.) from `package:ack/ack.dart` across field - declarations and primary/generative constructor parameters, as well as - `static final schema = _Schema;` on class-first models (`#191`). + declarations and primary/generative constructor parameters (`#191`). * Support `JsonMaybe` fields in `@Schemable()` class-first models for PATCH tri-state presence (`#148`). * Support `@AckKey('...')` directly without `package:json_annotation` (`#189`). @@ -40,6 +47,10 @@ ### Fixed +* Preserve child `super` parameter validation alongside annotations on inherited + primary fields, without applying direct primary parameter constraints twice. +* Support fields named `dynamic` or `override` in augmentations, and ignore + inaccessible private superclass members when checking generated-name conflicts. * Preserve integral decimal length and item bounds in JSON Schema generation. Use validated value models for Unicode regex patterns and named scalar unions that the typed model generator cannot represent faithfully. diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index abf20830..1f896d29 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -47,7 +47,7 @@ targets: # "https://example.test/common.json": "lib/common.json" ``` -Run `dart run build_runner build`. Import `contract.schema.dart` to use the +Run `dart run build_runner build --enable-experiment=augmentations`. Import `contract.schema.dart` to use the generated models. A typed model also writes `contract.schema.g.dart` and offers typed fields and `copyWith`. A validated value model needs no parts: `User.parse(value)` returns a wrapper whose immutable `value` is the validated @@ -90,6 +90,34 @@ differ for astral Unicode characters (code points in the importer versus UTF-16 units in the fluent schema). Set `model_mode: validated` when these edge cases matter; typed output retains the original v1 contract for compatibility. +## Dart 3.14 prerelease setup + +This Ack 2.0 development branch is pinned to **Flutter 3.50.0-0.1.pre**, +which bundles **Dart 3.14.0-315.1.beta**. The stable 3.50.0 / 3.14.0 releases +are not available yet. Use `sdk: '>=3.14.0-315.1.beta <4.0.0'` for this +prerelease; the stable target is `sdk: '>=3.14.0 <4.0.0'` once released. +Primary constructors are enabled by default. Augmentations remain experimental +and must be enabled in consumer projects: + +```yaml title="analysis_options.yaml" +analyzer: + enable-experiment: + - augmentations +``` + +```bash +dart run build_runner build --enable-experiment=augmentations +dart --enable-experiment=augmentations test +# For a Dart application: +dart --enable-experiment=augmentations run bin/main.dart +# For a Flutter application: +flutter run --enable-experiment=augmentations +``` + +`@Schemable()` classes use ordinary `part '.g.dart';` directives. +Generated `augment final class` / `augment sealed class` declarations add +members to the source class; they do not change its primary constructor. + ## Schema-first usage ```dart @@ -104,7 +132,7 @@ final userSchema = Ack.object({ }); ``` -Run `dart run build_runner build`. A declaration ending in `Schema` loses that +Run `dart run build_runner build --enable-experiment=augmentations`. A declaration ending in `Schema` loses that suffix, so `userSchema` generates `User`: ```dart @@ -142,8 +170,8 @@ names must be unchanged UpperCamelCase identifiers. ## Class-first usage -In a class-first library, keep the model in source (using a standard or Dart 3.12+ -primary constructor) and apply the generated mixin: +In a class-first library, keep the model in source (using a standard or Dart 3.14 +primary constructor). Augmentations add the generated API directly: ```dart import 'package:ack/ack.dart'; @@ -151,15 +179,9 @@ import 'package:ack/ack.dart'; part 'account.g.dart'; @Schemable() -final class Account with _$AccountAck { - const Account({required this.name}); - - @Validate.minLength(2) - final String name; - - static final schema = _accountSchema; - static final fromJson = AccountSchema.fromJson; -} +final class const Account({ + @Validate.minLength(2) required final String name, +}); ``` After generation, `Account.schema`, the public `AccountSchema` facade, and model @@ -191,8 +213,8 @@ or `Command.schema()`. A `schema()` that declares one positional `AckSchema` parameter per type parameter `A` receives each type argument's inferred schema, as in `Box.schema(RowSchema.schema)`. An enum with a static `schema` or a `@Schemable(schemas: [...])` entry resolves to it -instead of `Ack.enumValues`. A class may also expose its own generated codec -with `static final schema = _accountSchema;` (or `AccountSchema.schema`). +instead of `Ack.enumValues`. Annotated classes expose their generated codec +automatically as `Account.schema`. Use `@Schemable(description: ...)` for class descriptions and a single-line `@description` tag in a `///` or `/** */` doc comment on a class, field, or @@ -303,14 +325,16 @@ means a JSON-safe value, not an arbitrary Dart instance: For a hand-written `Account`, class-first generation exposes an `AccountSchema` facade backed by a private `_accountSchema` codec. The facade provides parsing, safe parsing, encoding, JSON Schema/schema-model export, -typed `schema`, and raw `wireSchema`. Instantiable models apply the generated -`_$AccountAck` mixin, which supplies `toJson`, `safeToJson`, a typed `copyWith`, -and deep collection-aware equality. Add `static final schema = _accountSchema;` -and `static final fromJson = AccountSchema.fromJson;` when the class should -expose the schema and one-argument JSON constructor directly. Imported nested -models compose as `prefix.AddressSchema.schema` (or `prefix.Address.schema` when -`Address.schema` is declared and `AddressSchema` is hidden by a combinator). -Across all imports and barrel exports, `show`/`hide` combinators must expose the -authored declaration and either `Address.schema` or its generated companion -(`AddressSchema` for class-first, or `Address` for schema-first). Visibility may -be split across multiple imports that use the same prefix. +typed `schema`, and raw `wireSchema`. Class augmentations supply a `fromJson` +factory, static `parse` / `safeParse` / `schema`, `toJson`, `safeToJson`, typed +`copyWith`, and deep collection-aware equality. No generated mixin is needed. Remove handwritten static forwarders before +generation. Ack owns the +generated names; colliding members and import prefixes receive actionable +diagnostics. Generic annotated classes and generic union branches are unsupported. +External domain types may still provide their own static schema. + +Imported nested models compose as `prefix.Address.schema`; `show`/`hide` +combinators and barrel exports only need to expose `Address`. Explicit facade +references also require `AddressSchema` to be visible. For schema-first types, +expose the annotated schema and its generated model. Visibility may be split +across multiple imports that use the same prefix. diff --git a/packages/ack_generator/example/example.dart b/packages/ack_generator/example/example.dart index c951a237..cf0e555f 100644 --- a/packages/ack_generator/example/example.dart +++ b/packages/ack_generator/example/example.dart @@ -10,7 +10,7 @@ // Then run: // // ```bash -// dart run build_runner build --delete-conflicting-outputs +// dart run build_runner build --enable-experiment=augmentations // ``` // // Add `ack` to `dependencies`, and `ack_generator` plus `build_runner` to @@ -36,8 +36,8 @@ final settingsSchema = Ack.object({ }); /// Class-first. `@Schemable()` runs the other direction: it derives a validated -/// schema from this hand-written class, which applies the generated -/// `_$ProductAck` mixin once the parts are in place. +/// schema from this hand-written class. The generated part augments `Product` +/// with parsing, JSON, and equality members. @Schemable(caseStyle: AckCaseStyle.snake) final class Product { const Product({required this.displayName, required this.priceCents}); diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 71b23021..ce298d5c 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -37,6 +37,7 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { r'$ack', 'parse', 'safeParse', + 'schema', 'fromJson', 'toJson', 'safeToJson', @@ -49,6 +50,13 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { static const _generatedSerializationMembers = {'toJson', 'safeToJson'}; + static const _generatedParsingMembers = { + 'fromJson', + 'parse', + 'safeParse', + 'schema', + }; + static const _generatedValueMembers = { 'copyWith', 'toString', @@ -142,22 +150,28 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { generatedOwners[name] = element; } + for (final name in ackClassScopeTypeNames) { + claim(name, graph.nodes.first); + } for (final node in graph.nodes) { final metadata = graph.classMetadataFor(node.id); if (metadata == null) { throw StateError('Missing class-first metadata for ${node.id}.'); } claim(metadata.facadeName, node); + claim(ackClassSchemaAliasName(node.className), node); claim(metadata.backingName, node); claim(ackClassWireSchemaName(node.className), node); - claim(ackClassMixinName(node.className), node); claim(ackClassRawObjectName(node.className), node); if (node is! AckObjectModelNode) continue; claim(ackClassFromRuntimeName(node.className), node); + claim(ackClassEqualsName(node.className), node); + claim(ackClassHashName(node.className), node); claim(ackClassToRuntimeName(node.className), node); if (node.fields.any((field) => field.nullable || !field.isRequired)) { claim(ackCopyWithUnsetTypeName(node.className), node); } + claim(ackClassCopyWithAliasName(node.className), node); claim(ackCopyWithInterfaceName(node.className), node); claim(ackCopyWithImplementationName(node.className), node); } @@ -183,7 +197,6 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { ); } _rejectRedirectingFactory(element); - _validateStaticSchema(element); _requireFinalConcreteClass(element); if (_jsonSerializableChecker.hasAnnotationOfExact(element)) { throw InvalidGenerationSource( @@ -217,30 +230,6 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { } } - /// A model may expose its facade as `static final schema = XSchema.schema;`. - /// - /// The facade is generated in this build, so the type is checked only once - /// it resolves. - void _validateStaticSchema(ClassElement element) { - final getter = element.getGetter('schema'); - if (getter == null || - !getter.isStatic || - containsInvalidType(getter.returnType)) { - return; - } - final runtime = ackSchemaRuntimeType(getter.returnType); - if (runtime != null && - sameTypeIgnoringNullability(runtime, element.thisType)) { - return; - } - throw InvalidGenerationSource( - '${element.name}.schema must be an AckSchema, ' - '${element.name}>; it produces ' - '${runtime?.getDisplayString() ?? 'an untyped value'}.', - element: getter, - ); - } - /// json_serializable applies a `JsonConverter` before Ack's runtime bridge, /// so the JSON part would skip the schema for that field. void _rejectJsonConverters( @@ -285,7 +274,6 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { } Future _buildUnion(ClassElement base) async { - _requireMixin(base); _rejectGeneratedMemberCollisions(base, includeValueMembers: false); final baseOptions = _options(base)!; final discriminatorKey = baseOptions.discriminatorKey!; @@ -427,7 +415,6 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { element: element, ); } - _requireMixin(element); _rejectGeneratedMemberCollisions(element, includeValueMembers: true); final fields = _instanceFields(element); @@ -853,14 +840,10 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { FormalParameterElement? parameter, ) { final effectiveType = unwrapJsonMaybeType(field.type); - if (field.isOriginDeclaringFormalParameter) { - final target = field.metadata.annotations.isNotEmpty - ? field - : (parameter ?? field); - return _inference.applyConstraints(schema, target, effectiveType); - } var output = _inference.applyConstraints(schema, field, effectiveType); - if (parameter != null && !identical(parameter, field)) { + // Primary fields expose their declaring parameter's metadata already. + // A child's super parameter is a distinct declaration with its own metadata. + if (parameter != null && parameter != field.declaringFormalParameter) { output = _inference.applyConstraints(output, parameter, effectiveType); } return output; @@ -887,7 +870,7 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { if (direct != null) return direct; final resolved = inputResolved; if (resolved == null) return null; - for (final element in [field, if (parameter != null) parameter]) { + for (final element in [field, ?parameter]) { final node = declarationNode(resolved, element); if (node is FormalParameter) { final comment = node.documentationComment; @@ -1035,6 +1018,28 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { } void _requireFinalConcreteClass(ClassElement element) { + final declaration = declarationNode(inputResolved!, element); + final legacyMixin = '_\$${element.name}Ack'; + if (declaration is ClassDeclaration && + (declaration.withClause?.mixinTypes.any( + (type) => type.toSource() == legacyMixin, + ) ?? + false)) { + throw InvalidGenerationSource( + '${element.name} uses the removed generated mixin $legacyMixin. ' + 'Remove the with clause; @Schemable now adds its API through augmentations.', + element: element, + ); + } + + if (element.typeParameters.isNotEmpty) { + throw InvalidGenerationSource( + '${element.name} is generic. @Schemable class-first models must not ' + 'declare type parameters.', + element: element, + ); + } + if (element.isAbstract || element.isSealed || element.isFinal) return; throw InvalidGenerationSource( '${element.name} must be declared as a final class because ' @@ -1174,27 +1179,11 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { ); } - void _requireMixin(ClassElement element) { - final mixinName = ackClassMixinName(element.name!); - if (_declaresMixin(element, mixinName)) return; - throw InvalidGenerationSource( - '${element.name} must apply mixin $mixinName.', - element: element, - todo: 'Add `with $mixinName` to ${element.name}.', - ); - } - - bool _declaresMixin(ClassElement element, String mixinName) { - if (element.mixins.any((type) => type.element.name == mixinName)) { - return true; - } - final resolved = inputResolved; - if (resolved == null) return false; - final node = declarationNode(resolved, element); - if (node is! ClassDeclaration) return false; - final withClause = node.withClause; - if (withClause == null) return false; - return withClause.mixinTypes.any((type) => type.name.lexeme == mixinName); + bool _isGeneratedAugmentationMember(Element element) { + final fragment = element.firstFragment; + return fragment.enclosingFragment is ClassFragment && + (fragment.enclosingFragment as ClassFragment).isAugmentation && + fragment.libraryFragment?.source.uri.path.endsWith('.g.dart') == true; } void _rejectGeneratedMemberCollisions( @@ -1203,24 +1192,82 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { }) { final blocked = { ..._generatedSerializationMembers, + ..._generatedParsingMembers, + ...ackClassScopeTypeNames, + ackClassSchemaAliasName(element.name!), + ackClassCopyWithAliasName(element.name!), + ackCopyWithImplementationName(element.name!), + if (includeValueMembers) ...{ + ackClassEqualsName(element.name!), + ackClassHashName(element.name!), + }, if (includeValueMembers) ..._generatedValueMembers, }; + for (final import in library.element.firstFragment.libraryImports) { + final prefix = import.prefix?.element.name; + if (blocked.contains(prefix)) { + throw InvalidGenerationSource( + 'Import prefix "$prefix" conflicts with a generated member on ' + '${element.name}. Rename the import prefix.', + element: element, + ); + } + } + for (final setter in element.setters) { + if (!_isGeneratedAugmentationMember(setter) && + blocked.contains(setter.name)) { + throw InvalidGenerationSource( + '${element.name}.${setter.name} conflicts with a generated member.', + element: setter, + ); + } + } + for (final parent in element.allSupertypes) { + if (isCoreType(parent, 'Object')) continue; + for (final member in [ + ...parent.element.methods, + ...parent.element.getters, + ...parent.element.setters, + ]) { + if (!member.isStatic && + member.isAccessibleIn(library.element) && + !_isGeneratedAugmentationMember(member) && + blocked.contains(member.name)) { + throw InvalidGenerationSource( + '${element.name} inherits ${parent.element.name}.${member.name}, ' + 'which conflicts with a generated member.', + element: element, + ); + } + } + } + for (final constructor in element.constructors) { + if (!_isGeneratedAugmentationMember(constructor) && + _generatedParsingMembers.contains(constructor.name)) { + throw InvalidGenerationSource( + '${element.name}.${constructor.name} conflicts with a generated member.', + element: constructor, + ); + } + } for (final method in element.methods) { - if (method.isStatic) continue; final name = method.name; - if (name != null && blocked.contains(name)) { + if (!_isGeneratedAugmentationMember(method) && + name != null && + blocked.contains(name)) { throw InvalidGenerationSource( - '${element.name}.$name would silently override a generated member.', + '${element.name}.$name conflicts with a generated member.', element: method, ); } } for (final getter in element.getters) { - if (getter.isStatic) continue; final name = getter.name; - if (name != null && blocked.contains(name)) { + if (!_isGeneratedAugmentationMember(getter) && + name != null && + blocked.contains(name)) { throw InvalidGenerationSource( - '${element.name}.$name would silently override a generated member.', + '${element.name}.$name conflicts with a generated member.', element: getter, ); } diff --git a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart index 4f7a270e..5e53316f 100644 --- a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -267,12 +267,12 @@ final class CustomSchemaResolver { final fieldOwner = field.enclosingElement; for (final scope in [ if (activeClass != null) ...[ - if (_classSchemas[activeClass] case final classList?) classList, + ?_classSchemas[activeClass], if (unionBaseByBranch[activeClass] case final base?) - if (_classSchemas[base] case final baseList?) baseList, + ?_classSchemas[base], ], if (fieldOwner is ClassElement && fieldOwner != activeClass) - if (_classSchemas[fieldOwner] case final ownerList?) ownerList, + ?_classSchemas[fieldOwner], _librarySchemas, ]) { for (final entry in scope) { @@ -515,33 +515,8 @@ final class CustomSchemaResolver { } final target = interfaceType.element; final path = _fieldPath(field); - if (target is ClassElement) { - final facadeName = _base.classFirstFacadeName(target); - if (facadeName != null) { - final prefix = _base.visiblePrefixFor( - target, - path: path, - context: field, - ); - if (target.library != _library.element && - !_isClassFirstFacadeVisible(target, facadeName, prefix: prefix)) { - final typeOwned = await typeOwnedSchema( - interfaceType, - field, - activeClass: activeClass, - schemaForType: schemaForType, - typeArgumentRef: typeArgumentRef, - ); - if (typeOwned != null) return typeOwned; - } - _validateClassFirstFacadeImport( - target, - facadeName, - prefix: prefix, - field: field, - ); - return '${prefix == null ? '' : '$prefix.'}$facadeName.schema'; - } + if (target is ClassElement && _base.isClassFirstModel(target)) { + return '${_base.visibleTypeNameFor(interfaceType, path: path, context: field)}.schema'; } if (schemaFirstRuntimeRef(target, field, typeRef: typeRef) != null) { final visibleName = _base.visibleTypeNameFor( @@ -679,10 +654,6 @@ final class CustomSchemaResolver { return null; } if (_base.containsInvalidType(returnType)) { - if (target is ClassElement && - _base.classFirstFacadeName(target) != null) { - return expression; - } throw InvalidGenerationSource( '$path resolves to ${target.name}.schema, whose type does not resolve ' 'yet. Declare the type of ${target.name}.schema explicitly.', @@ -929,43 +900,6 @@ final class CustomSchemaResolver { return matches.single; } - bool _isClassFirstFacadeVisible( - ClassElement target, - String facadeName, { - required String? prefix, - }) { - final visibility = _base.checkCompanionVisibility( - target, - facadeName, - prefix: prefix, - ); - return visibility.declarationVisible && visibility.companionVisible; - } - - void _validateClassFirstFacadeImport( - ClassElement target, - String facadeName, { - required String? prefix, - required FieldElement field, - }) { - final visibility = _base.checkCompanionVisibility( - target, - facadeName, - prefix: prefix, - ); - if (visibility.declarationVisible && visibility.companionVisible) return; - if (visibility.declarationVisible) { - final modelName = target.name!; - throw InvalidGenerationSource( - 'Generated class-first facade "$facadeName" is hidden by an import ' - 'combinator or an upstream export combinator for $modelName. Expose ' - 'both through the full import route (for example, show $modelName, ' - '$facadeName).', - element: field, - ); - } - } - String _fieldPath(FieldElement field) => '${field.enclosingElement.name}.${field.name}'; } diff --git a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart index 1dc8a126..7f942f5f 100644 --- a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart +++ b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart @@ -218,6 +218,10 @@ abstract base class ModelGraphBuilderBase { : null; } + /// Class-first models own their `.schema` member, including before parts exist. + bool isClassFirstModel(ClassElement element) => + classFirstFacadeName(element) != null; + /// The boundary and runtime type arguments of [type] as an /// `AckSchema`. ({DartType boundary, DartType runtime})? ackSchemaTypeArguments( @@ -552,7 +556,7 @@ abstract base class ModelGraphBuilderBase { } } - /// Resolves a `?.schema` reference to the underlying + /// Resolves a `?.schema` reference to the underlying /// class-first `@Schemable` model class, if any. AckExternalTypeRef? resolveClassFirstModelReference( Expression expression, @@ -572,13 +576,18 @@ abstract base class ModelGraphBuilderBase { void consider(ClassElement element) { final generatedFacade = classFirstFacadeName(element); - if (generatedFacade != facadeName) return; + if (generatedFacade == null || + (generatedFacade != facadeName && element.name != facadeName)) { + return; + } candidates.add(element); } if (prefix == null) { for (final element in library.element.classes) { - if (classFirstFacadeName(element) == facadeName) { + if (isClassFirstModel(element) && + (element.name == facadeName || + classFirstFacadeName(element) == facadeName)) { matches.add(element); } } @@ -604,13 +613,15 @@ abstract base class ModelGraphBuilderBase { } } for (final target in candidates) { - final facadeVisible = imports.any( - (import) => importExposesGeneratedCompanion( - import, - definingLibrary: target.library, - generatedName: facadeName, - ), - ); + final facadeVisible = + target.name == facadeName || + imports.any( + (import) => importExposesGeneratedCompanion( + import, + definingLibrary: target.library, + generatedName: facadeName, + ), + ); if (visibleModels.contains(target) && facadeVisible) { matches.add(target); } else { @@ -640,8 +651,7 @@ abstract base class ModelGraphBuilderBase { /// Names of all top-level declarations in the current [library]. Set localDeclarationNames() => { - for (final element in library.allElements) - if (element.name case final name?) name, + for (final element in library.allElements) ?element.name, }; /// Validates that generated `copyWith` helper types for [className] do not diff --git a/packages/ack_generator/lib/src/builder.dart b/packages/ack_generator/lib/src/builder.dart index b4ae06a4..69eb5d57 100644 --- a/packages/ack_generator/lib/src/builder.dart +++ b/packages/ack_generator/lib/src/builder.dart @@ -1,4 +1,9 @@ +import 'package:analyzer/dart/analysis/features.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; import 'package:build/build.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:source_gen/source_gen.dart'; import 'json_schema/json_schema_builder.dart'; @@ -7,12 +12,65 @@ import 'model_generator.dart'; /// Creates the shared `.g.dart` part builder for `@Schemable` models. Builder ackGenerator(BuilderOptions options) { - return SharedPartBuilder([AckModelGenerator()], 'ack'); + return SharedPartBuilder( + [AckModelGenerator()], + 'ack', + formatOutput: _formatOutput, + ); } /// Creates a direct `.g.dart` part builder for single-step unit tests. Builder ackModelBuilder(BuilderOptions options) { - return PartBuilder([AckModelGenerator()], '.g.dart', options: options); + return PartBuilder( + [AckModelGenerator()], + '.g.dart', + options: options, + formatOutput: _formatOutput, + ); +} + +// dart_style 3.1.14 parses augmentations but its class visitor drops `augment`. +// Temporarily remove only AST-identified declaration tokens, then restore them +// at the formatted declaration offsets. String literals and comments are never +// interpreted as declarations or changed by this workaround. +String _formatOutput(String code, Version languageVersion) { + final features = FeatureSet.fromEnableFlags2( + sdkLanguageVersion: languageVersion, + flags: const ['augmentations'], + ); + List classes(String source) => parseString( + content: source, + featureSet: features, + ).unit.declarations.whereType().toList(); + final declarations = classes(code); + final names = {}; + var plainCode = code; + for (final declaration in declarations.reversed) { + final token = declaration.augmentKeyword; + if (token == null) continue; + names.add(declaration.namePart.typeName.lexeme); + plainCode = plainCode.replaceRange(token.offset, token.end, ''); + } + var formatted = DartFormatter( + languageVersion: languageVersion, + experimentFlags: const ['augmentations'], + ).format(plainCode); + for (final declaration in classes(formatted).reversed) { + final name = declaration.namePart.typeName.lexeme; + if (!names.contains(name)) continue; + final modifier = declaration.finalKeyword ?? declaration.sealedKeyword; + if (modifier == null) { + throw FormatException( + 'Augmented class $name is neither final nor sealed.', + ); + } + formatted = formatted.replaceRange( + modifier.offset, + modifier.offset, + 'augment ', + ); + } + return formatted; } /// Creates the opt-in builder that generates `@Schemable` schema declarations diff --git a/packages/ack_generator/lib/src/builders/class_model_emitter.dart b/packages/ack_generator/lib/src/builders/class_model_emitter.dart index 3f00fcb4..28ab83ac 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -14,13 +14,27 @@ final class AckClassModelEmitter { String emit(AckModelGraph graph) { final nodes = {for (final node in graph.nodes) node.id: node}; - final output = StringBuffer(); + final output = StringBuffer(''' +// Private aliases preserve public underlying types while avoiding model member +// shadowing. Their visibility is intentional; no private runtime type escapes. +// ignore_for_file: library_private_types_in_public_api + +typedef _AckObject = Object; +typedef _AckDynamic = dynamic; +typedef _AckString = String; +typedef _AckMap = Map; +typedef _AckBool = bool; +typedef _AckInt = int; +typedef _AckSchema = ${_ack('AckSchema')}; +typedef _AckSchemaResult = ${_ack('SchemaResult')}; + +'''); for (final node in graph.nodes) { switch (node) { case AckObjectModelNode(:final unionId) when unionId != null: continue; case AckObjectModelNode(): - _emitObject(output, graph, node); + _emitConcrete(output, graph, node); case AckValueModelNode(): throw StateError('Class-first graphs cannot contain value roots.'); case AckUnionModelNode(): @@ -31,7 +45,7 @@ final class AckClassModelEmitter { 'Class-first union branch $branchId is not an object.', ); } - _emitBranch(output, graph, branch); + _emitConcrete(output, graph, branch); } _emitUnion(output, graph, node, nodes); } @@ -39,28 +53,11 @@ final class AckClassModelEmitter { return output.toString(); } - void _emitObject( - StringBuffer output, - AckModelGraph graph, - AckObjectModelNode node, - ) { - _emitConcrete(output, graph, node, includeValueMembers: true); - } - - void _emitBranch( + void _emitConcrete( StringBuffer output, AckModelGraph graph, AckObjectModelNode node, ) { - _emitConcrete(output, graph, node, includeValueMembers: true); - } - - void _emitConcrete( - StringBuffer output, - AckModelGraph graph, - AckObjectModelNode node, { - required bool includeValueMembers, - }) { final metadata = _metadata(graph, node); final rawName = ackClassRawObjectName(node.className); final wireName = ackClassWireSchemaName(node.className); @@ -69,9 +66,11 @@ final class AckClassModelEmitter { ..writeln() ..writeln('final $wireName = ${_ack('Ack')}.preserveBoundary($rawName);') ..writeln() - ..writeln( - _codecSchema(node, backingName: metadata.backingName, input: rawName), - ) + ..writeln(''' +final ${metadata.backingName} = $rawName.codec<${node.className}>( + decode: ${ackClassFromRuntimeName(node.className)}, + encode: ${ackClassToRuntimeName(node.className)}, +)${_describe(node.description)};''') ..writeln() ..writeln(_facade(node.className, metadata, wireName: wireName)) ..writeln() @@ -80,12 +79,12 @@ final class AckClassModelEmitter { ..writeln(_toRuntimeFunction(node)) ..writeln() ..writeln( - _dataClasses.mixin( + _dataClasses.augmentation( className: node.className, facadeName: metadata.facadeName, fields: node.fields, constructorParameters: node.constructorParameters, - includeValueMembers: includeValueMembers, + includeValueMembers: true, captureFieldName: node.captureFieldName, copyWithSupertypes: metadata.copyWithSupertypes, ), @@ -167,7 +166,7 @@ final ${metadata.backingName} = $rawName.codec<${node.className}>( ..writeln(_facade(node.className, metadata, wireName: wireName)) ..writeln() ..writeln( - _dataClasses.mixin( + _dataClasses.augmentation( className: node.className, facadeName: metadata.facadeName, fields: const [], @@ -178,19 +177,6 @@ final ${metadata.backingName} = $rawName.codec<${node.className}>( ..writeln(); } - String _codecSchema( - AckObjectModelNode node, { - required String backingName, - String? input, - }) { - final expression = input ?? _objectSchema(node); - return ''' -final $backingName = $expression.codec<${node.className}>( - decode: ${ackClassFromRuntimeName(node.className)}, - encode: ${ackClassToRuntimeName(node.className)}, -)${_describe(node.description)};'''; - } - /// Explicit class descriptions describe the wire object and model codec. /// /// A codec does not inherit its input schema's description, so describing @@ -436,7 +422,7 @@ Map $function(${node.className} model) { Set _declaredKeys(AckObjectModelNode node) => { for (final field in node.fields) field.jsonKey, - if (node.discriminatorKey case final discriminator?) discriminator, + ?node.discriminatorKey, }; AckClassModelMetadata _metadata(AckModelGraph graph, AckModelNode node) { diff --git a/packages/ack_generator/lib/src/builders/data_class_emitter.dart b/packages/ack_generator/lib/src/builders/data_class_emitter.dart index 05955e9c..3369e233 100644 --- a/packages/ack_generator/lib/src/builders/data_class_emitter.dart +++ b/packages/ack_generator/lib/src/builders/data_class_emitter.dart @@ -40,8 +40,8 @@ final class AckDataClassEmitter { final String? ackPrefix; - /// Private mixin applied by a hand-written `@AckModel` class. - String mixin({ + /// Adds generated members directly to a hand-written `@Schemable` class. + String augmentation({ required String className, required String facadeName, required List fields, @@ -73,21 +73,59 @@ final class AckDataClassEmitter { positional: parameter.kind != AckConstructorParameterKind.named, ), ]; + final schemaApi = ackClassSchemaAliasName(className); + final copyWithApi = ackClassCopyWithAliasName(className); + final equalsHelper = ackClassEqualsName(className); + final hashHelper = ackClassHashName(className); final members = [ if (includeValueMembers) ...[ - copyWithGetter( - className: className, - hasParameters: copyWithParameters.isNotEmpty, - castSelf: true, - ), - equalityMembers(className: className, fields: stored, castSelf: true), - toStringMethod(className: className, fields: stored, castSelf: true), + '/// Creates a copy with selected fields replaced.\n' + '$copyWithApi<$className> get copyWith => ' + '${_copyWithCreation(className, hasParameters: copyWithParameters.isNotEmpty)};', + // Override annotations add no runtime semantics here, and a legal + // source field named `override` would shadow the annotation constant. + '_AckBool operator ==(_AckObject other) => $equalsHelper(this, other);' + '\n\n_AckInt get hashCode => $hashHelper(this);', + "_AckString toString() => '${_toStringPreview(className, stored)}';", ], - jsonMembers(className: className, facadeName: facadeName), + 'static _AckSchema<_AckMap<_AckString, _AckObject?>, $className> ' + 'get schema => $schemaApi.schema;', + ''' +/// Validates decoded JSON and creates a model. +factory $className.fromJson(_AckMap<_AckString, _AckDynamic> json) => + $schemaApi.fromJson(json); + +/// Validates an untrusted value and creates a model. +static $className parse(_AckObject? value, {_AckString? debugName}) => + $schemaApi.parse(value, debugName: debugName); + +/// Validates an untrusted value without throwing on validation failure. +static _AckSchemaResult<$className> safeParse( + _AckObject? value, {_AckString? debugName} +) => $schemaApi.safeParse(value, debugName: debugName); +''', + ''' +/// Validates this model and encodes it for JSON. +_AckMap<_AckString, _AckDynamic> toJson() => + _AckMap<_AckString, _AckDynamic>.from($schemaApi.encode(this)); + +/// Validates this model and encodes it without throwing on validation failure. +_AckSchemaResult<_AckMap<_AckString, _AckObject?>> safeToJson() => + $schemaApi.safeEncode(this); +''', ]; return ''' -${includeValueMembers ? '${copyWithContract(className: className, parameters: copyWithParameters, supertypes: copyWithSupertypes)}\n\n' : ''}mixin ${'_\$${className}Ack'} { +typedef $schemaApi = $facadeName; +${includeValueMembers ? 'typedef $copyWithApi<\$Result> = ${ackCopyWithInterfaceName(className)}<\$Result>;' : ''} + +${includeValueMembers ? ''' +bool $equalsHelper($className self, Object other) => + ${_directEqualsExpression(className, stored, receiver: 'self')}; + +int $hashHelper($className self) => ${_hashExpression(stored, receiver: 'self')}; + +''' : ''}${includeValueMembers ? '${copyWithContract(className: className, parameters: copyWithParameters, supertypes: copyWithSupertypes)}\n\n' : ''}augment ${includeValueMembers ? 'final' : 'sealed'} class $className { ${members.join('\n\n ')} }'''; } @@ -190,6 +228,8 @@ ${includeValueMembers ? '${copyWithContract(className: className, parameters: co : ''; final sentinelField = needsSentinel ? ' static const $sentinelType $_copyWithUnset = $sentinelType();\n\n' + ' static bool _ackIsCopyWithUnset(Object? value) =>\n' + ' identical(value, $_copyWithUnset);\n\n' : ''; final sourceField = hasSource ? ' final $className _source;\n\n' : ''; return ''' @@ -211,121 +251,31 @@ $sentinelField$sourceField @override }'''; } - /// The `copyWith` getter that exposes [copyWithContract]'s interface. - String copyWithGetter({ - required String className, - required bool hasParameters, - bool castSelf = false, - }) { - final creation = _copyWithCreation( - className, - hasParameters: hasParameters, - castSelf: castSelf, - ); - return ''' -/// Creates a copy of this model with selected fields replaced. -${ackCopyWithInterfaceName(className)}<$className> get copyWith => $creation;'''; - } - - String equalityMembers({ - required String className, - required List fields, - bool castSelf = false, - }) { - if (castSelf && fields.isNotEmpty) { - final fieldEquals = [ - for (final field in fields) - _equals('self.${field.dartName}', 'other.${field.dartName}'), - ]; - final equality = fieldEquals.isEmpty ? 'true' : fieldEquals.join(' && '); - final hashes = _hashExpression(fields, receiver: 'self'); - return ''' -@override -bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! $className || runtimeType != other.runtimeType) { - return false; - } - final self = this as $className; - return $equality; -} - -@override -int get hashCode { - final self = this as $className; - return $hashes; -}'''; - } - return ''' -@override -bool operator ==(Object other) => - ${_directEqualsExpression(className, fields)}; - -@override -int get hashCode => ${_hashExpression(fields)};'''; - } - - String toStringMethod({ - required String className, - required List fields, - bool castSelf = false, - }) { - if (castSelf && fields.isNotEmpty) { - final parts = [ - for (final field in fields) - '${field.dartName}: \${self.${field.dartName}}', - ]; - return ''' -@override -String toString() { - final self = this as $className; - return '$className(${parts.join(', ')})'; -}'''; - } - return ''' -@override -String toString() => '${_toStringPreview(className, fields)}';'''; - } - - String jsonMembers({required String className, required String facadeName}) { - return ''' -/// Validates this model and encodes it for JSON. -/// -/// Throws an `AckException` when validation fails. -Map toJson() => - Map.from($facadeName.encode(this as $className)); - -/// Validates this model and encodes it for JSON, returning the validation -/// failure instead of throwing. -${_ack('SchemaResult')}> safeToJson() => - $facadeName.safeEncode(this as $className);'''; - } - - String _copyWithCreation( - String className, { - required bool hasParameters, - bool castSelf = false, - }) { + String _copyWithCreation(String className, {required bool hasParameters}) { final implementationName = ackCopyWithImplementationName(className); return hasParameters - ? '$implementationName(${castSelf ? 'this as $className' : 'this'})' + ? '$implementationName(this)' : 'const $implementationName()'; } - String _directEqualsExpression(String className, List fields) { + String _directEqualsExpression( + String className, + List fields, { + String receiver = 'this', + }) { final comparisons = [ 'other is $className', - 'runtimeType == other.runtimeType', + '$receiver.runtimeType == other.runtimeType', for (final field in fields) - _equals(field.dartName, 'other.${field.dartName}'), + _equals('$receiver.${field.dartName}', 'other.${field.dartName}'), ]; - return 'identical(this, other) || (${comparisons.join(' && ')})'; + return 'identical($receiver, other) || (${comparisons.join(' && ')})'; } String _hashExpression(List fields, {String? receiver}) { final prefix = receiver == null ? '' : '$receiver.'; final hashes = [ - 'runtimeType', + '${prefix}runtimeType', for (final field in fields) _hash('$prefix${field.dartName}'), ]; return 'Object.hashAll([${hashes.join(', ')}])'; @@ -346,7 +296,7 @@ ${_ack('SchemaResult')}> safeToJson() => ? parameter.name : '${parameter.name} as ${parameter.type}'; final replacement = parameter.nullable - ? 'identical(${parameter.name}, $_copyWithUnset) ' + ? '_ackIsCopyWithUnset(${parameter.name}) ' '? _source.${parameter.fieldName} ' ': $value' : '${parameter.name} ?? _source.${parameter.fieldName}'; diff --git a/packages/ack_generator/lib/src/json/helper_names.dart b/packages/ack_generator/lib/src/json/helper_names.dart index a614a6bf..153f7009 100644 --- a/packages/ack_generator/lib/src/json/helper_names.dart +++ b/packages/ack_generator/lib/src/json/helper_names.dart @@ -1,6 +1,3 @@ -/// Generated mixin that exposes class-first JSON and value members. -String ackClassMixinName(String className) => '_\$${className}Ack'; - /// Public static facade generated for a class-first schema. String ackClassSchemaFacadeName(String className, {String? override}) => override ?? '${className}Schema'; @@ -44,3 +41,25 @@ String ackCopyWithInterfaceName(String className) => '\$${className}CopyWith'; /// Private implementation behind a generated model's `copyWith` getter. String ackCopyWithImplementationName(String className) => '_\$${className}CopyWith'; + +/// Private facade alias used from a model's scope without member shadowing. +String ackClassSchemaAliasName(String className) => '_\$${className}SchemaApi'; + +/// Library-scoped value operations, outside user model member scope. +String ackClassEqualsName(String className) => '_\$${className}Equals'; +String ackClassHashName(String className) => '_\$${className}HashCode'; + +/// Types used inside augmentations are private aliases so user fields cannot +/// shadow core type names or the Ack import prefix. +const ackClassScopeTypeNames = { + '_AckObject', + '_AckDynamic', + '_AckString', + '_AckMap', + '_AckBool', + '_AckInt', + '_AckSchema', + '_AckSchemaResult', +}; +String ackClassCopyWithAliasName(String className) => + '_\$${className}CopyWithApi'; diff --git a/packages/ack_generator/lib/src/model_generator.dart b/packages/ack_generator/lib/src/model_generator.dart index 3e35e817..dedc0489 100644 --- a/packages/ack_generator/lib/src/model_generator.dart +++ b/packages/ack_generator/lib/src/model_generator.dart @@ -136,9 +136,8 @@ final class AckModelGenerator extends Generator { } if (classFirst != null) { output.add( - AckClassModelEmitter( - ackPrefix: classFirst.ackPrefix, - ).emit(classFirst.graph), + AckClassModelEmitter(ackPrefix: classFirst.ackPrefix) + .emit(classFirst.graph), ); } final body = output.where((chunk) => chunk.trim().isNotEmpty).join('\n\n'); diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 15661fc3..bbe3aa6c 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -11,24 +11,26 @@ topics: - schema environment: - sdk: '>=3.11.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: # Core code generation dependencies - analyzer: ">=13.0.0 <14.0.0" - build: ^4.0.0 - source_gen: ^4.2.4 + analyzer: ">=14.3.0 <15.0.0" + # Stay on build 4 while json_serializable's interoperability fixture requires + # it; build_runner 2.17.1 and build_test 3.5.23 require build 5. + build: ^4.0.11 + source_gen: ^4.3.1 code_builder: ^4.10.0 - dart_style: ^3.1.0 + dart_style: ^3.1.14 pub_semver: ^2.1.4 - # Ack packages (versions are overridden locally by Melos) + # Workspace resolution selects the local Ack package during development. ack: ^2.0.0-alpha.1 dev_dependencies: - build_runner: ^2.1.7 - build_test: ^3.1.0 + build_runner: ^2.16.2 + build_test: ^3.5.21 json_annotation: ^4.12.0 json_serializable: ^6.14.1 # 6.14.1 does not compile against source_helper 1.4.0. The build fixtures @@ -38,4 +40,4 @@ dev_dependencies: test: ^1.25.15 path: ^1.9.0 # Code quality - lints: ^5.0.0 + lints: ^6.1.0 diff --git a/packages/ack_generator/test/analyzer_constraint_test.dart b/packages/ack_generator/test/analyzer_constraint_test.dart index 843b521b..b07c89d8 100644 --- a/packages/ack_generator/test/analyzer_constraint_test.dart +++ b/packages/ack_generator/test/analyzer_constraint_test.dart @@ -4,7 +4,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; void main() { - test('ack_generator pins Analyzer to the supported 13.x line', () { + test('ack_generator pins Analyzer to the supported 14.x line', () { final pubspec = File( p.join( Directory.current.path.endsWith('ack_generator') @@ -14,10 +14,10 @@ void main() { ), ).readAsStringSync(); - expect(pubspec, contains('analyzer: ">=13.0.0 <14.0.0"')); + expect(pubspec, contains('analyzer: ">=14.3.0 <15.0.0"')); }); - test('schema analysis uses only the supported Analyzer 10 AST shape', () { + test('schema analysis uses only the supported Analyzer 14 AST shape', () { final packageRoot = Directory.current.path.endsWith('ack_generator') ? Directory.current.path : p.join(Directory.current.path, 'packages', 'ack_generator'); diff --git a/packages/ack_generator/test/integration/ack_infer_contract_test.dart b/packages/ack_generator/test/integration/ack_infer_contract_test.dart index 0f5bdab6..8381d5d7 100644 --- a/packages/ack_generator/test/integration/ack_infer_contract_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_contract_test.dart @@ -35,9 +35,9 @@ const _schemaShorthand = r'static final schema = $ack.modelSchema;'; /// static `schema` shorthand. Matcher _schemaShorthandOn(String className, {required bool present}) => predicate((source) { - final start = RegExp( - 'class ${RegExp.escape(className)}\\b', - ).firstMatch(source)?.start; + final start = RegExp('class ${RegExp.escape(className)}\\b') + .firstMatch(source) + ?.start; if (start == null) return false; final end = source.indexOf('\n}\n', start); final body = source.substring(start, end == -1 ? null : end); diff --git a/packages/ack_generator/test/integration/ack_infer_data_class_test.dart b/packages/ack_generator/test/integration/ack_infer_data_class_test.dart index 3f81fa77..d4aa8b9f 100644 --- a/packages/ack_generator/test/integration/ack_infer_data_class_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_data_class_test.dart @@ -48,13 +48,13 @@ part 'model.g.dart'; void main() { test( - 'class-first mixin reconstructs positional and named parameters', + 'class-first augmentation reconstructs positional and named parameters', () async { await _build( ''' $_imports @Schemable() -final class Point with _\$PointAck { +final class Point { const Point(this.x, {required this.y, this.label = 'origin'}); final int x; final int y; @@ -64,8 +64,8 @@ final class Point with _\$PointAck { outputs: { 'test_pkg|lib/model.g.dart': decodedMatches( allOf([ - contains(r'mixin _$PointAck'), - contains(r'$PointCopyWith get copyWith'), + contains(r'augment final class Point'), + contains(r'_$PointCopyWithApi get copyWith'), contains('x ?? _source.x'), contains('y: y ?? _source.y'), contains('label: label ?? _source.label'), @@ -78,18 +78,20 @@ final class Point with _\$PointAck { }, ); - test('unknown-property modes emit reject, discard, and args capture', () async { - await _build( - ''' + test( + 'unknown-property modes emit reject, discard, and args capture', + () async { + await _build( + ''' $_imports @Schemable() -final class Strict with _\$StrictAck { +final class Strict { const Strict({required this.name}); final String name; } @Schemable(unknownProperties: AckUnknownPropertyPolicy.discard) -final class Loose with _\$LooseAck { +final class Loose { const Loose({required this.name}); final String name; } @@ -98,47 +100,48 @@ final class Loose with _\$LooseAck { unknownProperties: AckUnknownPropertyPolicy.capture, captureField: 'args', ) -final class Bag with _\$BagAck { +final class Bag { const Bag({required this.name, this.args = const {}}); final String name; final Map args; } ''', - outputs: { - 'test_pkg|lib/model.g.dart': decodedMatches( - allOf([ - contains('final _strictObject = Ack.object({'), - isNot( - contains( - "final _strictObject = Ack.object({'name': Ack.string()}, additionalProperties: true)", + outputs: { + 'test_pkg|lib/model.g.dart': decodedMatches( + allOf([ + contains('final _strictObject = Ack.object({'), + isNot( + contains( + "final _strictObject = Ack.object({'name': Ack.string()}, additionalProperties: true)", + ), ), - ), - contains('additionalProperties: true'), - contains('args: deepUnmodifiableJsonMap('), - contains('Map.fromEntries('), - contains('model.args.entries'), - contains('self.args'), - isNot(contains('_ackClassImmutableCopyValue')), - isNot(contains('_ackClassImmutableCopyMap')), - ]), - ), - }, - ); - }); + contains('additionalProperties: true'), + contains('args: deepUnmodifiableJsonMap('), + contains('Map.fromEntries('), + contains('model.args.entries'), + contains('self.args'), + isNot(contains('_ackClassImmutableCopyValue')), + isNot(contains('_ackClassImmutableCopyMap')), + ]), + ), + }, + ); + }, + ); test( - 'presence overrides and required mixin collisions are diagnosed', + 'presence overrides and generated member collisions are diagnosed', () async { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); User copyWith({String? name}) => User(name: name ?? this.name); final String name; } ''', - ['User.copyWith', 'override'], + ['User.copyWith', 'conflicts with a generated member'], ); }, ); @@ -150,19 +153,19 @@ final class User with _\$UserAck { ''' $_imports @Schemable() -final class User with _\$UserAck { +final class User { const User({this.nickname}); @Validate.required() final String? nickname; } @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet({required this.id}); final String id; } -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat({required super.id, required this.lives}); final int lives; } @@ -189,7 +192,7 @@ final class Cat extends Pet with _\$CatAck { ''' $_imports @Schemable() -final class Normalized with _\$NormalizedAck { +final class Normalized { const Normalized(String? value) : value = value ?? ''; @Validate.optional() @@ -203,7 +206,7 @@ final class Normalized with _\$NormalizedAck { contains("'value': Ack.string().optional()"), contains('Normalized call({Object? value = _ackCopyWithUnset})'), contains( - 'identical(value, _ackCopyWithUnset) ' + '_ackIsCopyWithUnset(value) ' '? _source.value ' ': value as String?', ), @@ -241,7 +244,7 @@ final userSchema = Ack.object({ '/// Creates a copy of this model with selected fields replaced.', ), contains('User call({'), - contains('deepEquals(name, other.name)'), + contains('deepEquals(this.name, other.name)'), contains('deepHashCode(tags)'), contains("toString() => 'User("), isNot(contains('class UserType')), diff --git a/packages/ack_generator/test/integration/ack_infer_models_test.dart b/packages/ack_generator/test/integration/ack_infer_models_test.dart index 17be2ce9..807f699d 100644 --- a/packages/ack_generator/test/integration/ack_infer_models_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_models_test.dart @@ -116,7 +116,7 @@ final userSchema = Ack.object({ contains('const _UserCopyWithUnset()'), contains('static const _UserCopyWithUnset _ackCopyWithUnset ='), contains('Object? nickname = _ackCopyWithUnset'), - contains('nickname: identical(nickname, _ackCopyWithUnset)'), + contains('nickname: _ackIsCopyWithUnset(nickname)'), contains(': nickname as String?'), isNot(contains('_ackCopyWithOmitted')), ]), @@ -276,7 +276,7 @@ $_imports part 'address.g.dart'; @Schemable() -final class Address with _\$AddressAck { +final class Address { const Address({required this.city}); final String city; @@ -315,7 +315,7 @@ $_imports part 'address.g.dart'; @Schemable() -final class Address with _\$AddressAck { +final class Address { const Address({required this.city}); final String city; diff --git a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart index 54b7f5ba..27fd3085 100644 --- a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart @@ -7,8 +7,10 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + Future _run(Directory directory, List arguments) => - Process.run('dart', arguments, workingDirectory: directory.path); + runDart(directory, arguments); void _expectSuccess(ProcessResult result, String command) { expect( @@ -30,9 +32,8 @@ Future _expectConsumerSafeParts( for (final MapEntry(key: path, value: source) in generated.entries) { expect(source, contains(lintSuppression), reason: path); expect(source, contains('// coverage:ignore-file\n'), reason: path); - File( - p.join(project.path, path), - ).writeAsStringSync(source.replaceFirst(lintSuppression, '')); + File(p.join(project.path, path)) + .writeAsStringSync(source.replaceFirst(lintSuppression, '')); } try { _expectSuccess( @@ -63,26 +64,23 @@ Future _expectCopyWithRejects(Directory project, String misuse) async { } void main() { - test( - 'clean generated models compile and preserve the Schemable runtime contract', - () async { - var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { - projectRoot = projectRoot.parent; - } - final temporary = await Directory.systemTemp.createTemp( - 'ack_ack_infer_runtime_', - ); - try { - Directory(p.join(temporary.path, 'lib')).createSync(); - Directory(p.join(temporary.path, 'test')).createSync(); - File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' + test('clean generated models compile and preserve the Schemable runtime contract', () async { + var projectRoot = Directory.current; + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { + projectRoot = projectRoot.parent; + } + final temporary = await Directory.systemTemp.createTemp( + 'ack_ack_infer_runtime_', + ); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + Directory(p.join(temporary.path, 'test')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' name: ack_ack_infer_runtime publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -95,16 +93,14 @@ dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} '''); - File(p.join(temporary.path, 'analysis_options.yaml')).writeAsStringSync( - ''' + File(p.join(temporary.path, 'analysis_options.yaml')) + .writeAsStringSync(''' linter: rules: - avoid_redundant_argument_values - prefer_null_aware_operators -''', - ); - File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync( - r''' +'''); + File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; part 'models.g.dart'; @@ -258,23 +254,18 @@ final countedSchema = Ack.object({ ), ), }); -''', - ); - File(p.join(temporary.path, 'lib', 'address.dart')).writeAsStringSync( - r''' +'''); + File(p.join(temporary.path, 'lib', 'address.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; part 'address.g.dart'; @Schemable() final addressSchema = Ack.object({'city': Ack.string()}); -''', - ); - File( - p.join(temporary.path, 'lib', 'exports.dart'), - ).writeAsStringSync("export 'address.dart';\n"); - File(p.join(temporary.path, 'lib', 'person.dart')).writeAsStringSync( - r''' +'''); + File(p.join(temporary.path, 'lib', 'exports.dart')) + .writeAsStringSync("export 'address.dart';\n"); + File(p.join(temporary.path, 'lib', 'person.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'address.dart' as direct; @@ -287,11 +278,9 @@ final personSchema = Ack.object({ 'home': direct.addressSchema, 'history': Ack.list(exported.addressSchema), }); -''', - ); - File( - p.join(temporary.path, 'test', 'runtime_test.dart'), - ).writeAsStringSync(r''' +'''); + File(p.join(temporary.path, 'test', 'runtime_test.dart')) + .writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'package:ack_ack_infer_runtime/models.dart'; import 'package:ack_ack_infer_runtime/person.dart'; @@ -586,63 +575,59 @@ void main() { } '''); - _expectSuccess( - await _run(temporary, ['pub', 'get', '--offline']), - 'dart pub get --offline', - ); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'build_runner build', - ); - - final generated = { - for (final file - in temporary - .listSync(recursive: true) - .whereType() - .where((file) => file.path.endsWith('.g.dart'))) - p.relative(file.path, from: temporary.path): file - .readAsStringSync(), - }; - expect(generated.keys, contains('lib/models.g.dart')); - expect( - generated['lib/models.g.dart'], - contains('static Extras _fromAckRuntime'), - ); - expect( - generated['lib/models.g.dart'], - contains('Map _toAckRuntime()'), - ); - - _expectSuccess( - await _run(temporary, ['analyze', '--fatal-infos']), - 'dart analyze --fatal-infos', - ); - await _expectConsumerSafeParts(temporary, generated); - await _expectCopyWithRejects(temporary, ''' + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'build_runner build', + ); + + final generated = { + for (final file + in temporary + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.g.dart'))) + p.relative(file.path, from: temporary.path): file.readAsStringSync(), + }; + expect(generated.keys, contains('lib/models.g.dart')); + expect( + generated['lib/models.g.dart'], + contains('static Extras _fromAckRuntime'), + ); + expect( + generated['lib/models.g.dart'], + contains('Map _toAckRuntime()'), + ); + + _expectSuccess( + await _run(temporary, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + await _expectConsumerSafeParts(temporary, generated); + await _expectCopyWithRejects(temporary, ''' import 'models.dart'; Extras misuse(Extras extras) => extras.copyWith(nickname: const Object()); '''); - _expectSuccess(await _run(temporary, ['test']), 'dart test'); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'second build_runner build', - ); - final rebuilt = { - for (final file - in temporary - .listSync(recursive: true) - .whereType() - .where((file) => file.path.endsWith('.g.dart'))) - p.relative(file.path, from: temporary.path): file - .readAsStringSync(), - }; - expect(rebuilt, generated); - } finally { - temporary.deleteSync(recursive: true); - } - }, - timeout: const Timeout(Duration(minutes: 3)), - ); + _expectSuccess(await _run(temporary, ['test']), 'dart test'); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'second build_runner build', + ); + final rebuilt = { + for (final file + in temporary + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.g.dart'))) + p.relative(file.path, from: temporary.path): file.readAsStringSync(), + }; + expect(rebuilt, generated); + } finally { + temporary.deleteSync(recursive: true); + } + }, timeout: const Timeout(Duration(minutes: 3))); } diff --git a/packages/ack_generator/test/integration/augmentation_test.dart b/packages/ack_generator/test/integration/augmentation_test.dart new file mode 100644 index 00000000..63a8c78c --- /dev/null +++ b/packages/ack_generator/test/integration/augmentation_test.dart @@ -0,0 +1,355 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build/experiments.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +Future _expectCollision(String member) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + final errors = []; + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': + ''' +import 'package:ack/ack.dart'; +part 'model.g.dart'; +@Schemable() +final class Account { + const Account(); + $member +} +''', + }, + readerWriter: readerWriter, + onLog: (log) { + if (log.level.name == 'SEVERE') errors.add(log.message); + }, + ); + expect(errors, contains(contains('conflicts with a generated member'))); +} + +Future _build( + String source, + Matcher output, { + Map extraSources = const {}, +}) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + final errors = []; + await withEnabledExperiments( + () => testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': + ''' +// @dart=3.14 +import 'package:ack/ack.dart'; +${extraSources.isEmpty ? '' : "import 'exports.dart';"} +part 'model.g.dart'; +$source +''', + for (final entry in extraSources.entries) + 'test_pkg|lib/${entry.key}': entry.value, + }, + onLog: (log) { + if (log.level.name == 'SEVERE') errors.add(log.message); + }, + generateFor: const {'test_pkg|lib/model.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/model.g.dart': decodedMatches(output), + if (extraSources.containsKey('account.dart')) + 'test_pkg|lib/account.g.dart': decodedMatches(anything), + }, + ), + ['augmentations'], + ); + expect(errors, isEmpty); +} + +// Parent and child both emit `id`. Only the child carries minLength(4). +Matcher _idSchema(String expression) => predicate((source) { + final found = [ + for (final match in RegExp(r"'id':\s*([^,}]+)").allMatches(source)) + match.group(1)!.replaceAll(RegExp(r'\s+'), ''), + ].where((item) => item.contains('minLength(4)')); + return found.length == 1 && found.single == expression; +}, "id schema exactly '$expression'"); + +Future _expectInvalid( + String source, + String diagnostic, { + String imports = "import 'package:ack/ack.dart';", +}) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + final errors = []; + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': + ''' +$imports +part 'model.g.dart'; +$source +''', + }, + readerWriter: readerWriter, + onLog: (log) { + if (log.level.name == 'SEVERE') errors.add(log.message); + }, + ); + expect(errors, contains(contains(diagnostic))); +} + +void main() { + for (final member in [ + 'static final fromJson = AccountSchema.fromJson;', + 'factory Account.fromJson(Map json) => const Account();', + 'static Account parse(Object? value) => const Account();', + 'static int get safeParse => 1;', + 'int get fromJson => 1;', + 'static final schema = AccountSchema.schema;', + 'const Account.schema();', + 'static set schema(int value) {}', + ]) { + test('rejects a generated API collision: $member', () { + return _expectCollision(member); + }); + } + + test('primary model gets parsing and value members without source glue', () { + return _build( + ''' +@Schemable() +final class const Account({ + @AckKey('display_name') required final String name, + final String? nickname, + final int age = 18, +}); +''', + allOf([ + contains('augment final class Account'), + contains('factory Account.fromJson('), + contains('static Account parse('), + contains('static _AckSchemaResult safeParse('), + contains('get schema =>'), + contains('_AckMap<_AckString, _AckDynamic> toJson()'), + contains('get copyWith =>'), + contains("'display_name': Ack.string()"), + contains('Ack.integer().withDefault(18)'), + isNot(contains(r'_$AccountAck')), + isNot(contains('this as Account')), + ]), + ); + }); + + test('conventional constructors also need no mixin or static forwarder', () { + return _build( + ''' +@Schemable() +final class Account { + const Account(this.name); + final String name; +} +''', + allOf( + contains('augment final class Account'), + contains('factory Account.fromJson('), + isNot(contains(r'_$AccountAck')), + ), + ); + }); + + test('primary parameter constraints are applied exactly once', () { + return _build( + ''' +@Schemable() +final class const Account({ + @Validate.minLength(2) required final String name, +}); +''', + allOf( + contains("'name': Ack.string().minLength(2)"), + isNot(contains('.minLength(2).minLength(2)')), + ), + ); + }); + + test('distinct field and parameter annotations are both retained', () { + return _build(''' +@Schemable() +final class Account { + const Account({@Validate.minLength(2) required this.name}); + @Validate.minLength(2) + final String name; +} +''', contains("'name': Ack.string().minLength(2).minLength(2)")); + }); + + for (final primaryChild in [true, false]) { + for (final annotatedParent in [true, false]) { + test( + '${primaryChild ? 'primary' : 'conventional'} super parameters retain ' + 'constraints with ${annotatedParent ? 'annotated' : 'unannotated'} ' + 'primary fields', + () => _build( + ''' +@Schemable() +final class const Parent({ + ${annotatedParent ? "@AckKey('id') @Validate.maxLength(6)" : ''} + required final String id, +}); +@Schemable() +${primaryChild ? ''' +final class const Child({ + @Validate.minLength(4) required super.id, +}) extends Parent; +''' : ''' +final class Child extends Parent { + const Child({@Validate.minLength(4) required super.id}); +} +'''} +''', + _idSchema( + 'Ack.string()' + '${annotatedParent ? '.maxLength(6)' : ''}.minLength(4)', + ), + ), + ); + } + } + + test('sealed unions preserve class modifiers in every augmentation', () { + return _build( + ''' +@Schemable(discriminatorKey: 'kind') +sealed class const Pet(); +final class const Cat({required final String name}) extends Pet; +final class const Dog({required final bool good}) extends Pet; +''', + allOf( + contains('augment sealed class Pet'), + contains('factory Pet.fromJson('), + contains('augment final class Cat'), + contains('factory Cat.fromJson('), + contains('augment final class Dog'), + isNot(contains('mixin ')), + ), + ); + }); + + test( + 'schema-first collections reference same-library generated Model.schema', + () { + return _build( + ''' +@Schemable() +final class const Account({required final String name}); +@Schemable() +final teamSchema = Ack.object({'members': Ack.list(Account.schema)}); +''', + allOf( + contains('final List members;'), + contains('augment final class Account'), + ), + ); + }, + ); + + test( + 'a model-only barrel import supports fields and schema-first collections', + () { + return _build( + ''' +@Schemable() +final class const Team({required final Account owner}); +@Schemable() +final teamListSchema = Ack.object({'members': Ack.list(Account.schema)}); +''', + allOf( + contains("'owner': Account.schema"), + contains('final List members;'), + isNot(contains('AccountSchema.schema')), + ), + extraSources: { + 'exports.dart': "export 'account.dart' show Account;", + 'account.dart': ''' +import 'package:ack/ack.dart'; +part 'account.g.dart'; +@Schemable() +final class const Account({required final String name}); +''', + }, + ); + }, + ); + test('rejects generic models even when no field uses the parameter', () { + return _expectInvalid( + '@Schemable() final class const Account({final int count = 0});', + 'class-first models must not declare type parameters', + ); + }); + + test('rejects implicit generic union branches', () { + return _expectInvalid(''' +@Schemable(discriminatorKey: 'kind') +sealed class const Pet(); +final class const Cat({final int lives = 9}) extends Pet; +''', 'class-first models must not declare type parameters'); + }); + + for (final name in ['schema', 'parse', 'safeParse']) { + test('rejects inherited instance $name conflicts', () { + return _expectInvalid(''' +class Base { const Base(); int get $name => 1; } +@Schemable() +final class const Account() extends Base; +''', 'inherits Base.$name'); + }); + } + + test('private superclass members in another library cannot collide', () { + return _build( + ''' +@Schemable() +final class const Account({required final String name}) extends Base; +''', + contains('augment final class Account'), + extraSources: { + 'exports.dart': r''' +class Base { + const Base(); + int get _AckObject => 1; + int get _$AccountSchemaApi => 1; +} +''', + }, + ); + }); + + test('private superclass members in the same library still collide', () { + return _expectInvalid(r''' +class Base { const Base(); int get _AckObject => 1; } +@Schemable() +final class const Account() extends Base; +''', 'inherits Base._AckObject'); + }); + + test('diagnoses legacy generated mixins without fallback generation', () { + return _expectInvalid( + r'@Schemable() final class Account with _$AccountAck { const Account(); }', + r'Remove the with clause', + ); + }); + + test('diagnoses an import prefix shadowed by the generated schema API', () { + return _expectInvalid( + '@schema.Schemable() final class const Account();', + 'Import prefix "schema" conflicts with a generated member', + imports: "import 'package:ack/ack.dart' as schema;", + ); + }); +} diff --git a/packages/ack_generator/test/integration/class_first_cycle_build_test.dart b/packages/ack_generator/test/integration/class_first_cycle_build_test.dart index fa46e83a..a39ecda9 100644 --- a/packages/ack_generator/test/integration/class_first_cycle_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_cycle_build_test.dart @@ -6,17 +6,18 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + Future _run(Directory directory, List arguments) => - Process.run('dart', arguments, workingDirectory: directory.path); + runDart(directory, arguments); void main() { test( 'a clean build rejects a two-library class-first cycle with a field path', () async { var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { projectRoot = projectRoot.parent; } final temporary = await Directory.systemTemp.createTemp( @@ -29,7 +30,7 @@ void main() { name: ack_class_first_cycle publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -49,7 +50,7 @@ import 'child.dart'; part 'parent.g.dart'; @Schemable() -final class Parent with _$ParentAck { +final class Parent { const Parent({required this.child}); final Child child; @@ -63,7 +64,7 @@ import 'parent.dart'; part 'child.g.dart'; @Schemable() -final class Child with _$ChildAck { +final class Child { const Child({required this.parent}); final Parent parent; diff --git a/packages/ack_generator/test/integration/class_first_json_test.dart b/packages/ack_generator/test/integration/class_first_json_test.dart index 200f7dbb..50788fa9 100644 --- a/packages/ack_generator/test/integration/class_first_json_test.dart +++ b/packages/ack_generator/test/integration/class_first_json_test.dart @@ -24,33 +24,30 @@ part 'model.g.dart'; '''; void main() { - test( - 'class-first runtime mapping uses direct constructor and map literals with pinned case style', - () async { - await _expectGeneratedPart( - ''' + test('class-first runtime mapping uses direct constructor and map literals with pinned case style', () async { + await _expectGeneratedPart( + ''' $_head @Schemable(caseStyle: AckCaseStyle.snake) -final class User with _\$UserAck { +final class User { const User({required this.firstName, this.nickname}); final String firstName; final String? nickname; } ''', - allOf([ - contains(r'User _$UserFromRuntime(Map value)'), - contains("firstName: value['first_name'] as String"), - contains("nickname: value['nickname'] as String?"), - contains(r'Map _$UserToRuntime(User model)'), - contains("'first_name': model.firstName"), - contains("'nickname': ?model.nickname"), - isNot(contains(r'_$UserFromJson')), - isNot(contains(r'_$UserToJson')), - ]), - ); - }, - ); + allOf([ + contains(r'User _$UserFromRuntime(Map value)'), + contains("firstName: value['first_name'] as String"), + contains("nickname: value['nickname'] as String?"), + contains(r'Map _$UserToRuntime(User model)'), + contains("'first_name': model.firstName"), + contains("'nickname': ?model.nickname"), + isNot(contains(r'_$UserFromJson')), + isNot(contains(r'_$UserToJson')), + ]), + ); + }); test( 'runtime emission auto-includes unannotated concrete sealed branches', @@ -59,12 +56,12 @@ final class User with _\$UserAck { ''' $_head @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet({required this.id}); final String id; } -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat({required super.id, required this.lives}); final int lives; } @@ -86,12 +83,12 @@ final class Cat extends Pet with _\$CatAck { ''' $_head @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } @Schemable(caseStyle: AckCaseStyle.snake) -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat({required this.livesLeft}); final int livesLeft; } @@ -116,7 +113,7 @@ final class Cat extends Pet with _\$CatAck { ''' $_head @Schemable(caseStyle: AckCaseStyle.${style.name}) -final class User with _\$UserAck { +final class User { const User({required this.firstName}); final String firstName; diff --git a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart index f8c81d35..0ea76fc3 100644 --- a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart @@ -6,8 +6,10 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + Future _run(Directory directory, List arguments) => - Process.run('dart', arguments, workingDirectory: directory.path); + runDart(directory, arguments); void _expectSuccess(ProcessResult result, String command) { expect( @@ -32,9 +34,8 @@ void main() { 'class-first facades compose across libraries from a clean build', () async { var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { projectRoot = projectRoot.parent; } final temporary = await Directory.systemTemp.createTemp( @@ -47,7 +48,7 @@ void main() { name: ack_class_first_nested publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -60,23 +61,22 @@ dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} '''); - File(p.join(temporary.path, 'lib', 'address.dart')).writeAsStringSync( - r''' + File(p.join(temporary.path, 'lib', 'address.dart')) + .writeAsStringSync(r''' import 'package:ack/ack.dart'; part 'address.g.dart'; @Schemable(schemaName: 'PostalAddressSchema') -final class Address with _$AddressAck { +final class Address { const Address({required this.city}); final String city; - static final fromJson = PostalAddressSchema.fromJson; } @Schemable() -final class AddressBook with _$AddressBookAck { +final class AddressBook { const AddressBook({required this.primary, this.secondary}); final Address primary; @@ -84,24 +84,23 @@ final class AddressBook with _$AddressBookAck { } @Schemable(caseStyle: AckCaseStyle.kebab) -final class Label with _$LabelAck { +final class Label { const Label({required this.postCode, this.lineCount = 1}); final String postCode; final int lineCount; } -''', - ); - File(p.join(temporary.path, 'lib', 'customer.dart')).writeAsStringSync( - r''' +'''); + File(p.join(temporary.path, 'lib', 'customer.dart')) + .writeAsStringSync(r''' import 'package:ack/ack.dart'; -import 'address.dart' show Address, PostalAddressSchema; +import 'address.dart' show Address; part 'customer.g.dart'; @Schemable() -final class Customer with _$CustomerAck { +final class Customer { const Customer({ this.primary = const Address(city: 'Default City'), this.secondary, @@ -110,13 +109,11 @@ final class Customer with _$CustomerAck { final Address primary; final Address? secondary; } -''', - ); - File( - p.join(temporary.path, 'lib', 'models.dart'), - ).writeAsStringSync("export 'address.dart';\n"); - File(p.join(temporary.path, 'lib', 'parcel.dart')).writeAsStringSync( - r''' +'''); + File(p.join(temporary.path, 'lib', 'models.dart')) + .writeAsStringSync("export 'address.dart' show Address;\n"); + File(p.join(temporary.path, 'lib', 'parcel.dart')) + .writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'models.dart' as models; @@ -124,20 +121,19 @@ import 'models.dart' as models; part 'parcel.g.dart'; @Schemable() -final class Parcel with _$ParcelAck { +final class Parcel { const Parcel({required this.destination}); final models.Address destination; } -''', - ); +'''); File(p.join(temporary.path, 'lib', 'north.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; part 'north.g.dart'; @Schemable() -final class Place with _$PlaceAck { +final class Place { const Place({required this.name}); final String name; @@ -149,14 +145,14 @@ import 'package:ack/ack.dart'; part 'south.g.dart'; @Schemable() -final class Place with _$PlaceAck { +final class Place { const Place({required this.name}); final String name; } '''); - File(p.join(temporary.path, 'lib', 'itinerary.dart')).writeAsStringSync( - r''' + File(p.join(temporary.path, 'lib', 'itinerary.dart')) + .writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'north.dart' as north; @@ -165,27 +161,26 @@ import 'south.dart' as south; part 'itinerary.g.dart'; @Schemable() -final class Itinerary with _$ItineraryAck { +final class Itinerary { const Itinerary({required this.start, required this.finish}); final north.Place start; final south.Place finish; } -''', - ); +'''); File(p.join(temporary.path, 'lib', 'pet.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; part 'pet.g.dart'; @Schemable(discriminatorKey: 'type') -sealed class Pet with _$PetAck { +sealed class Pet { const Pet({required this.id}); final String id; } -final class Cat extends Pet with _$CatAck { +final class Cat extends Pet { const Cat({required super.id, required this.lives}); final int lives; @@ -200,7 +195,7 @@ import 'pet.dart' as pets; part 'order.g.dart'; @Schemable() -final class Order with _$OrderAck { +final class Order { const Order({ required this.shipping, required this.history, @@ -216,18 +211,17 @@ final class Order with _$OrderAck { final pets.Cat cat; } '''); - File(p.join(temporary.path, 'lib', 'legacy.dart')).writeAsStringSync( - r''' + File(p.join(temporary.path, 'lib', 'legacy.dart')) + .writeAsStringSync(r''' import 'package:ack/ack.dart'; part 'legacy.g.dart'; @Schemable(name: 'LegacyAddress') final legacyAddressContract = Ack.object({'city': Ack.string()}); -''', - ); - File(p.join(temporary.path, 'lib', 'holder.dart')).writeAsStringSync( - r''' +'''); + File(p.join(temporary.path, 'lib', 'holder.dart')) + .writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'legacy.dart' as legacy; @@ -235,7 +229,7 @@ import 'legacy.dart' as legacy; part 'holder.g.dart'; @Schemable() -final class Holder with _$HolderAck { +final class Holder { const Holder({ required this.primary, this.optional, @@ -250,11 +244,9 @@ final class Holder with _$HolderAck { final List> matrix; final Set unique; } -''', - ); - File( - p.join(temporary.path, 'lib', 'address_envelope.dart'), - ).writeAsStringSync(r''' +'''); + File(p.join(temporary.path, 'lib', 'address_envelope.dart')) + .writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'address.dart' as address; @@ -273,9 +265,8 @@ final addressEnvelopeSchema = Ack.object({ ), }); '''); - File( - p.join(temporary.path, 'test', 'nested_test.dart'), - ).writeAsStringSync(r''' + File(p.join(temporary.path, 'test', 'nested_test.dart')) + .writeAsStringSync(r''' import 'package:ack_class_first_nested/address.dart'; import 'package:ack_class_first_nested/address_envelope.dart'; import 'package:ack_class_first_nested/customer.dart'; @@ -452,28 +443,25 @@ void main() { ); final generated = _generatedFiles(temporary); final orderOutput = generated['lib/order.g.dart']; - expect(orderOutput, contains('address.PostalAddressSchema.schema')); - expect(orderOutput, contains('pets.PetSchema.schema')); - expect(orderOutput, contains('pets.CatSchema.schema')); + expect(orderOutput, contains('address.Address.schema')); + expect(orderOutput, contains('pets.Pet.schema')); + expect(orderOutput, contains('pets.Cat.schema')); expect(orderOutput, isNot(contains('address.addressSchema'))); expect( generated['lib/address.g.dart'], - contains("'primary': PostalAddressSchema.schema"), + contains("'primary': Address.schema"), ); expect( generated['lib/customer.g.dart'], - contains("'primary': PostalAddressSchema.schema.withDefault"), + contains("'primary': Address.schema.withDefault"), ); expect( generated['lib/parcel.g.dart'], - contains('models.PostalAddressSchema.schema'), + contains('models.Address.schema'), ); expect( generated['lib/itinerary.g.dart'], - allOf( - contains('north.PlaceSchema.schema'), - contains('south.PlaceSchema.schema'), - ), + allOf(contains('north.Place.schema'), contains('south.Place.schema')), ); expect( generated['lib/holder.g.dart'], diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index 320d2d24..cc25bd0d 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -6,8 +6,10 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + Future _run(Directory directory, List arguments) => - Process.run('dart', arguments, workingDirectory: directory.path); + runDart(directory, arguments); void _expectSuccess(ProcessResult result, String command) { expect( @@ -33,7 +35,7 @@ Map _generatedFiles(Directory directory) => { /// Asserts modern generated parts suppress consumer lints and coverage, then /// analyzes them without the lint suppression so the harness rules still -/// guard the generated code itself. Frozen `@AckType()` parts are excluded. +/// guard the generated code itself. Future _expectConsumerSafeParts( Directory project, Map generated, @@ -47,9 +49,8 @@ Future _expectConsumerSafeParts( for (final MapEntry(key: path, value: source) in modern.entries) { expect(source, contains(lintSuppression), reason: path); expect(source, contains('// coverage:ignore-file\n'), reason: path); - File( - p.join(project.path, path), - ).writeAsStringSync(source.replaceAll(lintSuppression, '')); + File(p.join(project.path, path)) + .writeAsStringSync(source.replaceAll(lintSuppression, '')); } try { _expectSuccess( @@ -80,26 +81,23 @@ Future _expectCopyWithRejects(Directory project, String misuse) async { } void main() { - test( - 'class-first models compile and preserve the runtime contract', - () async { - var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { - projectRoot = projectRoot.parent; - } - final temporary = await Directory.systemTemp.createTemp( - 'ack_class_first_runtime_', - ); - try { - Directory(p.join(temporary.path, 'lib')).createSync(); - Directory(p.join(temporary.path, 'test')).createSync(); - File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' + test('class-first models compile and preserve the runtime contract', () async { + var projectRoot = Directory.current; + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { + projectRoot = projectRoot.parent; + } + final temporary = await Directory.systemTemp.createTemp( + 'ack_class_first_runtime_', + ); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + Directory(p.join(temporary.path, 'test')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' name: ack_class_first_runtime publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -117,22 +115,21 @@ dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} '''); - File(p.join(temporary.path, 'analysis_options.yaml')).writeAsStringSync( - ''' + File(p.join(temporary.path, 'analysis_options.yaml')) + .writeAsStringSync(''' linter: rules: - avoid_redundant_argument_values - prefer_null_aware_operators -''', - ); - File(p.join(temporary.path, 'lib', 'alpha.dart')).writeAsStringSync( - 'final class Item { const Item(this.value); final String value; }\n', - ); - File(p.join(temporary.path, 'lib', 'beta.dart')).writeAsStringSync( - 'final class Item { const Item(this.value); final int value; }\n', - ); - File(p.join(temporary.path, 'lib', 'coexist.dart')).writeAsStringSync( - r''' + - library_private_types_in_public_api +'''); + File(p.join(temporary.path, 'lib', 'alpha.dart')).writeAsStringSync( + 'final class Item { const Item(this.value); final String value; }\n', + ); + File(p.join(temporary.path, 'lib', 'beta.dart')).writeAsStringSync( + 'final class Item { const Item(this.value); final int value; }\n', + ); + File(p.join(temporary.path, 'lib', 'coexist.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; part 'coexist.g.dart'; @@ -141,15 +138,26 @@ part 'coexist.g.dart'; final modernSchema = Ack.object({'name': Ack.string()}); @Schemable() -final class Handwritten with _$HandwrittenAck { +final class Handwritten { const Handwritten({required this.enabled}); final bool enabled; } -''', - ); - File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync( - r''' +'''); + File(p.join(temporary.path, 'lib', 'prefix_fields.dart')) + .writeAsStringSync(r''' +import 'package:ack/ack.dart' as ack; +part 'prefix_fields.g.dart'; +@ack.Schemable() +final class const PrefixFields({ + required final String ack, + required final int Map, + required final int Object, + required final int dynamic, + required final int override, +}); +'''); + File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'package:json_annotation/json_annotation.dart' show JsonSerializable; @@ -158,6 +166,51 @@ import 'beta.dart' as beta; part 'models.g.dart'; +@Schemable() +final class const TextDefault({ + final String text = """ +final class TextDefault +""", +}); + +@Schemable() +final class const ShadowedNames({ + required final int other, + required final int identical, + required final int deepEquals, + required final int deepHashCode, + required final int ShadowedNamesSchema, + final String? nickname, +}); + +@Schemable() +final class const PrimaryAccount({ + /// @description The display name. + @AckKey('display_name') @Validate.minLength(2) + required final String name, + required final DateTime createdAt, + final String? nickname, + final int age = 18, +}); + +@Schemable() +final class const ConstraintParent({ + @AckKey('external_id') @Validate.maxLength(6) + required final String id, +}); + +@Schemable() +final class const PrimaryConstraintChild({ + @Validate.minLength(4) required super.id, +}) extends ConstraintParent; + +@Schemable() +final class ConventionalConstraintChild extends ConstraintParent { + const ConventionalConstraintChild({ + @Validate.minLength(4) required super.id, + }); +} + final class Color { const Color(this.hex); final String hex; @@ -181,7 +234,7 @@ AckSchema betaItemSchema() => Ack.integer() .codec(decode: beta.Item.new, encode: (item) => item.value); @Schemable(schemas: [colorSchema]) -final class Profile with _$ProfileAck { +final class Profile { const Profile({ required this.name, this.website, @@ -199,21 +252,19 @@ final class Profile with _$ProfileAck { final Set tags; final Color color; - static final fromJson = ProfileSchema.fromJson; } @Schemable(caseStyle: AckCaseStyle.snake) -final class Account with _$AccountAck { +final class Account { const Account({required this.firstName, required this.imageUrl}); final String firstName; @AckKey('avatar') final Uri imageUrl; - static final fromJson = AccountSchema.fromJson; } @Schemable(unknownProperties: AckUnknownPropertyPolicy.capture) -final class Config with _$ConfigAck { +final class Config { const Config({ required this.name, this.additionalProperties = const {}, @@ -227,7 +278,7 @@ final class Config with _$ConfigAck { unknownProperties: AckUnknownPropertyPolicy.capture, captureField: 'extraValues', ) -final class CaseStyledExtras with _$CaseStyledExtrasAck { +final class CaseStyledExtras { const CaseStyledExtras({ required this.displayName, this.extraValues = const {}, @@ -237,13 +288,13 @@ final class CaseStyledExtras with _$CaseStyledExtrasAck { } @Schemable(unknownProperties: AckUnknownPropertyPolicy.discard) -final class Loose with _$LooseAck { +final class Loose { const Loose({required this.name}); final String name; } @Schemable() -final class Normalized with _$NormalizedAck { +final class Normalized { const Normalized(String? value) : value = value ?? ''; @Validate.optional() @@ -251,28 +302,28 @@ final class Normalized with _$NormalizedAck { } @Schemable() -final class NullableDefault with _$NullableDefaultAck { +final class NullableDefault { const NullableDefault({this.label = 'fallback'}); final String? label; } @Schemable() -final class NullDefault with _$NullDefaultAck { +final class NullDefault { const NullDefault({this.label = null}); final String? label; } @Schemable(schemas: [alphaItemSchema, betaItemSchema]) -final class ImportedPair with _$ImportedPairAck { +final class ImportedPair { const ImportedPair({required this.left, required this.right}); final alpha.Item left; final beta.Item right; } @Schemable() -final class ImmutableCollections with _$ImmutableCollectionsAck { +final class ImmutableCollections { const ImmutableCollections({ required this.matrix, required this.labels, @@ -286,7 +337,7 @@ final class ImmutableCollections with _$ImmutableCollectionsAck { } @Schemable() -final class Example with _$ExampleAck { +final class Example { const Example({this.label, this.title}); @Validate.optional() @@ -298,18 +349,17 @@ final class Example with _$ExampleAck { @Validate.notEmpty() final String? title; - static final fromJson = ExampleSchema.fromJson; } @Schemable() -final class ExampleHolder with _$ExampleHolderAck { +final class ExampleHolder { const ExampleHolder({required this.example}); final Example example; } @Schemable() -final class OptionalNullable with _$OptionalNullableAck { +final class OptionalNullable { const OptionalNullable({this.note}); @Validate.optional() @@ -317,7 +367,7 @@ final class OptionalNullable with _$OptionalNullableAck { } @Schemable() -final class InferredNotNull with _$InferredNotNullAck { +final class InferredNotNull { const InferredNotNull({this.label}); @Validate.notNull() @@ -342,7 +392,7 @@ AckSchema nullableNameSchema() => ); @Schemable(schemas: [nullableNameSchema]) -final class OverrideNotNull with _$OverrideNotNullAck { +final class OverrideNotNull { const OverrideNotNull({this.name}); @Validate.notNull() @@ -350,7 +400,7 @@ final class OverrideNotNull with _$OverrideNotNullAck { } @Schemable() -final class RequiredNotNull with _$RequiredNotNullAck { +final class RequiredNotNull { const RequiredNotNull({this.value}); @Validate.required() @@ -370,7 +420,7 @@ AckSchema parameterEntrySchema() => ); @Schemable(schemas: [parameterEntrySchema]) -final class CapabilityBinding with _$CapabilityBindingAck { +final class CapabilityBinding { CapabilityBinding({ required this.name, Map parameters = const {}, @@ -381,11 +431,10 @@ final class CapabilityBinding with _$CapabilityBindingAck { @Validate.optional() final Map parameters; - static final fromJson = CapabilityBindingSchema.fromJson; } @Schemable() -final class Envelope with _$EnvelopeAck { +final class Envelope { const Envelope({ required this.kind, this.payload, @@ -412,12 +461,12 @@ final class Envelope with _$EnvelopeAck { } @Schemable() -final class Declined with _$DeclinedAck { +final class Declined { const Declined(); } @Schemable() -final class OptionalCollections with _$OptionalCollectionsAck { +final class OptionalCollections { const OptionalCollections({this.headers, this.aliases, this.tags}); final Map? headers; @@ -426,53 +475,45 @@ final class OptionalCollections with _$OptionalCollectionsAck { } @Schemable(discriminatorKey: 'type') -sealed class Pet with _$PetAck { - const Pet({required this.id}); - final String id; -} +sealed class const Pet({required final String id}); @Schemable(discriminatorValue: 'cat') -final class Cat extends Pet with _$CatAck { - const Cat({required super.id, required this.lives}); - final int lives; -} +final class const Cat({required super.id, required final int lives}) extends Pet; -final class Dog extends Pet with _$DogAck { - const Dog({required super.id, required this.breed}); - final String breed; +final class const Dog({required super.id, required final String breed}) extends Pet { String get type => 'Dog'; } @Schemable(discriminatorKey: 'type') -sealed class Animal with _$AnimalAck { +sealed class Animal { const Animal({required this.id}); final String id; } -final class Rabbit extends Animal with _$RabbitAck { +final class Rabbit extends Animal { const Rabbit({required super.id, required this.lives}); final int lives; } -final class BabyRabbit extends Rabbit with _$BabyRabbitAck { +final class BabyRabbit extends Rabbit { const BabyRabbit({required super.id, required super.lives, this.note}); final String? note; } @Schemable() -final class Parent with _$ParentAck { +final class Parent { const Parent({required this.id}); final String id; } @Schemable() -final class Child extends Parent with _$ChildAck { +final class Child extends Parent { const Child({required super.id, this.note}); final String? note; } @Schemable() -final class ImplementingChild with _$ImplementingChildAck implements Parent { +final class ImplementingChild implements Parent { const ImplementingChild({required this.id, this.note}); final String id; final String? note; @@ -481,7 +522,7 @@ final class ImplementingChild with _$ImplementingChildAck implements Parent { /// A task the person can complete. /// @description This tag has lower priority. @Schemable(description: 'A task the person can complete.') -final class Task with _$TaskAck { +final class Task { const Task({required this.id, required this.title}); final String id; @@ -497,7 +538,7 @@ final class Task with _$TaskAck { * @description A board that groups related tasks. */ @Schemable() -final class Board with _$BoardAck { +final class Board { const Board({required this.focus, required this.backlog, this.pinned}); /// The task to do next. @@ -508,7 +549,7 @@ final class Board with _$BoardAck { } @Schemable() -final class Undocumented with _$UndocumentedAck { +final class Undocumented { const Undocumented({required this.value}); /// Ordinary field prose does not become schema data. @@ -518,7 +559,7 @@ final class Undocumented with _$UndocumentedAck { /// A shape to draw. /// @description A shape to draw. @Schemable(discriminatorKey: 'kind') -sealed class Shape with _$ShapeAck { +sealed class Shape { const Shape(); } @@ -527,13 +568,13 @@ sealed class Shape with _$ShapeAck { discriminatorValue: 'circle', description: 'A circle, sized by its radius.', ) -final class Circle extends Shape with _$CircleAck { +final class Circle extends Shape { const Circle({required this.radius}); final double radius; } -final class Square extends Shape with _$SquareAck { +final class Square extends Shape { const Square({required this.side}); final double side; @@ -543,7 +584,7 @@ final class Square extends Shape with _$SquareAck { final legacySchema = Ack.object({'enabled': Ack.boolean()}); @Schemable() -final class UserPatch with _$UserPatchAck { +final class UserPatch { const UserPatch({ this.nickname = const JsonMaybe.absent(), this.title = const JsonMaybe.absent(), @@ -568,17 +609,16 @@ final class PlainJson { final String value; Map toJson() => _$PlainJsonToJson(this); } -''', - ); - File( - p.join(temporary.path, 'test', 'runtime_test.dart'), - ).writeAsStringSync(r''' +'''); + File(p.join(temporary.path, 'test', 'runtime_test.dart')) + .writeAsStringSync(r''' import 'dart:convert'; import 'package:ack_class_first_runtime/alpha.dart' as alpha; import 'package:ack_class_first_runtime/beta.dart' as beta; import 'package:ack_class_first_runtime/coexist.dart'; import 'package:ack_class_first_runtime/models.dart'; +import 'package:ack_class_first_runtime/prefix_fields.dart'; import 'package:ack/ack.dart'; import 'package:test/test.dart'; @@ -606,7 +646,113 @@ final envelopeJson = { }; void main() { - test('presence, defaults, collections, and escape hatches round-trip', () { + test('formatting preserves declaration-looking multiline defaults', () { + expect(TextDefault.fromJson({}).text, 'final class TextDefault\n'); + expect(TextDefault.schema.parse({})!.text, 'final class TextDefault\n'); + }); + + test('fields cannot shadow runtime import prefixes or generated signature types', () { + final json = { + 'ack': 'prefix', 'Map': 1, 'Object': 2, 'dynamic': 3, 'override': 4, + }; + final model = PrefixFields.fromJson(json); + expect(model.ack, 'prefix'); + expect(model, PrefixFields.parse(json)); + expect(model.hashCode, PrefixFields.parse(json).hashCode); + expect(model.toString(), contains('dynamic: 3')); + expect(model.toString(), contains('override: 4')); + expect(model.toJson(), json); + expect(model.safeToJson().isOk, isTrue); + expect(PrefixFields.safeParse(json).isOk, isTrue); + expect(model.copyWith(ack: 'next').ack, 'next'); + expect(model.copyWith(dynamic: 5).dynamic, 5); + expect(model.copyWith(override: 6).override, 6); + }); + + test('model fields cannot shadow generated value or JSON operations', () { + const value = ShadowedNames( + other: 1, identical: 2, deepEquals: 3, deepHashCode: 4, + ShadowedNamesSchema: 5, nickname: 'preserved', + ); + final wire = { + 'other': 1, 'identical': 2, 'deepEquals': 3, 'deepHashCode': 4, + 'ShadowedNamesSchema': 5, 'nickname': 'preserved', + }; + expect(value, ShadowedNames.fromJson(wire)); + expect(value.hashCode, ShadowedNames.parse(wire).hashCode); + expect(value, isNot(value.copyWith(other: 9))); + expect(value.copyWith().nickname, 'preserved'); + expect(value.copyWith(nickname: null).nickname, isNull); + expect(value.toJson(), wire); + expect(value.safeToJson().isOk, isTrue); + expect(ShadowedNames.safeParse(wire).isOk, isTrue); + }); + + test('primary constructors preserve the validated model contract', () { + final wire = { + 'display_name': 'Leo', + 'createdAt': '2026-10-18T12:00:00.000Z', + 'nickname': 'L', + }; + final account = PrimaryAccount.fromJson(wire); + expect(account.age, 18); + expect(account.createdAt, DateTime.utc(2026, 10, 18, 12)); + expect(account, PrimaryAccount.parse(wire)); + expect(account.hashCode, PrimaryAccount.parse(wire).hashCode); + expect(account.copyWith(), account); + expect(account.copyWith(nickname: null).nickname, isNull); + expect(account.copyWith(age: 21).age, 21); + expect(account.toJson(), {...wire, 'age': 18}); + expect(account.safeToJson().isOk, isTrue); + expect(jsonDecode(jsonEncode(account)), {...wire, 'age': 18}); + expect(PrimaryAccount.schema, same(PrimaryAccountSchema.schema)); + expect((PrimaryAccountSchema.toJsonSchema()['properties'] as Map)['display_name']['description'], 'The display name.'); + expect(PrimaryAccount.safeParse({...wire, 'display_name': 'L'}).isFail, isTrue); + expect(() => PrimaryAccount.fromJson({...wire, 'display_name': 'L'}), + throwsA(isA())); + expect(account.copyWith(name: 'L').safeToJson().isFail, isTrue); + }); + + for (final (name, parse, safeParse, encode, safeEncode, jsonSchema) in [ + ( + 'primary', + PrimaryConstraintChild.parse, + PrimaryConstraintChild.safeParse, + (String id) => PrimaryConstraintChild(id: id).toJson(), + (String id) => PrimaryConstraintChild(id: id).safeToJson(), + PrimaryConstraintChildSchema.toJsonSchema(), + ), + ( + 'conventional', + ConventionalConstraintChild.parse, + ConventionalConstraintChild.safeParse, + (String id) => ConventionalConstraintChild(id: id).toJson(), + (String id) => ConventionalConstraintChild(id: id).safeToJson(), + ConventionalConstraintChildSchema.toJsonSchema(), + ), + ]) { + test('$name super parameters combine ancestor and child constraints', () { + const valid = {'external_id': 'valid'}; + expect(parse(valid).id, 'valid'); + expect(safeParse(valid).isOk, isTrue); + expect(encode('valid'), valid); + expect(safeEncode('valid').isOk, isTrue); + for (final invalid in ['abc', 'toolong']) { + final wire = {'external_id': invalid}; + expect(() => parse(wire), throwsA(isA())); + expect(safeParse(wire).isFail, isTrue); + expect(() => encode(invalid), throwsA(isA())); + expect(safeEncode(invalid).isFail, isTrue); + } + final properties = jsonSchema['properties'] as Map; + expect(properties['external_id'], allOf( + containsPair('minLength', 4), + containsPair('maxLength', 6), + )); + }); + } + + test('presence, defaults, collections, and escape hatches round-trip' , () { final profile = Profile.fromJson({ 'name': 'Ada', 'website': null, @@ -1198,83 +1344,82 @@ void main() { } '''); - _expectSuccess( - await _run(temporary, ['pub', 'get', '--offline']), - 'dart pub get --offline', - ); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'build_runner build', - ); - final generated = _generatedFiles(temporary); - expect( - generated.keys, - containsAll(['lib/coexist.g.dart', 'lib/models.g.dart']), - ); - expect( - generated['lib/models.g.dart'], - contains(r'decode: _$ProfileFromRuntime'), - ); - expect( - generated['lib/models.g.dart'], - contains(r'_$PlainJsonFromJson'), - ); - expect(generated['lib/models.g.dart'], contains('class Legacy')); - expect(generated['lib/models.g.dart'], contains(r'mixin _$ProfileAck')); - expect( - generated['lib/models.g.dart'], - contains(r'implements $ParentCopyWith<$Result>'), - ); - expect( - generated['lib/models.g.dart'], - contains(r'implements $RabbitCopyWith<$Result>'), - ); - expect( - generated['lib/models.g.dart'], - contains('abstract final class ProfileSchema'), - ); - expect( - generated['lib/models.g.dart'], - contains('final _profileSchema'), - ); - expect(generated['lib/coexist.g.dart'], contains('class Modern')); - expect( - generated['lib/coexist.g.dart'], - contains(r'mixin _$HandwrittenAck'), - ); - expect( - generated['lib/models.g.dart'], - isNot(contains('final profileSchema =')), - ); - expect( - generated['lib/models.g.dart'], - contains('parameters: parameters ?? _source.parameters'), - ); - expect( - generated['lib/models.g.dart'], - isNot(contains('parameters as Map?')), - ); - - _expectSuccess( - await _run(temporary, ['analyze', '--fatal-infos']), - 'dart analyze --fatal-infos', - ); - await _expectConsumerSafeParts(temporary, generated); - await _expectCopyWithRejects(temporary, ''' + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'build_runner build', + ); + final generated = _generatedFiles(temporary); + expect( + generated.keys, + containsAll(['lib/coexist.g.dart', 'lib/models.g.dart']), + ); + expect( + generated['lib/models.g.dart'], + contains(r'decode: _$ProfileFromRuntime'), + ); + expect(generated['lib/models.g.dart'], contains(r'_$PlainJsonFromJson')); + expect(generated['lib/models.g.dart'], contains('class Legacy')); + expect( + generated['lib/models.g.dart'], + contains(r'augment final class Profile'), + ); + expect( + generated['lib/models.g.dart'], + contains(r'implements $ParentCopyWith<$Result>'), + ); + expect( + generated['lib/models.g.dart'], + contains(r'implements $RabbitCopyWith<$Result>'), + ); + expect( + generated['lib/models.g.dart'], + contains('abstract final class ProfileSchema'), + ); + expect(generated['lib/models.g.dart'], contains('final _profileSchema')); + expect(generated['lib/coexist.g.dart'], contains('class Modern')); + expect( + generated['lib/coexist.g.dart'], + contains(r'augment final class Handwritten'), + ); + expect( + generated['lib/models.g.dart'], + isNot(contains('final profileSchema =')), + ); + expect( + generated['lib/models.g.dart'], + contains('parameters: parameters ?? _source.parameters'), + ); + expect( + generated['lib/models.g.dart'], + isNot(contains('parameters as Map?')), + ); + + _expectSuccess( + await _run(temporary, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + await _expectConsumerSafeParts(temporary, generated); + await _expectCopyWithRejects(temporary, ''' import 'models.dart'; Profile misuse(Profile profile) => profile.copyWith(nickname: const Object()); '''); - _expectSuccess(await _run(temporary, ['test']), 'dart test'); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'outputs-present build_runner build', - ); - expect(_generatedFiles(temporary), generated); - } finally { - temporary.deleteSync(recursive: true); - } - }, - timeout: const Timeout(Duration(minutes: 3)), - ); + _expectSuccess(await _run(temporary, ['test']), 'dart test'); + final modelsSource = File(p.join(temporary.path, 'lib', 'models.dart')); + modelsSource.writeAsStringSync( + '${modelsSource.readAsStringSync()}\n// Force an outputs-present regeneration.\n', + ); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'outputs-present build_runner build', + ); + expect(_generatedFiles(temporary), generated); + } finally { + temporary.deleteSync(recursive: true); + } + }, timeout: const Timeout(Duration(minutes: 3))); } diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index 61f4a98c..80aec66c 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -58,7 +58,7 @@ final class Email { } @Schemable() -final class Contact with _\$ContactAck { +final class Contact { const Contact({required this.address, required this.id}); @ack.Validate.email() @@ -92,7 +92,7 @@ $_imports part 'grid.g.dart'; @Schemable() -final class Grid with _\$GridAck { +final class Grid { const Grid({required this.cells}); @Validate.minItems(2) @@ -126,7 +126,7 @@ $_imports part 'documented.g.dart'; @Schemable() -final class Documented with _\$DocumentedAck { +final class Documented { const Documented({required this.title}); /// The item title. @@ -143,15 +143,13 @@ final class Documented with _\$DocumentedAck { ); }); - test( - 'primary constructor declaring parameters support @description and @Validate', - () async { - await withEnabledExperiments( - () => _build( - { - 'primary_doc.dart': - ''' -// @dart=3.13 + test('primary constructor declaring parameters support @description and @Validate', () async { + await withEnabledExperiments( + () => _build( + { + 'primary_doc.dart': + ''' +// @dart=3.14 $_imports part 'primary_doc.g.dart'; @@ -165,28 +163,26 @@ final class const PrimaryTask({ /// @description The category id. @Validate.minLength(1) final String category = 'general', -}) with _\$PrimaryTaskAck { - static final schema = _primaryTaskSchema; +}) { } ''', - }, - outputs: { - 'test_pkg|lib/primary_doc.g.dart': decodedMatches( - allOf( - _containsCode( - "'title': Ack.string().minLength(1).describe('The item title.')", - ), - _containsCode( - "'category': Ack.string().minLength(1).describe('The category id.').withDefault('general')", - ), + }, + outputs: { + 'test_pkg|lib/primary_doc.g.dart': decodedMatches( + allOf( + _containsCode( + "'title': Ack.string().minLength(1).describe('The item title.')", + ), + _containsCode( + "'category': Ack.string().minLength(1).describe('The category id.').withDefault('general')", ), ), - }, - ), - const ['primary-constructors'], - ); - }, - ); + ), + }, + ), + const ['augmentations'], + ); + }); test('description tags describe the object and its codec', () async { const task = r"'A task the person\'s \$team can complete.'"; @@ -202,7 +198,7 @@ part 'documented.g.dart'; /// /// Shown in the task list. @Schemable() -final class Task with _\$TaskAck { +final class Task { const Task({required this.title}); /// What to do. @@ -216,7 +212,7 @@ final class Task with _\$TaskAck { * @description A note without fields. */ @Schemable() -final class Note with _\$NoteAck { +final class Note { const Note(); } ''', @@ -258,7 +254,7 @@ part 'plain.g.dart'; /// Ordinary class prose is not schema data. @Schemable() -final class Plain with _\$PlainAck { +final class Plain { const Plain({required this.title, this.note}); /// Ordinary field prose is not schema data. @@ -286,7 +282,7 @@ $_imports part 'escaped.g.dart'; @Schemable(description: 'First\\nsecond') -final class Escaped with _\$EscapedAck { +final class Escaped { const Escaped({required this.value}); /// @description A\tB @@ -316,20 +312,20 @@ part 'shapes.g.dart'; /// A shape to draw. /// @description A shape to draw. @Schemable(discriminatorKey: 'kind') -sealed class Shape with _\$ShapeAck { +sealed class Shape { const Shape(); } /// A circle. /// @description A circle. @Schemable(discriminatorValue: 'circle') -final class Circle extends Shape with _\$CircleAck { +final class Circle extends Shape { const Circle({required this.radius}); final double radius; } -final class Square extends Shape with _\$SquareAck { +final class Square extends Shape { const Square({required this.side}); final double side; @@ -369,7 +365,7 @@ $_imports part 'formats.g.dart'; @Schemable() -final class Formats with _\$FormatsAck { +final class Formats { const Formats({ required this.url, required this.uri, @@ -414,7 +410,7 @@ $_imports part 'defaults.g.dart'; @Schemable() -final class Defaults with _\$DefaultsAck { +final class Defaults { const Defaults({this.fallback = 'fallback', this.empty = null}); final String? fallback; @@ -447,7 +443,7 @@ $_imports part 'envelope.g.dart'; @Schemable() -final class Envelope with _\$EnvelopeAck { +final class Envelope { const Envelope({ required this.kind, this.payload, @@ -490,18 +486,16 @@ final class Envelope with _\$EnvelopeAck { ); }); - test( - 'emits a codec schema, presence semantics, mixin, and direct decode/encode', - () async { - await _build( - { - 'profile.dart': - ''' + test('emits a codec schema, presence semantics, augmentation, and direct decode/encode', () async { + await _build( + { + 'profile.dart': + ''' $_imports part 'profile.g.dart'; @Schemable() -final class Profile with _\$ProfileAck { +final class Profile { const Profile({ required this.bio, this.website, @@ -520,73 +514,72 @@ final class Profile with _\$ProfileAck { final Set tags; } ''', - }, - outputs: { - 'test_pkg|lib/profile.g.dart': decodedMatches( - allOf([ - contains('final _profileObject = Ack.object'), - contains( - 'final _profileWireSchema = ' - 'Ack.preserveBoundary(_profileObject)', - ), - contains('final _profileSchema = _profileObject.codec'), - contains('get wireSchema'), - contains('_profileWireSchema;'), - isNot(contains('final profileSchema =')), - contains("'bio': Ack.string().minLength(1).maxLength(500)"), - contains("'website': Ack.uri().optional().nullable()"), - contains("'nickname': Ack.string().nullable()"), - contains("'role': Ack.string().withDefault('member')"), - contains('Ack.list(Ack.string())'), - contains( - RegExp( - r'\.minItems\(1\)[\s\S]*\.unique\(\)[\s\S]*\.codec>', - ), - ), - contains('.codec('), - contains(r'decode: _$ProfileFromRuntime'), - contains(r'encode: _$ProfileToRuntime'), - contains('abstract final class ProfileSchema'), - contains( - 'static AckSchema, Profile> get schema', - ), - contains('static Profile parse('), - contains('static SchemaResult safeParse('), - contains( - 'static Profile fromJson(Map json) => parse(json)', - ), - contains('static Map encode('), - contains('static SchemaResult> safeEncode('), - contains('static Map toJsonSchema()'), - contains('static AckSchemaModel toSchemaModel()'), - contains('_profileSchema.parse(value, debugName: debugName)!'), - contains('_profileSchema.encode(value, debugName: debugName)!'), - contains(r'Profile _$ProfileFromRuntime'), - contains(r'Map _$ProfileToRuntime'), - contains("'nickname': model.nickname"), - contains(r'mixin _$ProfileAck'), - contains(r'$ProfileCopyWith get copyWith'), - contains('final class _ProfileCopyWithUnset'), - contains('const _ProfileCopyWithUnset()'), - contains( - 'static const _ProfileCopyWithUnset _ackCopyWithUnset =', + }, + outputs: { + 'test_pkg|lib/profile.g.dart': decodedMatches( + allOf([ + contains('final _profileObject = Ack.object'), + contains( + 'final _profileWireSchema = ' + 'Ack.preserveBoundary(_profileObject)', + ), + contains('final _profileSchema = _profileObject.codec'), + contains('get wireSchema'), + contains('_profileWireSchema;'), + isNot(contains('final profileSchema =')), + contains("'bio': Ack.string().minLength(1).maxLength(500)"), + contains("'website': Ack.uri().optional().nullable()"), + contains("'nickname': Ack.string().nullable()"), + contains("'role': Ack.string().withDefault('member')"), + contains('Ack.list(Ack.string())'), + contains( + RegExp( + r'\.minItems\(1\)[\s\S]*\.unique\(\)[\s\S]*\.codec>', ), - contains('Object? website = _ackCopyWithUnset'), - contains('website: identical(website, _ackCopyWithUnset)'), - contains(': website as Uri?'), - isNot(contains('_ackCopyWithOmitted')), - contains('deepEquals('), - contains('deepHashCode('), - contains('Map toJson()'), - contains('SchemaResult> safeToJson()'), - contains('ProfileSchema.encode(this as Profile)'), - contains('ProfileSchema.safeEncode(this as Profile)'), - ]), - ), - }, - ); - }, - ); + ), + contains('.codec('), + contains(r'decode: _$ProfileFromRuntime'), + contains(r'encode: _$ProfileToRuntime'), + contains('abstract final class ProfileSchema'), + contains( + 'static AckSchema, Profile> get schema', + ), + contains('static Profile parse('), + contains('static SchemaResult safeParse('), + contains( + 'static Profile fromJson(Map json) => parse(json)', + ), + contains('static Map encode('), + contains('static SchemaResult> safeEncode('), + contains('static Map toJsonSchema()'), + contains('static AckSchemaModel toSchemaModel()'), + contains('_profileSchema.parse(value, debugName: debugName)!'), + contains('_profileSchema.encode(value, debugName: debugName)!'), + contains(r'Profile _$ProfileFromRuntime'), + contains(r'Map _$ProfileToRuntime'), + contains("'nickname': model.nickname"), + contains(r'augment final class Profile'), + contains(r'_$ProfileCopyWithApi get copyWith'), + contains('final class _ProfileCopyWithUnset'), + contains('const _ProfileCopyWithUnset()'), + contains('static const _ProfileCopyWithUnset _ackCopyWithUnset ='), + contains('Object? website = _ackCopyWithUnset'), + contains('website: _ackIsCopyWithUnset(website)'), + contains(': website as Uri?'), + isNot(contains('_ackCopyWithOmitted')), + contains('deepEquals('), + contains('deepHashCode('), + contains('_AckMap<_AckString, _AckDynamic> toJson()'), + contains( + '_AckSchemaResult<_AckMap<_AckString, _AckObject?>> safeToJson()', + ), + contains(r'_$ProfileSchemaApi.encode(this)'), + contains(r'_$ProfileSchemaApi.safeEncode(this)'), + ]), + ), + }, + ); + }); test('emits custom schemas and built-in recursive type coverage', () async { await _build( @@ -609,7 +602,7 @@ AckSchema colorSchema() => Ack.string().codec( enum Role { admin, member } @Schemable(schemas: [colorSchema]) -final class Record with _\$RecordAck { +final class Record { const Record({ required this.color, required this.scores, @@ -660,7 +653,7 @@ $_imports part 'account.g.dart'; @Schemable(caseStyle: AckCaseStyle.snake) -final class Account with _\$AccountAck { +final class Account { const Account({ required this.firstName, @AckKey('avatar') required this.imageUrl, @@ -700,7 +693,7 @@ $_imports part 'a.g.dart'; @Schemable() -final class Address with _\$AddressAck { +final class Address { const Address({required this.city}); final String city; } @@ -718,7 +711,7 @@ import 'b.dart' as b; part 'order.g.dart'; @Schemable() -final class Order with _\$OrderAck { +final class Order { const Order({required this.shipping}); final a.Address shipping; } @@ -736,7 +729,7 @@ const Type otherAddressType = b.Address; ), 'test_pkg|lib/order.g.dart': decodedMatches( allOf([ - contains("'shipping': a.AddressSchema.schema"), + contains("'shipping': a.Address.schema"), contains("shipping: value['shipping'] as a.Address"), isNot(contains('b.AddressSchema')), ]), @@ -757,20 +750,20 @@ $_imports part 'pet.g.dart'; @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet({required this.id}); final String id; } @Schemable(discriminatorValue: 'cat') -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat({required super.id, required this.lives}); @Validate.min(1) @Validate.max(9) final int lives; } -final class Dog extends Pet with _\$DogAck { +final class Dog extends Pet { const Dog({required super.id, required this.breed}); final String breed; } @@ -801,8 +794,8 @@ final class Dog extends Pet with _\$DogAck { contains('encode: (model) => switch (model)'), contains(r'Cat() => _$CatToRuntime(model)'), contains(r'Dog() => _$DogToRuntime(model)'), - contains(r'mixin _$PetAck'), - contains(r'mixin _$CatAck'), + contains(r'augment sealed class Pet'), + contains(r'augment final class Cat'), contains('Cat call({'), contains('id: id ?? _source.id'), contains('get wireSchema'), @@ -826,7 +819,7 @@ $_imports part 'account.g.dart'; @Schemable(schemaName: 'WireAccountSchema') -final class Account with _\$AccountAck { +final class Account { const Account({required this.id}); final String id; } @@ -838,7 +831,7 @@ final class Account with _\$AccountAck { contains('final _accountObject = Ack.object'), contains('final _accountSchema = _accountObject.codec'), contains('abstract final class WireAccountSchema'), - contains('WireAccountSchema.encode(this as Account)'), + contains(r'_$AccountSchemaApi.encode(this)'), isNot(contains('abstract final class AccountSchema')), ]), ), @@ -858,7 +851,7 @@ $_imports part 'fields.g.dart'; @Schemable() -final class Example with _\$ExampleAck { +final class Example { const Example({this.label, this.nickname, this.title}); @Validate.optional() @@ -875,7 +868,7 @@ final class Example with _\$ExampleAck { } @Schemable() -final class InferredNotNull with _\$InferredNotNullAck { +final class InferredNotNull { const InferredNotNull({this.label}); @Validate.notNull() @@ -893,7 +886,7 @@ AckSchema nullableNameSchema() => Ack.string().nullable().codec ); @Schemable(schemas: [nullableNameSchema]) -final class OverrideNotNull with _\$OverrideNotNullAck { +final class OverrideNotNull { const OverrideNotNull({this.name}); @Validate.notNull() @@ -901,7 +894,7 @@ final class OverrideNotNull with _\$OverrideNotNullAck { } @Schemable() -final class RequiredNotNull with _\$RequiredNotNullAck { +final class RequiredNotNull { const RequiredNotNull({this.value}); @Validate.required() @@ -910,7 +903,7 @@ final class RequiredNotNull with _\$RequiredNotNullAck { } @Schemable() -final class Forced with _\$ForcedAck { +final class Forced { const Forced({this.summary}); @Validate.required() @@ -929,7 +922,7 @@ AckSchema parameterEntrySchema() => ); @Schemable(schemas: [parameterEntrySchema]) -final class CapabilityBinding with _\$CapabilityBindingAck { +final class CapabilityBinding { CapabilityBinding({ required this.name, Map parameters = const {}, @@ -997,7 +990,7 @@ import 'package:ack/annotations.dart' as annotations part 'account.g.dart'; @annotations.Schemable() -final class Account with _\$AccountAck { +final class Account { const Account(); } ''', @@ -1016,12 +1009,10 @@ final class Account with _\$AccountAck { ); }); - test( - '@Validate.* constraints work with single package:ack/ack.dart import alongside same-named types and constructor parameters', - () async { - await _build( - { - 'check_models.dart': ''' + test('@Validate.* constraints work with single package:ack/ack.dart import alongside same-named types and constructor parameters', () async { + await _build( + { + 'check_models.dart': ''' import 'package:ack/ack.dart'; part 'check_models.g.dart'; @@ -1037,7 +1028,7 @@ final class Uuid { } @Schemable() -final class CheckedRecord with _\$CheckedRecordAck { +final class CheckedRecord { const CheckedRecord({ required this.email, required this.id, @@ -1104,38 +1095,35 @@ final class CheckedRecord with _\$CheckedRecordAck { final String handle; } ''', - }, - outputs: { - 'test_pkg|lib/check_models.g.dart': decodedMatches( - allOf([ - _containsCode("'email': Ack.string().email()"), - _containsCode("'id': Ack.string().uuid()"), - _containsCode("'website': Ack.string().url()"), - _containsCode("'resourceUri': Ack.string().uri()"), - _containsCode("'birthDate': Ack.string().date()"), - _containsCode("'createdAt': Ack.string().datetime()"), - _containsCode("'clientIp': Ack.string().ip(version: 4)"), - _containsCode("'v4': Ack.string().ipv4()"), - _containsCode("'v6': Ack.string().ipv6()"), - _containsCode( - r"'code': Ack.string().notEmpty().matches('^[A-Z]+\$')", - ), - _containsCode( - "'score': Ack.integer().min(0).max(100).multipleOf(5).positive()", - ), - _containsCode("'debt': Ack.double().negative()"), - _containsCode( - "'tags': Ack.list(Ack.string()).notEmpty().minItems(1).maxItems(5).unique()", - ), - _containsCode( - "'handle': Ack.string().minLength(2).maxLength(20)", - ), - ]), - ), - }, - ); - }, - ); + }, + outputs: { + 'test_pkg|lib/check_models.g.dart': decodedMatches( + allOf([ + _containsCode("'email': Ack.string().email()"), + _containsCode("'id': Ack.string().uuid()"), + _containsCode("'website': Ack.string().url()"), + _containsCode("'resourceUri': Ack.string().uri()"), + _containsCode("'birthDate': Ack.string().date()"), + _containsCode("'createdAt': Ack.string().datetime()"), + _containsCode("'clientIp': Ack.string().ip(version: 4)"), + _containsCode("'v4': Ack.string().ipv4()"), + _containsCode("'v6': Ack.string().ipv6()"), + _containsCode( + r"'code': Ack.string().notEmpty().matches('^[A-Z]+\$')", + ), + _containsCode( + "'score': Ack.integer().min(0).max(100).multipleOf(5).positive()", + ), + _containsCode("'debt': Ack.double().negative()"), + _containsCode( + "'tags': Ack.list(Ack.string()).notEmpty().minItems(1).maxItems(5).unique()", + ), + _containsCode("'handle': Ack.string().minLength(2).maxLength(20)"), + ]), + ), + }, + ); + }); test( 'emits JsonMaybe decode and encode presence checks for PATCH models', @@ -1148,7 +1136,7 @@ import 'package:ack/ack.dart'; part 'patch_models.g.dart'; @Schemable() -final class UserPatch with _$UserPatchAck { +final class UserPatch { const UserPatch({ required this.nickname, this.title = const JsonMaybe.absent(), diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 7442ff92..047f6299 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -64,7 +64,7 @@ final class UriText implements JsonConverter { ''' $converter @Schemable() -final class Link with _\$LinkAck { +final class Link { const Link({required this.target}); @UriText() @@ -82,7 +82,7 @@ final class Link with _\$LinkAck { $converter @Schemable() @UriText() -final class Link with _\$LinkAck { +final class Link { const Link({required this.target}); final Uri target; @@ -100,7 +100,7 @@ final class Link with _\$LinkAck { await _expectFailure( ''' @Schemable() -final class Range with _\$RangeAck { +final class Range { const Range({required this.limit}); @Validate.max(double.infinity) @@ -114,7 +114,7 @@ final class Range with _\$RangeAck { await _expectFailure( ''' @Schemable() -final class Range with _\$RangeAck { +final class Range { const Range({required this.step}); @Validate.multipleOf(double.nan) @@ -131,7 +131,7 @@ final class Range with _\$RangeAck { await _expectFailure( ''' @Schemable() -final class Person with _\$PersonAck { +final class Person { const Person({required this.home, required this.shade}); final Addr home; final Shade shade; @@ -157,7 +157,7 @@ import 'package:ack/ack.dart'; part 'address.g.dart'; @Schemable() -final class Address with _\$AddressAck { +final class Address { const Address({required this.city}); final String city; } @@ -168,7 +168,7 @@ final class Address with _\$AddressAck { await _expectFailure( ''' @Schemable() -final class Swatch with _\$SwatchAck { +final class Swatch { const Swatch({required this.shade}); final Shade shade; } @@ -193,7 +193,7 @@ typedef Shade = Color; await _expectFailure( ''' @Schemable(name: 'IgnoredName') -final class Account with _\$AccountAck { +final class Account { const Account({required this.id}); final String id; } @@ -211,7 +211,7 @@ final class Account with _\$AccountAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); final String name; @@ -228,7 +228,7 @@ final class _\$UserCopyWith {} await _expectFailure( ''' @Schemable(description: ' ') -final class Item with _\$ItemAck { +final class Item { const Item({required this.value}); final String value; } @@ -243,7 +243,7 @@ final class Item with _\$ItemAck { /// @description First. /// @description Second. @Schemable() -final class Item with _\$ItemAck { +final class Item { const Item({required this.value}); final String value; } @@ -256,7 +256,7 @@ final class Item with _\$ItemAck { await _expectFailure( ''' @Schemable() -final class Item with _\$ItemAck { +final class Item { const Item({required this.value}); /// @description final String value; @@ -270,7 +270,7 @@ final class Item with _\$ItemAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); @Validate.min(1) @@ -285,7 +285,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.age}); @Validate.minLength(1) @@ -300,7 +300,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); @Validate.uniqueItems() @@ -317,7 +317,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.tags}); final List tags; @@ -334,7 +334,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.tags}); final Set tags; @@ -349,7 +349,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class Stats with _\$StatsAck { +final class Stats { const Stats({required this.values}); final Map values; @@ -363,7 +363,7 @@ final class Stats with _\$StatsAck { await _expectFailure( ''' @Schemable() -final class Envelope with _\$EnvelopeAck { +final class Envelope { const Envelope({required this.items}); final List items; @@ -377,7 +377,7 @@ final class Envelope with _\$EnvelopeAck { await _expectFailure( ''' @Schemable() -final class Stats with _\$StatsAck { +final class Stats { const Stats({required this.scores}); final List> scores; @@ -391,7 +391,7 @@ final class Stats with _\$StatsAck { await _expectFailure( ''' @Schemable() -final class Stats with _\$StatsAck { +final class Stats { const Stats({required this.scores}); final Map scores; @@ -405,7 +405,7 @@ final class Stats with _\$StatsAck { await _expectFailure( ''' @Schemable() -final class Payload with _\$PayloadAck { +final class Payload { const Payload({required this.value}); final dynamic value; @@ -433,7 +433,7 @@ final class _User { await _expectFailure( ''' @Schemable() -class User with _\$UserAck { +class User { const User({required this.name}); final String name; @@ -447,7 +447,7 @@ class User with _\$UserAck { await _expectFailure( ''' @Schemable() -abstract class HabitDto with _\$HabitDtoAck { +abstract class HabitDto { const HabitDto._(); const factory HabitDto({required String name}) = _HabitDto; } @@ -465,11 +465,11 @@ abstract class HabitDto with _\$HabitDtoAck { await _expectFailure( ''' @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } -class Cat extends Pet with _\$CatAck { +class Cat extends Pet { const Cat(); } ''', @@ -481,7 +481,7 @@ class Cat extends Pet with _\$CatAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { User({required this.name}); String name; @@ -495,7 +495,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this._secret}); final String _secret; @@ -509,7 +509,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable(unknownProperties: AckUnknownPropertyPolicy.capture) -final class Config with _\$ConfigAck { +final class Config { const Config({required this.name}); final String name; @@ -523,7 +523,7 @@ final class Config with _\$ConfigAck { await _expectFailure( ''' @Schemable(unknownProperties: AckUnknownPropertyPolicy.capture) -final class Config with _\$ConfigAck { +final class Config { const Config({required this.additionalProperties}); final Map additionalProperties; @@ -540,7 +540,7 @@ final class Config with _\$ConfigAck { unknownProperties: AckUnknownPropertyPolicy.capture, captureField: '_extras', ) -final class Config with _\$ConfigAck { +final class Config { const Config({required this.name}); final String name; @@ -554,7 +554,7 @@ final class Config with _\$ConfigAck { await _expectFailure( ''' @Schemable(captureField: 'args') -final class Config with _\$ConfigAck { +final class Config { const Config({required this.name}); final String name; @@ -568,11 +568,11 @@ final class Config with _\$ConfigAck { await _expectFailure( ''' @Schemable() -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat(); } ''', @@ -584,17 +584,17 @@ final class Cat extends Pet with _\$CatAck { await _expectFailure( ''' @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } @Schemable(discriminatorValue: 'pet') -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat(); } @Schemable(discriminatorValue: 'pet') -final class Dog extends Pet with _\$DogAck { +final class Dog extends Pet { const Dog(); } ''', @@ -606,7 +606,7 @@ final class Dog extends Pet with _\$DogAck { await _expectFailure( ''' @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } @@ -626,11 +626,11 @@ final class Cat extends Mammal { await _expectFailure( ''' @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat(); int get type => 1; @@ -644,12 +644,12 @@ final class Cat extends Pet with _\$CatAck { await _expectFailure( ''' @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } @Schemable(discriminatorValue: 'cat') -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat(); String get type => 'dog'; @@ -664,7 +664,7 @@ final class Cat extends Pet with _\$CatAck { ''' @Schemable() @JsonSerializable() -final class User with _\$UserAck { +final class User { const User({required this.name}); final String name; @@ -678,7 +678,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); @JsonKey(name: 'wire_name') @@ -696,7 +696,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({@JsonKey(name: 'wire_name') required this.name}); final String name; @@ -713,7 +713,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User(); } ''', @@ -732,12 +732,12 @@ part 'model.g.dart'; await _expectFailure( ''' @Schemable(schemaName: 'PersonSchema') -final class User with _\$UserAck { +final class User { const User(); } @Schemable(schemaName: 'PersonSchema') -final class Admin with _\$AdminAck { +final class Admin { const Admin(); } ''', @@ -749,7 +749,7 @@ final class Admin with _\$AdminAck { await _expectFailure( ''' @Schemable(schemaName: 'personSchema') -final class User with _\$UserAck { +final class User { const User(); } ''', @@ -761,7 +761,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User(); } @@ -775,7 +775,7 @@ abstract final class UserSchema {} await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User(); } @@ -789,11 +789,11 @@ final _userSchema = Ack.string(); await _expectFailure( ''' @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat(); } @@ -804,7 +804,6 @@ abstract final class CatSchema {} }); for (final collision in <({String name, String declaration})>[ - (name: r'_$UserAck', declaration: r'mixin _$UserAck {}'), (name: r'_userObject', declaration: r'final _userObject = Ack.object({});'), ( name: r'_userWireSchema', @@ -817,7 +816,7 @@ abstract final class CatSchema {} await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); final String name; @@ -834,11 +833,11 @@ ${collision.declaration} await _expectFailure( ''' @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat(); } @@ -852,17 +851,17 @@ final _catObject = Ack.object({}); await _expectFailure( ''' @Schemable(discriminatorKey: 'type') -sealed class Pet with _\$PetAck { +sealed class Pet { const Pet(); } @Schemable(schemaName: 'UpperCatSchema') -final class Cat extends Pet with _\$CatAck { +final class Cat extends Pet { const Cat(); } @Schemable(schemaName: 'LowerCatSchema') -final class cat extends Pet with _\$catAck { +final class cat extends Pet { const cat(); } ''', @@ -874,7 +873,7 @@ final class cat extends Pet with _\$catAck { await _expectFailure( ''' @Schemable() -final class Node with _\$NodeAck { +final class Node { const Node({this.child}); final Node? child; @@ -888,14 +887,14 @@ final class Node with _\$NodeAck { await _expectFailure( ''' @Schemable() -final class Parent with _\$ParentAck { +final class Parent { const Parent({required this.child}); final Child child; } @Schemable() -final class Child with _\$ChildAck { +final class Child { const Child({required this.parent}); final Parent parent; @@ -909,7 +908,7 @@ final class Child with _\$ChildAck { await _expectFailure( ''' @Schemable() -final class Parent with _\$ParentAck { +final class Parent { const Parent({required this.child}); final Child child; @@ -931,7 +930,7 @@ import 'model.dart'; part 'child.g.dart'; @Schemable() -final class Child with _\$ChildAck { +final class Child { const Child({required this.parent}); final Parent parent; @@ -941,47 +940,39 @@ final class Child with _\$ChildAck { ); }); - test( - 'rejects an imported class-first model whose facade is hidden', - () async { - await _expectFailure( - ''' -@Schemable() -final class Order with _\$OrderAck { - const Order({required this.address}); - - final Address address; -} -''', - ['AddressSchema', 'hidden', 'show Address, AddressSchema'], - head: ''' + test('imports only the model when its generated facade is hidden', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + final errors = []; + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': ''' import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; import 'address.dart' show Address; - part 'model.g.dart'; +@Schemable() +final class const Order({required final Address address}); ''', - extraSources: { - 'address.dart': ''' + 'test_pkg|lib/address.dart': ''' import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - part 'address.g.dart'; - @Schemable() -final class Address with _\$AddressAck { - const Address({required this.city}); - - final String city; -} +final class const Address({required final String city}); ''', - }, - allowedOutputs: { - 'test_pkg|lib/address.g.dart': decodedMatches(anything), - }, - ); - }, - ); + }, + generateFor: const {'test_pkg|lib/model.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/model.g.dart': decodedMatches(contains('Address.schema')), + 'test_pkg|lib/address.g.dart': decodedMatches(anything), + }, + onLog: (log) { + if (log.level == Level.SEVERE) errors.add(log.message); + }, + ); + expect(errors, isEmpty); + }); test('allows split imports for a class-first model and its facade', () async { final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); @@ -998,7 +989,7 @@ import 'address.dart' show AddressSchema; part 'model.g.dart'; @Schemable() -final class Order with _\$OrderAck { +final class Order { const Order({required this.address}); final Address address; @@ -1011,7 +1002,7 @@ import 'package:ack/annotations.dart'; part 'address.g.dart'; @Schemable() -final class Address with _\$AddressAck { +final class Address { const Address({required this.city}); final String city; @@ -1025,56 +1016,51 @@ final class Address with _\$AddressAck { readerWriter: readerWriter, outputs: { 'test_pkg|lib/address.g.dart': decodedMatches(anything), - 'test_pkg|lib/model.g.dart': decodedMatches( - contains('AddressSchema.schema'), - ), + 'test_pkg|lib/model.g.dart': decodedMatches(contains('Address.schema')), }, ); }); - test('rejects a class-first facade hidden by a barrel export', () async { - await _expectFailure( - ''' -@Schemable() -final class Order with _\$OrderAck { - const Order({required this.address}); - - final Address address; -} -''', - ['AddressSchema', 'export combinator'], - head: ''' + test('composes a model exported by a model-only barrel', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + final errors = []; + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': ''' import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; import 'exports.dart'; - part 'model.g.dart'; +@Schemable() +final class const Order({required final Address address}); ''', - extraSources: { - 'exports.dart': "export 'address.dart' show Address;", - 'address.dart': ''' + 'test_pkg|lib/exports.dart': "export 'address.dart' show Address;", + 'test_pkg|lib/address.dart': ''' import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - part 'address.g.dart'; - @Schemable() -final class Address with _\$AddressAck { - const Address({required this.city}); - - final String city; -} +final class const Address({required final String city}); ''', }, - allowedOutputs: {'test_pkg|lib/address.g.dart': decodedMatches(anything)}, + generateFor: const {'test_pkg|lib/model.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/model.g.dart': decodedMatches(contains('Address.schema')), + 'test_pkg|lib/address.g.dart': decodedMatches(anything), + }, + onLog: (log) { + if (log.level == Level.SEVERE) errors.add(log.message); + }, ); + expect(errors, isEmpty); }); test('rejects case-style key collisions', () async { await _expectFailure( ''' @Schemable(caseStyle: AckCaseStyle.snake) -final class Collision with _\$CollisionAck { +final class Collision { const Collision({required this.fooBar, required this.foo_bar}); final String fooBar; @@ -1085,25 +1071,11 @@ final class Collision with _\$CollisionAck { ); }); - test('rejects a missing generated mixin', () async { - await _expectFailure( - ''' -@Schemable() -final class User { - const User({required this.name}); - - final String name; -} -''', - ['User', r'_$UserAck'], - ); - }); - test('rejects blank @AckKey(...)', () async { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); @AckKey(' ') @@ -1118,7 +1090,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); @AckKey('a') @@ -1139,7 +1111,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); @Validate.optional() @@ -1157,7 +1129,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({this.name}); @Validate.optional() @@ -1174,7 +1146,7 @@ final class User with _\$UserAck { await _expectFailure( ''' @Schemable() -final class User with _\$UserAck { +final class User { const User(this.name, String extra) : label = extra; final String name; @@ -1185,107 +1157,96 @@ final class User with _\$UserAck { ); }); - test( - 'allows an imported class-first model with hidden facade when it exposes static schema', - () async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - ackModelBuilder(BuilderOptions.empty), - { - 'test_pkg|lib/model.dart': ''' + test('an imported class-first model owns its generated schema', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': ''' import 'package:ack/ack.dart'; import 'address.dart' show Address; part 'model.g.dart'; @Schemable() -final class Order with _\$OrderAck { +final class Order { const Order({required this.address}); final Address address; } ''', - 'test_pkg|lib/address.dart': ''' + 'test_pkg|lib/address.dart': ''' import 'package:ack/ack.dart'; part 'address.g.dart'; @Schemable() -final class Address with _\$AddressAck { +final class Address { const Address({required this.city}); final String city; - static final schema = _addressSchema; -} -''', - }, - generateFor: const { - 'test_pkg|lib/model.dart', - 'test_pkg|lib/address.dart', - }, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/address.g.dart': decodedMatches(anything), - 'test_pkg|lib/model.g.dart': decodedMatches( - contains('Address.schema'), - ), - }, - ); - }, - ); +} +''', + }, + generateFor: const { + 'test_pkg|lib/model.dart', + 'test_pkg|lib/address.dart', + }, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/address.g.dart': decodedMatches(anything), + 'test_pkg|lib/model.g.dart': decodedMatches(contains('Address.schema')), + }, + ); + }); - test( - 'rejects invalid @Validate.* constraints on mismatched field types or values', - () async { - await _expectFailure( - ''' + test('rejects invalid @Validate.* constraints on mismatched field types or values', () async { + await _expectFailure( + ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); @Validate.min(1) final String name; } ''', - ['User.name', '@Validate.min', 'String', '@Validate.minLength'], - ); - await _expectFailure( - ''' + ['User.name', '@Validate.min', 'String', '@Validate.minLength'], + ); + await _expectFailure( + ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.ip}); @Validate.ip(version: 5) final String ip; } ''', - ['User.ip', '@Validate.ip(version: 5)', 'version must be 4 or 6.'], - ); - await _expectFailure( - ''' + ['User.ip', '@Validate.ip(version: 5)', 'version must be 4 or 6.'], + ); + await _expectFailure( + ''' @Schemable() -final class User with _\$UserAck { +final class User { const User({@Validate.minLength(1) required this.age}); final int age; } ''', - ['User.age', '@Validate.minLength', 'int', '@Validate.min'], - ); - }, - ); + ['User.age', '@Validate.minLength', 'int', '@Validate.min'], + ); + }); - test( - 'resolves constructor parameter annotations alongside same-build schema-first generated model', - () async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - ackModelBuilder(BuilderOptions.empty), - { - 'test_pkg|lib/model.dart': ''' + test('resolves constructor parameter annotations alongside same-build schema-first generated model', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': ''' import 'package:ack/ack.dart'; part 'model.g.dart'; @@ -1294,7 +1255,7 @@ part 'model.g.dart'; final addressSchema = Ack.object({'city': Ack.string()}); @Schemable() -final class Order with _\$OrderAck { +final class Order { const Order({ @AckKey('shipping_address') required this.shipping, @Validate.minItems(1) this.history = const [], @@ -1303,22 +1264,20 @@ final class Order with _\$OrderAck { final Address shipping; final List
history; - static final schema = _orderSchema; -} -''', - }, - generateFor: const {'test_pkg|lib/model.dart'}, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/model.g.dart': decodedMatches( - allOf([ - contains("'shipping_address': Address.\$ack.schema"), - contains('Ack.list(Address.\$ack.schema)'), - contains('.minItems(1)'), - ]), - ), - }, - ); - }, - ); +} +''', + }, + generateFor: const {'test_pkg|lib/model.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/model.g.dart': decodedMatches( + allOf([ + contains("'shipping_address': Address.\$ack.schema"), + contains('Ack.list(Address.\$ack.schema)'), + contains('.minItems(1)'), + ]), + ), + }, + ); + }); } diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 9659ab62..880adf9b 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -6,6 +6,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + void _copyDirectory( Directory source, Directory destination, { @@ -29,7 +31,7 @@ void _copyDirectory( } Future _run(Directory directory, List arguments) { - return Process.run('dart', arguments, workingDirectory: directory.path); + return runDart(directory, arguments); } void _expectSuccess(ProcessResult result, String command) { @@ -59,9 +61,8 @@ void main() { 'example clean-builds, analyzes, tests, and regenerates deterministically', () async { var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { projectRoot = projectRoot.parent; } final sourceExample = Directory(p.join(projectRoot.path, 'example')); @@ -74,19 +75,18 @@ void main() { try { _copyDirectory(sourceExample, temporaryExample); - File( - p.join(temporaryExample.path, 'analysis_options.yaml'), - ).writeAsStringSync(''' + File(p.join(temporaryExample.path, 'analysis_options.yaml')) + .writeAsStringSync(''' analyzer: language: strict-casts: true '''); - File(p.join(temporaryExample.path, 'pubspec.yaml')).writeAsStringSync( - ''' + File(p.join(temporaryExample.path, 'pubspec.yaml')) + .writeAsStringSync(''' name: ack_example publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -98,8 +98,7 @@ dev_dependencies: dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} -''', - ); +'''); _expectSuccess( await _run(temporaryExample, ['pub', 'get', '--offline']), @@ -125,7 +124,7 @@ dependency_overrides: for (final entry in first.entries) { final isClassFirst = entry.key.contains('class_first_models'); if (isClassFirst) { - expect(entry.value, contains(r'mixin _$AccountAck')); + expect(entry.value, contains(r'augment final class Account')); expect(entry.value, contains('final _accountSchema')); expect(entry.value, contains('abstract final class AccountSchema')); expect(entry.value, contains(r'decode: _$AccountFromRuntime')); @@ -159,9 +158,8 @@ dependency_overrides: test('rebuilds when generated outputs are already present', () async { var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { projectRoot = projectRoot.parent; } final sourceExample = Directory(p.join(projectRoot.path, 'example')); @@ -174,9 +172,8 @@ dependency_overrides: try { _copyDirectory(sourceExample, temporaryExample, keepGenerated: true); - File( - p.join(temporaryExample.path, 'analysis_options.yaml'), - ).writeAsStringSync(''' + File(p.join(temporaryExample.path, 'analysis_options.yaml')) + .writeAsStringSync(''' analyzer: language: strict-casts: true @@ -185,7 +182,7 @@ analyzer: name: ack_example publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} diff --git a/packages/ack_generator/test/integration/inference_library_test.dart b/packages/ack_generator/test/integration/inference_library_test.dart index 67af42da..479a6765 100644 --- a/packages/ack_generator/test/integration/inference_library_test.dart +++ b/packages/ack_generator/test/integration/inference_library_test.dart @@ -52,8 +52,7 @@ void main() { await readerWriter.testing.loadIsolateSources(); await testBuilder( const _InferenceProbe( - sourceComment: - '/// The item title.\r\n/// @description It appears on screen.\r\n/// More prose.', + sourceComment: '/// The item title.\r\n/// @description It appears on screen.\r\n/// More prose.', ), { 'test_pkg|lib/widget.dart': ''' @@ -149,15 +148,13 @@ void widget({@Validate.maxItems(3) required Set> cells}) {} ); }); - test( - 'a second generator applies scalar item constraints and collection constraints on List', - () async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - const _InferenceProbe(parameterName: 'weekdays'), - { - 'test_pkg|lib/widget.dart': ''' + test('a second generator applies scalar item constraints and collection constraints on List', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + const _InferenceProbe(parameterName: 'weekdays'), + { + 'test_pkg|lib/widget.dart': ''' import 'package:ack/annotations.dart'; void widget({ @@ -168,15 +165,14 @@ void widget({ required List weekdays, }) {} ''', - }, - generateFor: const {'test_pkg|lib/widget.dart'}, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/widget.probe': decodedMatches( - 'Ack.list(Ack.integer().min(1).max(7)).minItems(1).unique()', - ), - }, - ); - }, - ); + }, + generateFor: const {'test_pkg|lib/widget.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/widget.probe': decodedMatches( + 'Ack.list(Ack.integer().min(1).max(7)).minItems(1).unique()', + ), + }, + ); + }); } diff --git a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart index 1c560b26..f9f4d106 100644 --- a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart @@ -7,8 +7,10 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + Future _run(Directory project, List args) => - Process.run('dart', args, workingDirectory: project.path); + runDart(project, args); void _succeeds(ProcessResult result, String command) { expect( @@ -23,9 +25,8 @@ void main() { 'JSON Schema builds into usable models in one deterministic build', () async { var repository = Directory.current; - while (!Directory( - p.join(repository.path, 'packages', 'ack_generator'), - ).existsSync()) { + while (!Directory(p.join(repository.path, 'packages', 'ack_generator')) + .existsSync()) { repository = repository.parent; } final project = await Directory.systemTemp.createTemp('ack_json_schema_'); @@ -36,7 +37,7 @@ void main() { name: ack_json_schema_runtime publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(repository.path, 'packages', 'ack')} @@ -90,9 +91,8 @@ targets: } } '''); - File( - p.join(project.path, 'test', 'models_test.dart'), - ).writeAsStringSync(r''' + File(p.join(project.path, 'test', 'models_test.dart')) + .writeAsStringSync(r''' import 'package:ack_json_schema_runtime/contract.schema.dart'; import 'package:test/test.dart'; @@ -118,9 +118,8 @@ void main() { } '''); - File( - p.join(project.path, 'lib', 'advanced.schema.json'), - ).writeAsStringSync(r''' + File(p.join(project.path, 'lib', 'advanced.schema.json')) + .writeAsStringSync(r''' { "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { @@ -151,9 +150,8 @@ void main() { ] } '''); - File( - p.join(project.path, 'test', 'advanced_test.dart'), - ).writeAsStringSync(r''' + File(p.join(project.path, 'test', 'advanced_test.dart')) + .writeAsStringSync(r''' import 'package:ack_json_schema_runtime/advanced.schema.dart'; import 'package:test/test.dart'; @@ -214,13 +212,11 @@ void main() { }, }; for (final entry in regressions.entries) { - File( - p.join(project.path, 'lib', '${entry.key}.schema.json'), - ).writeAsStringSync(jsonEncode({r'$defs': entry.value})); + File(p.join(project.path, 'lib', '${entry.key}.schema.json')) + .writeAsStringSync(jsonEncode({r'$defs': entry.value})); } - File( - p.join(project.path, 'test', 'regressions_test.dart'), - ).writeAsStringSync(r''' + File(p.join(project.path, 'test', 'regressions_test.dart')) + .writeAsStringSync(r''' import 'package:ack_json_schema_runtime/bounds.schema.dart'; import 'package:ack_json_schema_runtime/patterns.schema.dart'; import 'package:ack_json_schema_runtime/types.schema.dart' as types; @@ -266,9 +262,8 @@ void main() { ); final outputs = { for (final suffix in ['.dart', '.g.dart']) - suffix: File( - p.join(project.path, 'lib', 'contract.schema$suffix'), - ).readAsStringSync(), + suffix: File(p.join(project.path, 'lib', 'contract.schema$suffix')) + .readAsStringSync(), }; expect(outputs['.dart'], contains('unknown_properties: preserve')); expect(outputs['.g.dart'], contains('class Listing')); @@ -289,14 +284,12 @@ void main() { ); expect({ for (final suffix in outputs.keys) - suffix: File( - p.join(project.path, 'lib', 'contract.schema$suffix'), - ).readAsStringSync(), + suffix: File(p.join(project.path, 'lib', 'contract.schema$suffix')) + .readAsStringSync(), }, outputs); expect( - File( - p.join(project.path, 'lib', 'advanced.schema.dart'), - ).readAsStringSync(), + File(p.join(project.path, 'lib', 'advanced.schema.dart')) + .readAsStringSync(), advancedOutput, ); diff --git a/packages/ack_generator/test/integration/json_serializable_build_test.dart b/packages/ack_generator/test/integration/json_serializable_build_test.dart index ae8ac88d..53aba4d7 100644 --- a/packages/ack_generator/test/integration/json_serializable_build_test.dart +++ b/packages/ack_generator/test/integration/json_serializable_build_test.dart @@ -6,18 +6,19 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + Directory _projectRoot() { var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { projectRoot = projectRoot.parent; } return projectRoot; } Future _run(Directory directory, List arguments) { - return Process.run('dart', arguments, workingDirectory: directory.path); + return runDart(directory, arguments); } void _expectSuccess(ProcessResult result, String command) { @@ -37,20 +38,18 @@ int _helperDefinitionCount(String source, String className, String suffix) { } void main() { - test( - 'ack_generator alone generates direct runtime helpers in .g.dart without json_serializable', - () async { - final projectRoot = _projectRoot(); - final temporary = await Directory.systemTemp.createTemp( - 'ack_json_ack_only_', - ); - try { - Directory(p.join(temporary.path, 'lib')).createSync(); - File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' + test('ack_generator alone generates direct runtime helpers in .g.dart without json_serializable', () async { + final projectRoot = _projectRoot(); + final temporary = await Directory.systemTemp.createTemp( + 'ack_json_ack_only_', + ); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' name: ack_json_ack_only publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -62,7 +61,7 @@ dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} '''); - File(p.join(temporary.path, 'lib', 'user.dart')).writeAsStringSync(r''' + File(p.join(temporary.path, 'lib', 'user.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; part 'user.g.dart'; @@ -74,46 +73,41 @@ final userSchema = Ack.object({ }); '''); - _expectSuccess( - await _run(temporary, ['pub', 'get', '--offline']), - 'dart pub get --offline', - ); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'clean build_runner build', - ); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'clean build_runner build', + ); - final generatedPart = File( - p.join(temporary.path, 'lib', 'user.g.dart'), - ).readAsStringSync(); + final generatedPart = File(p.join(temporary.path, 'lib', 'user.g.dart')) + .readAsStringSync(); - expect(generatedPart, contains('AckModelGenerator')); - expect(generatedPart, contains('User._fromAckRuntime')); - expect(generatedPart, contains('model._toAckRuntime()')); - expect(generatedPart, isNot(contains('generatedJson'))); - expect(generatedPart, isNot(contains('JsonSerializableGenerator'))); - } finally { - temporary.deleteSync(recursive: true); - } - }, - timeout: const Timeout(Duration(minutes: 3)), - ); + expect(generatedPart, contains('AckModelGenerator')); + expect(generatedPart, contains('User._fromAckRuntime')); + expect(generatedPart, contains('model._toAckRuntime()')); + expect(generatedPart, isNot(contains('generatedJson'))); + expect(generatedPart, isNot(contains('JsonSerializableGenerator'))); + } finally { + temporary.deleteSync(recursive: true); + } + }, timeout: const Timeout(Duration(minutes: 3))); - test( - 'ordinary json_serializable coexists in the same .g.dart part via combining_builder', - () async { - final projectRoot = _projectRoot(); - final temporary = await Directory.systemTemp.createTemp( - 'ack_json_coexist_', - ); - try { - Directory(p.join(temporary.path, 'lib')).createSync(); - Directory(p.join(temporary.path, 'test')).createSync(); - File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' + test('ordinary json_serializable coexists in the same .g.dart part via combining_builder', () async { + final projectRoot = _projectRoot(); + final temporary = await Directory.systemTemp.createTemp( + 'ack_json_coexist_', + ); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + Directory(p.join(temporary.path, 'test')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' name: ack_json_coexist publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -131,7 +125,7 @@ dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} '''); - File(p.join(temporary.path, 'build.yaml')).writeAsStringSync(''' + File(p.join(temporary.path, 'build.yaml')).writeAsStringSync(''' targets: \$default: builders: @@ -139,7 +133,7 @@ targets: options: include_if_null: true '''); - File(p.join(temporary.path, 'lib', 'same.dart')).writeAsStringSync(r''' + File(p.join(temporary.path, 'lib', 'same.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -161,9 +155,8 @@ final class SameEnvelope { Map toJson() => _$SameEnvelopeToJson(this); } '''); - File( - p.join(temporary.path, 'test', 'runtime_test.dart'), - ).writeAsStringSync(r''' + File(p.join(temporary.path, 'test', 'runtime_test.dart')) + .writeAsStringSync(r''' import 'package:ack_json_coexist/same.dart'; import 'package:test/test.dart'; @@ -178,41 +171,35 @@ void main() { } '''); - _expectSuccess( - await _run(temporary, ['pub', 'get', '--offline']), - 'dart pub get --offline', - ); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'build_runner build', - ); - _expectSuccess( - await _run(temporary, ['analyze', '--fatal-infos']), - 'dart analyze --fatal-infos', - ); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'build_runner build', + ); + _expectSuccess( + await _run(temporary, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); - final combinedJson = File( - p.join(temporary.path, 'lib', 'same.g.dart'), - ).readAsStringSync(); - expect(_helperDefinitionCount(combinedJson, 'User', 'FromJson'), 0); - expect(_helperDefinitionCount(combinedJson, 'User', 'ToJson'), 0); - expect( - _helperDefinitionCount(combinedJson, 'SameEnvelope', 'FromJson'), - 1, - ); - expect( - _helperDefinitionCount(combinedJson, 'SameEnvelope', 'ToJson'), - 1, - ); - expect(combinedJson, contains('User._fromAckRuntime')); - expect(combinedJson, contains('JsonSerializableGenerator')); - _expectSuccess(await _run(temporary, ['test']), 'dart test'); - } finally { - temporary.deleteSync(recursive: true); - } - }, - timeout: const Timeout(Duration(minutes: 3)), - ); + final combinedJson = File(p.join(temporary.path, 'lib', 'same.g.dart')) + .readAsStringSync(); + expect(_helperDefinitionCount(combinedJson, 'User', 'FromJson'), 0); + expect(_helperDefinitionCount(combinedJson, 'User', 'ToJson'), 0); + expect( + _helperDefinitionCount(combinedJson, 'SameEnvelope', 'FromJson'), + 1, + ); + expect(_helperDefinitionCount(combinedJson, 'SameEnvelope', 'ToJson'), 1); + expect(combinedJson, contains('User._fromAckRuntime')); + expect(combinedJson, contains('JsonSerializableGenerator')); + _expectSuccess(await _run(temporary, ['test']), 'dart test'); + } finally { + temporary.deleteSync(recursive: true); + } + }, timeout: const Timeout(Duration(minutes: 3))); test( 'prefixed barrel Schemable imports compile cleanly into .g.dart', @@ -228,7 +215,7 @@ void main() { name: ack_json_barrel publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -241,9 +228,8 @@ dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} '''); - File( - p.join(temporary.path, 'lib', 'role.dart'), - ).writeAsStringSync('enum Role { admin, member }\n'); + File(p.join(temporary.path, 'lib', 'role.dart')) + .writeAsStringSync('enum Role { admin, member }\n'); File(p.join(temporary.path, 'lib', 'support.dart')).writeAsStringSync( "export 'package:ack/ack.dart';\n" "export 'package:ack/annotations.dart';\n" @@ -260,9 +246,8 @@ final userSchema = support.Ack.object({ 'role': support.Ack.enumValues(support.Role.values), }); '''); - File( - p.join(temporary.path, 'test', 'runtime_test.dart'), - ).writeAsStringSync(r''' + File(p.join(temporary.path, 'test', 'runtime_test.dart')) + .writeAsStringSync(r''' import 'package:ack_json_barrel/user.dart'; import 'package:test/test.dart'; @@ -289,9 +274,8 @@ void main() { 'dart analyze --fatal-infos', ); - final generatedPart = File( - p.join(temporary.path, 'lib', 'user.g.dart'), - ).readAsStringSync(); + final generatedPart = File(p.join(temporary.path, 'lib', 'user.g.dart')) + .readAsStringSync(); expect(generatedPart, contains('support.AckModelAdapter')); expect(generatedPart, contains('final support.Role role;')); _expectSuccess(await _run(temporary, ['test']), 'dart test'); diff --git a/packages/ack_generator/test/integration/library_schema_test.dart b/packages/ack_generator/test/integration/library_schema_test.dart index cfa489fd..48c4ff96 100644 --- a/packages/ack_generator/test/integration/library_schema_test.dart +++ b/packages/ack_generator/test/integration/library_schema_test.dart @@ -3,11 +3,9 @@ import 'package:test/test.dart'; import 'schema_resolution_support.dart'; void main() { - test( - 'automatically detects a unique top-level schema variable, getter, or function in the library', - () async { - await expectOutput( - r''' + test('automatically detects a unique top-level schema variable, getter, or function in the library', () async { + await expectOutput( + r''' final colorSchema = Ack.string().codec( decode: Color.new, encode: (color) => color.hex, @@ -24,7 +22,7 @@ AckSchema slugSchema() => Ack.string().codec( ); @Schemable() -final class Theme with _$ThemeAck { +final class Theme { const Theme({ required this.primary, this.secondary, @@ -48,18 +46,18 @@ final class Theme with _$ThemeAck { final Slug slug; } ''', - allOf([ - containsCode("'primary': colorSchema,"), - containsCode("'secondary': colorSchema.optional().nullable(),"), - containsCode("'palette': Ack.list(colorSchema),"), - containsCode("'swatches': Ack.list(colorSchema).codec>("), - containsCode("'namedColors': Ack.map(colorSchema),"), - containsCode("'maybeColor': colorSchema.optional().nullable(),"), - containsCode("'boxedColor': Box.schema(colorSchema),"), - containsCode("'price': centsSchema,"), - containsCode("'slug': slugSchema()"), - ]), - types: ''' + allOf([ + containsCode("'primary': colorSchema,"), + containsCode("'secondary': colorSchema.optional().nullable(),"), + containsCode("'palette': Ack.list(colorSchema),"), + containsCode("'swatches': Ack.list(colorSchema).codec>("), + containsCode("'namedColors': Ack.map(colorSchema),"), + containsCode("'maybeColor': colorSchema.optional().nullable(),"), + containsCode("'boxedColor': Box.schema(colorSchema),"), + containsCode("'price': centsSchema,"), + containsCode("'slug': slugSchema()"), + ]), + types: ''' import 'package:ack/ack.dart'; final class Color { @@ -86,9 +84,8 @@ final class Box { ) => value.codec>(decode: Box.new, encode: (box) => box.value); } ''', - ); - }, - ); + ); + }); test( 'rejects ambiguous auto-detected top-level schemas for the same type', @@ -106,7 +103,7 @@ final namedColorSchema = Ack.string().codec( ); @Schemable() -final class Theme with _$ThemeAck { +final class Theme { const Theme({required this.primary}); final Color primary; @@ -141,7 +138,7 @@ final class Color { final colorSchema = Ack.string().transform(Color.new); @Schemable() -final class Theme with _$ThemeAck { +final class Theme { const Theme({required this.primary}); final Color primary; diff --git a/packages/ack_generator/test/integration/model_generation_guide_build_test.dart b/packages/ack_generator/test/integration/model_generation_guide_build_test.dart index 75c582fa..46a9a640 100644 --- a/packages/ack_generator/test/integration/model_generation_guide_build_test.dart +++ b/packages/ack_generator/test/integration/model_generation_guide_build_test.dart @@ -6,8 +6,10 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + Future _run(Directory directory, List arguments) => - Process.run('dart', arguments, workingDirectory: directory.path); + runDart(directory, arguments); void _expectSuccess(ProcessResult result, String command) { expect( @@ -23,9 +25,8 @@ void main() { 'the model-generation guide sample builds and runs in both directions', () async { var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { projectRoot = projectRoot.parent; } @@ -37,9 +38,9 @@ void main() { 'typesafe-schemas.mdx', ), ).readAsStringSync(); - final sample = RegExp( - r'```dart title="lib/models.dart"\n([\s\S]*?)\n```', - ).firstMatch(guide)?.group(1); + final sample = RegExp(r'```dart title="lib/models.dart"\n([\s\S]*?)\n```') + .firstMatch(guide) + ?.group(1); expect( sample, isNotNull, @@ -56,7 +57,7 @@ void main() { name: ack_model_generation_guide publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -69,12 +70,10 @@ dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} '''); - File( - p.join(temporary.path, 'lib', 'models.dart'), - ).writeAsStringSync(sample!); - File( - p.join(temporary.path, 'test', 'models_test.dart'), - ).writeAsStringSync(r''' + File(p.join(temporary.path, 'lib', 'models.dart')) + .writeAsStringSync(sample!); + File(p.join(temporary.path, 'test', 'models_test.dart')) + .writeAsStringSync(r''' import 'package:ack_model_generation_guide/models.dart'; import 'package:test/test.dart'; diff --git a/packages/ack_generator/test/integration/schema_resolution_support.dart b/packages/ack_generator/test/integration/schema_resolution_support.dart index 364afba4..a330e4bc 100644 --- a/packages/ack_generator/test/integration/schema_resolution_support.dart +++ b/packages/ack_generator/test/integration/schema_resolution_support.dart @@ -100,8 +100,10 @@ Matcher containsCode(String code) => predicate( 'contains code `$code`', ); -String _compact(String code) => - code.replaceAll(RegExp(r'\s+'), '').replaceAll(',)', ')'); +String _compact(String code) => code + .replaceAll(RegExp(r'\s+'), '') + .replaceAllMapped(RegExp(r',([)}])'), (match) => match[1]!) + .replaceAll(RegExp(r',$'), ''); Future expectOutput( String body, diff --git a/packages/ack_generator/test/integration/schemable_schemas_test.dart b/packages/ack_generator/test/integration/schemable_schemas_test.dart index ea4215c4..050ddbf4 100644 --- a/packages/ack_generator/test/integration/schemable_schemas_test.dart +++ b/packages/ack_generator/test/integration/schemable_schemas_test.dart @@ -3,11 +3,9 @@ import 'package:test/test.dart'; import 'schema_resolution_support.dart'; void main() { - test( - '@Schemable(schemas: [...]) on a class resolves imported schemas, disambiguates local schemas, and overrides DateTime', - () async { - await expectOutput( - r''' + test('@Schemable(schemas: [...]) on a class resolves imported schemas, disambiguates local schemas, and overrides DateTime', () async { + await expectOutput( + r''' AckSchema hexColorSchema() => Ack.string().codec( decode: Color.new, encode: (color) => color.hex, @@ -29,7 +27,7 @@ AckSchema cellValueSchema() => cellValueSchema, ], ) -final class Order with _$OrderAck { +final class Order { const Order({ required this.color, required this.total, @@ -47,15 +45,15 @@ final class Order with _$OrderAck { final Object? anyJson; } ''', - allOf( - containsCode("'color': hexColorSchema(),"), - containsCode("'total': importedMoneySchema(),"), - containsCode("'createdAt': epochDateTimeSchema(),"), - containsCode("'timestamps': Ack.list(epochDateTimeSchema()),"), - containsCode("'cellValue': cellValueSchema(),"), - containsCode("'anyJson': Ack.any().optional().nullable()"), - ), - types: ''' + allOf( + containsCode("'color': hexColorSchema(),"), + containsCode("'total': importedMoneySchema(),"), + containsCode("'createdAt': epochDateTimeSchema(),"), + containsCode("'timestamps': Ack.list(epochDateTimeSchema()),"), + containsCode("'cellValue': cellValueSchema(),"), + containsCode("'anyJson': Ack.any().optional().nullable()"), + ), + types: ''' import 'package:ack/ack.dart'; final class Color { @@ -78,28 +76,25 @@ AckSchema epochDateTimeSchema() => Ack.integer().codec( encode: (dt) => dt.millisecondsSinceEpoch, ); ''', - ); - }, - ); - - test( - '@Schemable(schemas: [...]) on a library directive applies across models and imported schema libraries', - () async { - await expectOutput( - r''' + ); + }); + + test('@Schemable(schemas: [...]) on a library directive applies across models and imported schema libraries', () async { + await expectOutput( + r''' @Schemable() -final class Palette with _$PaletteAck { +final class Palette { const Palette({required this.primary, required this.budget}); final Color primary; final Money budget; } ''', - allOf( - containsCode("'primary': libraryColorSchema(),"), - containsCode("'budget': sharedMoneySchema()"), - ), - head: ''' + allOf( + containsCode("'primary': libraryColorSchema(),"), + containsCode("'budget': sharedMoneySchema()"), + ), + head: ''' @Schemable(schemas: [libraryColorSchema]) library; @@ -113,7 +108,7 @@ AckSchema libraryColorSchema() => Ack.string().codec( encode: (color) => color.hex, ); ''', - types: ''' + types: ''' @Schemable(schemas: [sharedMoneySchema]) library; @@ -134,35 +129,32 @@ AckSchema sharedMoneySchema() => Ack.integer().codec( encode: (money) => money.cents, ); ''', - ); - }, - ); - - test( - 'rejects core primitive types and duplicate runtime types in @Schemable(schemas: [...])', - () async { - final coreErrors = await generationErrors(r''' + ); + }); + + test('rejects core primitive types and duplicate runtime types in @Schemable(schemas: [...])', () async { + final coreErrors = await generationErrors(r''' AckSchema customStringSchema() => Ack.string(); @Schemable(schemas: [customStringSchema]) -final class Post with _$PostAck { +final class Post { const Post({required this.title}); final String title; } '''); - expect( - coreErrors, + expect( + coreErrors, + contains( contains( - contains( - 'Post @Schemable(schemas: ...) cannot override core type String.', - ), + 'Post @Schemable(schemas: ...) cannot override core type String.', ), - ); + ), + ); - final duplicateErrors = await generationErrors( - r''' + final duplicateErrors = await generationErrors( + r''' AckSchema firstColorSchema() => Ack.string().codec( decode: Color.new, encode: (color) => color.hex, @@ -174,29 +166,28 @@ AckSchema secondColorSchema() => Ack.string().codec( ); @Schemable(schemas: [firstColorSchema, secondColorSchema]) -final class Theme with _$ThemeAck { +final class Theme { const Theme({required this.primary}); final Color primary; } ''', - types: ''' + types: ''' final class Color { const Color(this.hex); final String hex; } ''', - ); + ); - expect( - duplicateErrors, + expect( + duplicateErrors, + contains( contains( - contains( - 'Theme @Schemable(schemas: ...) registers multiple schemas for ' - 'Color: firstColorSchema and secondColorSchema.', - ), + 'Theme @Schemable(schemas: ...) registers multiple schemas for ' + 'Color: firstColorSchema and secondColorSchema.', ), - ); - }, - ); + ), + ); + }); } diff --git a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart index 6707ef07..235ffcc4 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart @@ -6,8 +6,10 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_utils/run_dart.dart'; + Future _run(Directory directory, List arguments) => - Process.run('dart', arguments, workingDirectory: directory.path); + runDart(directory, arguments); void _expectSuccess(ProcessResult result, String command) { expect( @@ -21,9 +23,8 @@ void _expectSuccess(ProcessResult result, String command) { void main() { test('type-owned schemas build, analyze, and round-trip', () async { var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { + while (!Directory(p.join(projectRoot.path, 'packages', 'ack_generator')) + .existsSync()) { projectRoot = projectRoot.parent; } final temporary = await Directory.systemTemp.createTemp('ack_type_owned_'); @@ -34,7 +35,7 @@ void main() { name: ack_type_owned publish_to: none environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} @@ -152,16 +153,15 @@ final colorSchema = Ack.string().codec( ); @Schemable() -final class Habit with _$HabitAck { +final class Habit { const Habit({required this.name}); final String name; - static final schema = _habitSchema; } @Schemable(schemas: [importedMoneySchema]) -final class Palette with _$PaletteAck { +final class Palette { const Palette({ required this.primary, required this.swatches, @@ -174,7 +174,7 @@ final class Palette with _$PaletteAck { } @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({ required this.header, this.footer, @@ -195,7 +195,7 @@ final class Section with _$SectionAck { } @Schemable() -final class Toolbar with _$ToolbarAck { +final class Toolbar { const Toolbar({ required this.openAll, required this.anything, @@ -208,7 +208,7 @@ final class Toolbar with _$ToolbarAck { } @Schemable() -final class Shelf with _$ShelfAck { +final class Shelf { const Shelf({ required this.habit, required this.title, @@ -232,9 +232,8 @@ final panelSchema = Ack.object({ 'habit': Habit.schema.optional(), }); '''); - File( - p.join(temporary.path, 'test', 'type_owned_test.dart'), - ).writeAsStringSync(r''' + File(p.join(temporary.path, 'test', 'type_owned_test.dart')) + .writeAsStringSync(r''' import 'package:ack_type_owned/models.dart'; import 'package:ack_type_owned/types.dart'; import 'package:test/test.dart'; @@ -350,12 +349,11 @@ void main() { await _run(temporary, ['run', 'build_runner', 'build']), 'build_runner build', ); - final generated = File( - p.join(temporary.path, 'lib', 'models.g.dart'), - ).readAsStringSync(); + final generated = File(p.join(temporary.path, 'lib', 'models.g.dart')) + .readAsStringSync(); expect(generated, contains('Command.schema()')); expect(generated, contains('Trigger.schema()')); - expect(generated, contains('Box.schema(HabitSchema.schema)')); + expect(generated, contains('Box.schema(Habit.schema)')); expect(generated, contains('Ack.map(Slot.schema)')); _expectSuccess( await _run(temporary, ['analyze', '--fatal-infos']), diff --git a/packages/ack_generator/test/integration/type_owned_schema_test.dart b/packages/ack_generator/test/integration/type_owned_schema_test.dart index b3810cdb..7f9d49fc 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_test.dart @@ -7,7 +7,7 @@ void main() { await expectOutput( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({ required this.header, this.footer, @@ -34,7 +34,7 @@ final class Section with _$SectionAck { await expectOutput( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({ required this.children, required this.unique, @@ -62,7 +62,7 @@ final class Section with _$SectionAck { await expectOutput( r''' @Schemable() -final class Menu with _$MenuAck { +final class Menu { const Menu({required this.open, required this.history}); final Command open; @@ -83,7 +83,7 @@ final class Menu with _$MenuAck { await expectOutput( r''' @Schemable() -final class Panel with _$PanelAck { +final class Panel { const Panel({ required this.openAll, required this.anything, @@ -111,7 +111,7 @@ final class Panel with _$PanelAck { await expectOutput( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.header}); final types.Slot header; @@ -131,7 +131,7 @@ part 'model.g.dart'; final errors = await generationErrors( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.header}); final Label header; @@ -164,7 +164,7 @@ final class Label { final errors = await generationErrors( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.box}); final Box box; @@ -201,14 +201,14 @@ final class Box { await expectOutput( r''' @Schemable() -final class Row with _$RowAck { +final class Row { const Row({required this.name}); final String name; } @Schemable() -final class Screen with _$ScreenAck { +final class Screen { const Screen({ required this.row, required this.title, @@ -225,12 +225,10 @@ final class Screen with _$ScreenAck { } ''', allOf( - containsCode("'row': Box.schema(RowSchema.schema),"), + containsCode("'row': Box.schema(Row.schema),"), containsCode("'title': Box.schema(Ack.string()),"), - containsCode( - "'rows': Box.schema>(Ack.list(RowSchema.schema)),", - ), - containsCode("'boxes': Ack.list(Box.schema(RowSchema.schema)),"), + containsCode("'rows': Box.schema>(Ack.list(Row.schema)),"), + containsCode("'boxes': Ack.list(Box.schema(Row.schema)),"), containsCode( "'nested': Box.schema>(Box.schema(Ack.string()))", ), @@ -241,7 +239,7 @@ final class Screen with _$ScreenAck { test('a schema method with too few schema parameters is rejected', () async { final errors = await generationErrors(r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.pair}); final Pair pair; @@ -263,7 +261,7 @@ final class Section with _$SectionAck { test('a wrongly typed schema parameter is rejected', () async { final errors = await generationErrors(r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.run}); final Invoke run; @@ -285,7 +283,7 @@ final class Section with _$SectionAck { final errors = await generationErrors( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.color}); final Box color; @@ -317,7 +315,7 @@ final class Box { final errors = await generationErrors( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.color}); final Color color; @@ -342,39 +340,29 @@ final class Color { ); }); - test( - 'a Schemable class may expose its backing schema or facade as a static schema', - () async { - await expectOutput( - r''' + test('a Schemable class generates its composable static schema', () async { + await expectOutput(r''' @Schemable() -final class Habit with _$HabitAck { +final class Habit { const Habit({required this.name}); final String name; - static final schema = _habitSchema; } @Schemable() -final class Routine with _$RoutineAck { +final class Routine { const Routine({required this.habits}); final List habits; } -''', - allOf( - containsCode("Ack.object({'habits': Ack.list(HabitSchema.schema)})"), - isNot(contains('Habit.schema')), - ), - ); - }, - ); +''', allOf(containsCode("Ack.object({'habits': Ack.list(Habit.schema)})"))); + }); test('a Schemable class rejects a static schema of another type', () async { final errors = await generationErrors(r''' @Schemable() -final class Habit with _$HabitAck { +final class Habit { const Habit({required this.name}); final String name; @@ -385,12 +373,7 @@ final class Habit with _$HabitAck { expect( errors, - contains( - contains( - 'Habit.schema must be an AckSchema, Habit>; ' - 'it produces String.', - ), - ), + contains(contains('Habit.schema conflicts with a generated member.')), ); }); @@ -398,12 +381,11 @@ final class Habit with _$HabitAck { await expectOutput( ''' @Schemable() -final class Habit with _\$HabitAck { +final class Habit { const Habit({required this.name}); final String name; - static final schema = _habitSchema; } @Schemable() @@ -431,7 +413,7 @@ final sectionSchema = Ack.object({ final errors = await generationErrors( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.metric}); final Metric metric; @@ -461,19 +443,17 @@ final class Metric { ); }); - test( - 'a generic type-owned schema enforces parameter boundary types', - () async { - final errors = await generationErrors( - r''' + test('a generic type-owned schema enforces parameter boundary types', () async { + final errors = await generationErrors( + r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.record}); final RecordBox record; } ''', - types: ''' + types: ''' import 'package:ack/ack.dart'; final class RecordBox { @@ -485,26 +465,25 @@ final class RecordBox { ) => throw UnimplementedError(); } ''', - ); + ); - expect( - errors, + expect( + errors, + contains( contains( - contains( - 'Section.record resolves to RecordBox.schema, whose parameter value ' - 'expects boundary type Map, but the schema ' - 'inferred for int has boundary type num.', - ), + 'Section.record resolves to RecordBox.schema, whose parameter value ' + 'expects boundary type Map, but the schema ' + 'inferred for int has boundary type num.', ), - ); - }, - ); + ), + ); + }); test('an enum with a static schema resolves to it', () async { await expectOutput( r''' @Schemable() -final class Task with _$TaskAck { +final class Task { const Task({required this.status, required this.plain}); final Status status; @@ -540,7 +519,7 @@ enum Plain { one, two } final errors = await generationErrors( r''' @Schemable() -final class Section with _$SectionAck { +final class Section { const Section({required this.routine}); final Routine routine; diff --git a/packages/ack_generator/test/src/format_output_test.dart b/packages/ack_generator/test/src/format_output_test.dart new file mode 100644 index 00000000..2d32aca1 --- /dev/null +++ b/packages/ack_generator/test/src/format_output_test.dart @@ -0,0 +1,23 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:test/test.dart'; + +void main() { + test('restoring augment names a class that is neither final nor sealed', () { + final builder = ackModelBuilder(BuilderOptions.empty) as PartBuilder; + + expect( + () => + builder.formatOutput('augment class Product {}\n', Version(3, 14, 0)), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Product'), + ), + ), + ); + }); +} diff --git a/packages/ack_generator/test/src/generator_test.dart b/packages/ack_generator/test/src/generator_test.dart index 3763326d..8ee39da6 100644 --- a/packages/ack_generator/test/src/generator_test.dart +++ b/packages/ack_generator/test/src/generator_test.dart @@ -80,7 +80,7 @@ final userSchema = Ack.string(); import 'package:ack/annotations.dart'; @Schemable() -final class User with _\$UserAck { +final class User { const User({required this.name}); final String name; diff --git a/packages/ack_generator/test/test_utils/run_dart.dart b/packages/ack_generator/test/test_utils/run_dart.dart new file mode 100644 index 00000000..d919ceff --- /dev/null +++ b/packages/ack_generator/test/test_utils/run_dart.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Runs fixture commands with the test suite's SDK, not a global Dart install. +/// +/// The VM/analyzer and build_runner's resolver each need the augmentation flag +/// while the pinned Dart 3.14 SDK keeps this feature experimental. +Future runDart(Directory directory, List arguments) { + // The analyzer does not inherit the VM's experiment flag. Give each temporary + // consumer the same analyzer setting required by the documented setup. + final options = File(p.join(directory.path, 'analysis_options.yaml')); + var source = options.existsSync() ? options.readAsStringSync() : ''; + if (!source.contains('enable-experiment:')) { + const experiments = ' enable-experiment:\n - augmentations\n'; + final analyzer = RegExp(r'^analyzer:\s*$', multiLine: true); + source = analyzer.hasMatch(source) + ? source.replaceFirst(analyzer, 'analyzer:\n$experiments') + : 'analyzer:\n$experiments$source'; + options.writeAsStringSync(source); + } + return Process.run( + p.join( + p.dirname(Platform.resolvedExecutable), + Platform.isWindows ? 'dart.exe' : 'dart', + ), + [ + '--enable-experiment=augmentations', + ...arguments, + if (arguments.length >= 3 && + arguments[0] == 'run' && + arguments[1] == 'build_runner') + '--enable-experiment=augmentations', + ], + workingDirectory: directory.path, + ); +} diff --git a/packages/ack_json_schema_builder/example/example.dart b/packages/ack_json_schema_builder/example/example.dart index 418af0d0..62c7b1a0 100644 --- a/packages/ack_json_schema_builder/example/example.dart +++ b/packages/ack_json_schema_builder/example/example.dart @@ -40,9 +40,9 @@ void main() async { // Example 4: Array Schema print('\nExample 4: Array Schema'); - final tagsSchema = Ack.list( - Ack.string().minLength(1), - ).minLength(1).maxLength(5); + final tagsSchema = Ack.list(Ack.string().minLength(1)) + .minLength(1) + .maxLength(5); tagsSchema.toJsonSchemaBuilder(); print('Converted array schema with constraints'); diff --git a/packages/ack_json_schema_builder/pubspec.yaml b/packages/ack_json_schema_builder/pubspec.yaml index 44f9d886..48831164 100644 --- a/packages/ack_json_schema_builder/pubspec.yaml +++ b/packages/ack_json_schema_builder/pubspec.yaml @@ -10,7 +10,7 @@ topics: - validation environment: - sdk: '>=3.11.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: @@ -18,5 +18,5 @@ dependencies: json_schema_builder: ^0.1.3 dev_dependencies: - lints: ^5.0.0 + lints: ^6.1.0 test: ^1.25.15 diff --git a/packages/ack_mcp_dart/README.md b/packages/ack_mcp_dart/README.md index 3974bb16..793c8d21 100644 --- a/packages/ack_mcp_dart/README.md +++ b/packages/ack_mcp_dart/README.md @@ -140,7 +140,7 @@ New emitted keywords require revisiting this dialect assumption. | Component | Supported / exercised | | --- | --- | -| Dart | `>=3.9.0 <4.0.0` | +| Dart | `>=3.14.0-315.1.beta <4.0.0` | | ACK | `^2.0.0-alpha.1` | | mcp_dart | `^2.4.2` (tests resolved against 2.4.2) | | MCP stable discovery | `2026-07-28` | diff --git a/packages/ack_mcp_dart/lib/ack_mcp_dart.dart b/packages/ack_mcp_dart/lib/ack_mcp_dart.dart index bb464e5e..25ae809b 100644 --- a/packages/ack_mcp_dart/lib/ack_mcp_dart.dart +++ b/packages/ack_mcp_dart/lib/ack_mcp_dart.dart @@ -70,8 +70,10 @@ CallToolResult ackValidationErrorToCallToolResult( ); /// A tool callback receiving validated ACK runtime arguments and MCP context. -typedef AckToolFunction = - FutureOr Function(R args, RequestHandlerExtra extra); +typedef AckToolFunction = FutureOr Function( + R args, + RequestHandlerExtra extra, +); /// Registers MCP tools whose input contracts and parsing are owned by ACK. extension AckMcpServer on McpServer { diff --git a/packages/ack_mcp_dart/pubspec.yaml b/packages/ack_mcp_dart/pubspec.yaml index 42e66d97..7ff1a99c 100644 --- a/packages/ack_mcp_dart/pubspec.yaml +++ b/packages/ack_mcp_dart/pubspec.yaml @@ -10,7 +10,7 @@ topics: - validation environment: - sdk: '>=3.11.0 <4.0.0' + sdk: '>=3.14.0-315.1.beta <4.0.0' dependencies: @@ -18,5 +18,5 @@ dependencies: mcp_dart: ^2.4.2 dev_dependencies: - lints: ^5.0.0 + lints: ^6.1.0 test: ^1.25.15 diff --git a/pubspec.yaml b/pubspec.yaml index 38c4bbf9..f3361a3a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,8 +4,8 @@ publish_to: none repository: https://github.com/btwld/ack environment: - sdk: '>=3.11.0 <4.0.0' - flutter: '>=3.41.2' + sdk: '>=3.14.0-315.1.beta <4.0.0' + flutter: '>=3.50.0-0.1.pre' workspace: @@ -17,16 +17,15 @@ workspace: - example dev_dependencies: - melos: ^8.7.0 + melos: 8.9.0 # Release tooling under /scripts reads and rewrites pubspecs. path: ^1.9.0 pub_semver: ^2.2.0 test: ^1.25.15 - very_good_analysis: ^9.0.0 yaml: ^3.1.2 yaml_edit: ^2.2.1 # Shared lint config consumed by /analysis_options.yaml (workspace-wide). - lints: ^5.0.0 + lints: ^6.1.0 dart_code_metrics_presets: ^2.22.0 melos: @@ -46,7 +45,7 @@ melos: scripts: analyze: - run: dart run melos exec -- "dart analyze . --fatal-infos" + run: dart pub global run melos exec -- "dart analyze . --fatal-infos" description: Run analyzer on all packages packageFilters: scope: @@ -58,46 +57,46 @@ melos: - ack_example fix: - run: dart run melos exec -- "dart fix --apply" + run: dart pub global run melos exec -- "dart fix --apply" description: Apply fixes to all packages test: run: >- - dart run melos run analyze --no-select && + dart pub global run melos run analyze --no-select && dart test test && - dart run melos run test:dart --no-select && - dart run melos run test:flutter --no-select + dart pub global run melos run test:dart --no-select && + dart pub global run melos run test:flutter --no-select description: Run strict analysis and all root, Dart, and Flutter tests test:dart: - run: dart run melos exec -c 1 --fail-fast -- "dart test" + run: dart pub global run melos exec -c 1 --fail-fast -- "dart --enable-experiment=augmentations test" description: Run Dart tests for pure Dart packages packageFilters: flutter: false dirExists: test test:flutter: - run: dart run melos exec -c 1 --fail-fast -- "flutter test" + run: dart pub global run melos exec -c 1 --fail-fast -- "flutter test --enable-experiment=augmentations" description: Run Flutter tests for Flutter packages packageFilters: flutter: true dirExists: test format: - run: dart run melos exec -- "dart format ." - description: Format code for all packages + run: dart run scripts/format.dart + description: Format authored Dart sources across the workspace deps-outdated: - run: dart run melos exec -- "dart pub outdated" + run: dart pub global run melos exec -- "dart pub outdated" description: Check for outdated dependencies in all packages clean: - run: dart run melos clean + run: dart pub global run melos clean description: Clean all build outputs. # Build scripts for code generation build: - run: dart run melos exec -- "dart run build_runner build" + run: dart pub global run melos exec -- "dart run build_runner build --enable-experiment=augmentations" description: Run build_runner for all packages packageFilters: dependsOn: build_runner @@ -109,13 +108,13 @@ melos: # ACK Generator specific test commands test:gen: - run: dart run melos exec -c 1 -- dart test + run: dart pub global run melos exec -c 1 -- dart --enable-experiment=augmentations test description: Run generator tests packageFilters: scope: ack_generator test:gen:unit: - run: dart run melos exec -c 1 -- dart test -x integration + run: dart pub global run melos exec -c 1 -- dart --enable-experiment=augmentations test -x integration description: Run generator tests except the build_runner integration tests packageFilters: scope: ack_generator @@ -149,5 +148,5 @@ melos: description: "Check API compatibility against a published version" ci: - run: dart run melos run test --no-select + run: dart pub global run melos run test --no-select description: Run strict analysis (fatal infos) and all Dart & Flutter tests diff --git a/scripts/api_check.dart b/scripts/api_check.dart index 7e6ac476..d797f637 100755 --- a/scripts/api_check.dart +++ b/scripts/api_check.dart @@ -8,7 +8,9 @@ import 'package:pub_semver/pub_semver.dart'; import 'src/workspace_packages.dart'; final ackPackages = publishableAckPackages; -const dartApiToolVersion = '0.23.0'; +// Earlier releases misidentify primary constructors and private named field +// parameters, producing incorrect public API reports for the Dart 3.14 code. +const dartApiToolVersion = '0.23.2'; Future main(List args) async { final inputs = [...args]; @@ -251,6 +253,6 @@ void printUsage() { ); print(''); print('Melos usage:'); - print(' dart run melos run api-check -- ack v1.1.0'); - print(' dart run melos run api-check -- v1.1.0'); + print(' dart pub global run melos run api-check -- ack v1.1.0'); + print(' dart pub global run melos run api-check -- v1.1.0'); } diff --git a/scripts/format.dart b/scripts/format.dart new file mode 100644 index 00000000..7d249bdb --- /dev/null +++ b/scripts/format.dart @@ -0,0 +1,44 @@ +#!/usr/bin/env dart + +import 'dart:io'; + +/// Formats authored Dart sources; forwards arguments to `dart format`. +/// +/// The pinned beta's formatter drops `augment` tokens. Generated parts are +/// formatted by ack_generator's token-aware formatter instead, and checked by +/// the deterministic-generation CI lane. +Future main(List args) async { + final root = File.fromUri(Platform.script).parent.parent.path; + final files = await Process.run('git', [ + 'ls-files', + '-z', + '--cached', + '--others', + '--exclude-standard', + '--', + '*.dart', + ], workingDirectory: root); + if (files.exitCode != 0) { + stderr.write(files.stderr); + exitCode = files.exitCode; + + return; + } + + final sources = + (files.stdout as String) + .split('\u0000') + .where((path) => path.isNotEmpty && !path.endsWith('.g.dart')) + .where((path) => File('$root/$path').existsSync()) + .toList() + ..sort(); + if (sources.isEmpty) return; + + final formatter = await Process.start( + Platform.resolvedExecutable, + ['format', '--enable-experiment=augmentations', ...args, ...sources], + workingDirectory: root, + mode: ProcessStartMode.inheritStdio, + ); + exitCode = await formatter.exitCode; +} diff --git a/scripts/src/staging.dart b/scripts/src/staging.dart index 84460805..f27c3ec8 100644 --- a/scripts/src/staging.dart +++ b/scripts/src/staging.dart @@ -92,10 +92,8 @@ String overrideWithStagedPackages( /// Removes the [flutterMembers] workspace entries and the Flutter SDK /// constraint from a workspace root [pubspec]. /// -/// The complete workspace cannot resolve on the minimum Dart SDK, because -/// `flutter_test` pins an older `test_api` than the analyzer floor of -/// `ack_generator` allows. Dropping the Flutter members produces a resolvable -/// pure-Dart workspace that still covers every other package. +/// Dropping Flutter members verifies that standalone Dart consumers can +/// resolve the pure-Dart packages independently of Flutter's SDK packages. String toPureDartWorkspace( String pubspec, { required Set flutterMembers, diff --git a/scripts/stage_min_sdk_workspace.dart b/scripts/stage_min_sdk_workspace.dart index 46a45eac..45e3388d 100644 --- a/scripts/stage_min_sdk_workspace.dart +++ b/scripts/stage_min_sdk_workspace.dart @@ -9,12 +9,10 @@ import 'src/staging.dart'; /// Copies the repository into a pure-Dart workspace that resolves on the /// minimum Dart SDK. /// -/// The complete workspace cannot resolve on Dart 3.9, because `flutter_test` -/// pins `test_api 0.7.6` while the root `test` constraint and the Analyzer 10 -/// dependency of `ack_generator` require a newer `test_api`. Removing the -/// Flutter workspace members leaves a resolvable workspace that still covers -/// every pure-Dart package. The Flutter packages get their own staged lane -/// through `scripts/stage_package.dart`. +/// Removing Flutter workspace members verifies that consumers using the +/// standalone Dart SDK can resolve every pure-Dart package independently of +/// Flutter's SDK packages. Flutter packages get their own staged lane through +/// `scripts/stage_package.dart`. /// /// Usage: /// `dart scripts/stage_min_sdk_workspace.dart ` diff --git a/setup.sh b/setup.sh index 3519bacd..79ef5b89 100755 --- a/setup.sh +++ b/setup.sh @@ -25,7 +25,9 @@ dart --version echo "Resolving workspace dependencies..." dart pub get -dart run melos bootstrap +echo "Activating Melos 8.9.0..." +dart pub global activate melos 8.9.0 +dart pub global run melos bootstrap if [[ -f tools/npm-shrinkwrap.json ]]; then if command -v npm >/dev/null 2>&1; then @@ -36,4 +38,4 @@ if [[ -f tools/npm-shrinkwrap.json ]]; then fi fi -echo "Setup complete. Run 'dart run melos run --list' to see workspace scripts." +echo "Setup complete. Run 'dart pub global run melos run --list' to see workspace scripts." diff --git a/test/scripts/release_changelog_test.dart b/test/scripts/release_changelog_test.dart index 655439fc..c8585722 100644 --- a/test/scripts/release_changelog_test.dart +++ b/test/scripts/release_changelog_test.dart @@ -98,9 +98,9 @@ Duplicate stable notes. }); test('failed batches leave every changelog unchanged', () async { - final scriptPath = File( - 'scripts/update_release_changelog.dart', - ).absolute.path; + final scriptPath = File('scripts/update_release_changelog.dart') + .absolute + .path; final workingDirectory = Directory.systemTemp.createTempSync( 'ack-release-changelog-', ); diff --git a/test/scripts/release_documentation_test.dart b/test/scripts/release_documentation_test.dart index bf98f879..6af250b2 100644 --- a/test/scripts/release_documentation_test.dart +++ b/test/scripts/release_documentation_test.dart @@ -28,11 +28,10 @@ void main() { test( 'repository guidance does not advertise disabled publishing commands', () { - final guidance = File( - '.github/copilot-instructions.md', - ).readAsStringSync(); + final guidance = File('.github/copilot-instructions.md') + .readAsStringSync(); - expect(guidance, isNot(contains('dart run melos publish'))); + expect(guidance, isNot(contains('dart pub global run melos publish'))); expect(guidance, contains('/PUBLISHING.md')); }, ); diff --git a/test/scripts/release_workflow_security_test.dart b/test/scripts/release_workflow_security_test.dart index 7eb5b781..2cd2f78c 100644 --- a/test/scripts/release_workflow_security_test.dart +++ b/test/scripts/release_workflow_security_test.dart @@ -103,9 +103,8 @@ void main() { }); test('verifies the downloaded archive against a pinned checksum', () { - final action = File( - '.github/actions/setup-flutter/action.yml', - ).readAsStringSync(); + final action = File('.github/actions/setup-flutter/action.yml') + .readAsStringSync(); expect(action, contains('sha256sum --check')); expect(action, contains('.github/flutter-releases.json')); @@ -121,7 +120,7 @@ void main() { ); expect( entry.value['dart'], - matches(RegExp(r'^\d+\.\d+\.\d+$')), + matches(RegExp(r'^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')), reason: 'Flutter ${entry.key} needs the exact bundled Dart version', ); } @@ -224,9 +223,8 @@ void main() { }); test('proves the hosted dependency graph before it uploads', () { - final source = File( - '.github/workflows/publish-packages.yml', - ).readAsStringSync(); + final source = File('.github/workflows/publish-packages.yml') + .readAsStringSync(); expect(source, contains('scripts/stage_package.dart')); expect(source, contains('scripts/publish_dry_run.dart')); @@ -258,9 +256,9 @@ Map _automationSources() { } Map> get _flutterReleases { - final manifest = - jsonDecode(File('.github/flutter-releases.json').readAsStringSync()) - as Map; + final manifest = jsonDecode( + File('.github/flutter-releases.json').readAsStringSync(), + ) as Map; final releases = manifest['releases'] as Map; return { diff --git a/test/scripts/staging_test.dart b/test/scripts/staging_test.dart index 48b62eca..1721567d 100644 --- a/test/scripts/staging_test.dart +++ b/test/scripts/staging_test.dart @@ -32,9 +32,9 @@ dependencies: test('points a dependency at the staged sibling', () { const pubspec = 'name: ack_generator\ndependencies:\n ack: ^1.2.0\n'; - final overridden = - loadYaml(overrideWithStagedPackages(pubspec, {'ack': '../ack'})) - as Map; + final overridden = loadYaml( + overrideWithStagedPackages(pubspec, {'ack': '../ack'}), + ) as Map; expect((overridden['dependency_overrides'] as Map)['ack'], { 'path': '../ack', @@ -63,14 +63,12 @@ workspace: - example '''; - final pure = - loadYaml( - toPureDartWorkspace( - pubspec, - flutterMembers: const {'packages/ack_firebase_ai'}, - ), - ) - as Map; + final pure = loadYaml( + toPureDartWorkspace( + pubspec, + flutterMembers: const {'packages/ack_firebase_ai'}, + ), + ) as Map; expect(pure['workspace'], ['packages/ack', 'example']); expect((pure['environment'] as Map).containsKey('flutter'), isFalse); diff --git a/test/scripts/verify_release_tag_test.dart b/test/scripts/verify_release_tag_test.dart index f8b9f1c4..c149098e 100644 --- a/test/scripts/verify_release_tag_test.dart +++ b/test/scripts/verify_release_tag_test.dart @@ -63,9 +63,10 @@ Future _verify(List args) => Process.run( ); /// Reads the version that the core package currently declares. -String _declaredAckVersion() => File('packages/ack/pubspec.yaml') - .readAsLinesSync() - .firstWhere((line) => line.startsWith('version:')) - .split(':') - .last - .trim(); +String _declaredAckVersion() => + File('packages/ack/pubspec.yaml') + .readAsLinesSync() + .firstWhere((line) => line.startsWith('version:')) + .split(':') + .last + .trim(); diff --git a/tools/README.md b/tools/README.md index 467f0a94..37a97929 100644 --- a/tools/README.md +++ b/tools/README.md @@ -125,4 +125,4 @@ The validator outputs structured JSON results: The JSON Schema validation tool is integrated into the Melos workflow with `validate-jsonschema` scripts. -Run `dart run melos run --list` from the workspace root for all available commands. +Run `dart pub global run melos run --list` from the workspace root for all available commands.