diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..46f28c38 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# Agent notes + +Follow [CONTRIBUTING.md](CONTRIBUTING.md), including its generator-test +notes. Also: + +- Never run the `ack_generator` suite from several checkouts or worktrees at + the same time. +- Don't restart a test run that was stopped from outside; ask first. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de0858d..c3d70810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 1.7.0-beta.2 + +* Add `AckModelAdapter.modelSchema`, function-parameter annotations, string + format annotations, and shared schema inference for downstream generators. +* Generate a static `Model.schema` shorthand for `Model.$ack.modelSchema` on + `@AckInfer()` models. +* Add `CodecSchema.canEncode` for direct codec encoder introspection. +* Generated `copyWith` is a getter that returns a typed interface, so a wrongly + typed argument fails to compile. Calls such as `model.copyWith(name: 'x')` + are unchanged; code that tears off or mocks the `copyWith` method must be + updated. Generated parts also suppress consumer lints and coverage. +* Constrained `Set` fields (`@MinItems`, `@MaxItems`, `@UniqueItems`) require + unique items at the JSON boundary. Duplicate input such as `['a', 'a']` now + fails validation instead of collapsing into a smaller set. +* Add explicit `description` options to `@AckModel` and `@AckField` (#175). + A single-line `@description` doc tag is a fallback. Untagged prose does not + become schema data. +* Escape control characters in generated string literals, including inferred + pattern values (#164). +* Align all six publishable packages at the 1.7.0 beta. The generator now + requires the 1.7.0 beta of `ack` and `ack_annotations`. + ## 1.6.2 * Restore import of Ack-exported JSON Schemas containing `pattern` and accept diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ae7eba3..e90802d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,20 @@ For code generation changes, also run: dart run melos run test:gen ``` +The generator suite is memory-heavy: eight tests tagged `integration` run +real `build_runner` builds in temporary packages. For a quick check while +iterating, skip them: + +```bash +dart run melos run test:gen:unit +``` + +Run the full `test:gen` before opening the PR. Run one generator suite at a +time on a machine; running it from several checkouts in parallel can exhaust +memory. +Tag any new test that runs a real `build_runner` build with +`@Tags(['integration'])`. + For JSON Schema export changes, also run: ```bash diff --git a/PUBLISHING.md b/PUBLISHING.md index 2305f3ee..6e8463b3 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -9,15 +9,18 @@ environment. A release-preparation PR does not publish anything. Use SemVer for the combined public API: patch for compatible fixes, minor for new compatible features/packages, and major when any stable public API breaks. -The next release is **1.6.2**, restoring JSON Schema import compatibility for -`pattern` and accepting standard `propertyNames` schemas. No public Dart API -was added; this is a patch release for the import compatibility fix. The API -check compares the six packages against v1.6.1. - -The shared API baseline for this release is 1.6.1. Confirm that all six -packages have published 1.6.1 versions before preparing the release. - -Melos **8.7** is configured with `mode: fixed` and `workspaceTag: true`, matching +The next candidate is **1.7.0-beta.2**. It previews shared inference, model +adapter schemas, codec introspection, typed generated `copyWith`, stricter +constrained-`Set` validation, static model schema shorthand, and explicit +description controls. The API check compares +all six packages against 1.6.2; generator compatibility also requires the +consumer build and runtime tests because the API check cannot inspect +generated consumer code. + +The shared API baseline for this release is 1.6.2. Confirm that all six +packages have published 1.6.2 versions before preparing the release. + +Melos **8.x** is configured with `mode: fixed` and `workspaceTag: true`, matching this repository's release-tag verifier. `smartDependents: true` preserves compatible dependency minimums instead of forcing unnecessary upgrades. For example, packages at 1.3.0 can still declare `ack: ^1.2.0` when they use only @@ -47,27 +50,34 @@ 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:1.6.2 --yes --no-git-commit-version + dart run melos version --manual-version=ack:1.7.0-beta.2 --yes --no-git-commit-version ``` Use the named flag, not a positional package argument: the positional form - filters the selected packages in Melos 8.7. Choose the next SemVer value for - later releases. Normal automatic selection is also available with + 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. - Do not pass `--all`: that would include private packages. + 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. 4. Review generated changelogs and retain substantive release notes; move only unreleased notes into the new section, preserving every published section. Update installation snippets to match each package's version. -5. Set `API_BASELINE_VERSION` in `.github/workflows/preflight.yml` to the latest - release published for all packages (currently `1.6.2`). Record a new +5. Compare against the most recent coordinated stable release in + `API_BASELINE_VERSION` in `.github/workflows/preflight.yml` (currently + `1.6.2`). Keep that stable baseline for prereleases, so a beta cannot hide a + breaking change since the last stable version. Raise it after the next + stable release. Record a new package's actual first release in `ackPackageFirstReleases` in `scripts/src/workspace_packages.dart`; checks skip only older baselines. `ack_mcp_dart` first released at 1.3.0. -6. Validate the complete release: +6. Commit the reviewed version, changelog, and documentation changes locally + before the publish dry run. Pub treats modified tracked package files as a + warning, and this repository requires zero warnings. +7. Validate the complete release from that clean commit: ```sh - dart scripts/verify_release_tag.dart v1.6.2 --skip-ancestry + dart scripts/verify_release_tag.dart v1.7.0-beta.2 --skip-ancestry dart run melos run ci dart scripts/api_check.dart 1.6.2 dart scripts/publish_dry_run.dart @@ -78,13 +88,13 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), The actual release workflow always enforces ancestry on `origin/main`. CI also runs minimum Dart/Flutter SDK checks, deterministic generation, and staged hosted-dependency analysis; all must pass on the release merge commit. -7. Review and merge the release PR before creating the tag. +8. Review and merge the release PR before creating the tag. ## Historical first publication of ack_mcp_dart [pub.dev requires the first version of a new package to be published manually](https://dart.dev/tools/pub/automated-publishing). OIDC cannot create a new package. The steps below applied only to the first -`ack_mcp_dart` release, v1.3.0; do not repeat them for 1.6.2. +`ack_mcp_dart` release, v1.3.0; do not repeat them for later releases. After the release PR merges and all checks pass, but **before pushing v1.3.0**: @@ -121,9 +131,9 @@ After the release commit's CI/preflight succeeds: ```sh git fetch origin main --tags # Use the exact reviewed release merge commit, not an arbitrary later main head. -git tag -a v1.6.2 -m 'Ack 1.6.2' -dart scripts/verify_release_tag.dart v1.6.2 -git push origin v1.6.2 +git tag -a v1.7.0-beta.2 -m 'Ack 1.7.0 beta 2' +dart scripts/verify_release_tag.dart v1.7.0-beta.2 +git push origin v1.7.0-beta.2 ``` Pushing the tag triggers `.github/workflows/release.yml`; there is no Melos @@ -148,5 +158,7 @@ missing version. Rerun failed jobs on the **same tag**; never move a published t or republish different contents under an existing version. After all six exact versions are visible, create the GitHub Release from the -existing tag using the prepared release notes. Before the first subsequent code -change, begin a new unreleased changelog section instead of editing 1.6.2 notes. +existing tag using the prepared release notes and mark it as a prerelease for +beta versions. Before the first subsequent code +change, begin a new unreleased changelog section instead of editing the +1.7.0-beta.2 notes. diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 426f5733..a68cfa72 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -121,6 +121,11 @@ Generated models provide: - `User.parse` and `User.safeParse` for validated input; - `User.fromJson`, `toJson`, and `safeToJson` for the JSON boundary; - generated `copyWith`, deep collection-aware `==`/`hashCode`, and `toString`; +- `User.schema`, the model-valued schema for composition such as + `Ack.list(User.schema)`; it is a shorthand for `User.$ack.modelSchema` and + is omitted when the model has a field or discriminator named `schema`, the + annotated declaration is named `schema`, or the library imports a prefix + named `schema`; - a public `User.$ack` adapter used by nested generated models. The generator supports object and value roots, literals, enums, lists, sets, @@ -219,6 +224,32 @@ static final Profile Function(Map) fromJson = ProfileSchema.fromJson; ``` +Use `@AckModel(description: ...)` and `@AckField(description: ...)` to export +schema descriptions. A single-line `@description` tag in a `///` or `/** */` +doc comment is a fallback. Only the text after the tag, on the same line, is exported. Untagged +prose does not become schema data. Explicit annotation text takes precedence. +Blank or duplicate tags fail generation. `@description` is an Ack convention, +not a Dart annotation. + +A class description applies to its object schema and model codec. This includes +sealed union bases and branches. A field description applies at its property. +It does not change a nested model's own description. + +```dart +/// Tracks one unit of work. This prose is not exported. +@AckModel(description: 'A task the person can complete.') +final class Task with _$TaskAck { + const Task({required this.id, required this.title}); + + final String id; + + /// @description What to do. + final String title; +} + +TaskSchema.toJsonSchema()['description']; // 'A task the person can complete.' +``` + Use `@AckModel(schemaName: 'WireProfileSchema')` to override the exact public facade name. It must be a public UpperCamel identifier. The private backing name remains derived from the model class, and no public lower-camel alias is diff --git a/example/lib/additional_properties_example.ack.dart b/example/lib/additional_properties_example.ack.dart index e9bbf2b9..b76c8f43 100644 --- a/example/lib/additional_properties_example.ack.dart +++ b/example/lib/additional_properties_example.ack.dart @@ -51,6 +51,11 @@ final class UserConfig { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [UserConfig] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -197,6 +202,11 @@ final class ApiRequest { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [ApiRequest] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -343,6 +353,11 @@ final class FeatureFlags { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [FeatureFlags] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -484,6 +499,11 @@ final class DynamicData { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [DynamicData] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); diff --git a/example/lib/class_first_models.ack.dart b/example/lib/class_first_models.ack.dart index 6387eb76..0a04aea6 100644 --- a/example/lib/class_first_models.ack.dart +++ b/example/lib/class_first_models.ack.dart @@ -14,14 +14,13 @@ final _catObject = Ack.object({ 'type': Ack.literal('cat').optional(), 'id': Ack.string(), 'lives': Ack.integer().min(1).max(9), -}); +}).describe('A cat with a limited number of lives.'); final _catWireSchema = Ack.preserveBoundary(_catObject); -final _catSchema = _catObject.codec( - decode: _$CatFromRuntime, - encode: _$CatToRuntime, -); +final _catSchema = _catObject + .codec(decode: _$CatFromRuntime, encode: _$CatToRuntime) + .describe('A cat with a limited number of lives.'); /// Parses, validates, and encodes [Cat] values. abstract final class CatSchema { @@ -277,21 +276,23 @@ Object? _ackDogToRuntimeBreed(String value) => value; final _petObject = Ack.discriminated( discriminatorKey: 'type', schemas: {'cat': _catObject, 'Dog': _dogObject}, -); +).describe('A pet, identified by its `type`.'); final _petWireSchema = Ack.preserveBoundary(_petObject); -final _petSchema = _petObject.codec( - decode: (value) => switch (value['type']) { - 'cat' => _$CatFromRuntime(value), - 'Dog' => _$DogFromRuntime(value), - final unknown => throw StateError('Unknown type: $unknown'), - }, - encode: (model) => switch (model) { - Cat() => _$CatToRuntime(model), - Dog() => _$DogToRuntime(model), - }, -); +final _petSchema = _petObject + .codec( + decode: (value) => switch (value['type']) { + 'cat' => _$CatFromRuntime(value), + 'Dog' => _$DogFromRuntime(value), + final unknown => throw StateError('Unknown type: $unknown'), + }, + encode: (model) => switch (model) { + Cat() => _$CatToRuntime(model), + Dog() => _$DogToRuntime(model), + }, + ) + .describe('A pet, identified by its `type`.'); /// Parses, validates, and encodes [Pet] values. abstract final class PetSchema { @@ -352,17 +353,18 @@ mixin _$PetAck { } final _accountObject = Ack.object({ - 'display_name': Ack.string().minLength(2), + 'display_name': Ack.string() + .minLength(2) + .describe('The name shown to other users.'), 'website': Ack.uri().optional().nullable(), 'role': Ack.string().withDefault('member'), -}); +}).describe('A signed-in user\'s account.'); final _accountWireSchema = Ack.preserveBoundary(_accountObject); -final _accountSchema = _accountObject.codec( - decode: _$AccountFromRuntime, - encode: _$AccountToRuntime, -); +final _accountSchema = _accountObject + .codec(decode: _$AccountFromRuntime, encode: _$AccountToRuntime) + .describe('A signed-in user\'s account.'); /// Parses, validates, and encodes [Account] values. abstract final class AccountSchema { diff --git a/example/lib/class_first_models.dart b/example/lib/class_first_models.dart index 372636f7..6c07f3e9 100644 --- a/example/lib/class_first_models.dart +++ b/example/lib/class_first_models.dart @@ -4,7 +4,11 @@ import 'package:ack_annotations/ack_annotations.dart'; part 'class_first_models.ack.dart'; part 'class_first_models.ack.g.dart'; -@AckModel(caseStyle: AckCaseStyle.snake) +/// A signed-in user's account. +@AckModel( + caseStyle: AckCaseStyle.snake, + description: "A signed-in user's account.", +) final class Account with _$AccountAck { const Account({ required this.displayName, @@ -12,7 +16,9 @@ final class Account with _$AccountAck { this.role = 'member', }); + /// The name shown to other users. @MinLength(2) + @AckField(description: 'The name shown to other users.') final String displayName; final Uri? website; final String role; @@ -20,6 +26,8 @@ final class Account with _$AccountAck { static final fromJson = AccountSchema.fromJson; } +/// A pet, identified by its `type`. +/// @description A pet, identified by its `type`. @AckModel(discriminatorKey: 'type') sealed class Pet with _$PetAck { const Pet({required this.id}); @@ -27,6 +35,8 @@ sealed class Pet with _$PetAck { final String id; } +/// A cat with a limited number of lives. +/// @description A cat with a limited number of lives. @AckModel(discriminatorValue: 'cat') final class Cat extends Pet with _$CatAck { const Cat({required super.id, required this.lives}); diff --git a/example/lib/pet.ack.dart b/example/lib/pet.ack.dart index 63b08f9c..dad42c0b 100644 --- a/example/lib/pet.ack.dart +++ b/example/lib/pet.ack.dart @@ -36,6 +36,11 @@ sealed class Pet { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Pet] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -94,6 +99,11 @@ final class Cat extends Pet { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Cat] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -180,6 +190,11 @@ final class Dog extends Pet { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Dog] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); diff --git a/example/lib/schema_types_discriminated.ack.dart b/example/lib/schema_types_discriminated.ack.dart index f73355d0..b5a5de78 100644 --- a/example/lib/schema_types_discriminated.ack.dart +++ b/example/lib/schema_types_discriminated.ack.dart @@ -36,6 +36,11 @@ sealed class Pet { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Pet] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -94,6 +99,11 @@ final class Cat extends Pet { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Cat] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -186,6 +196,11 @@ final class Dog extends Pet { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Dog] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); diff --git a/example/lib/schema_types_edge_cases.ack.dart b/example/lib/schema_types_edge_cases.ack.dart index d8ff8d52..dea2612b 100644 --- a/example/lib/schema_types_edge_cases.ack.dart +++ b/example/lib/schema_types_edge_cases.ack.dart @@ -55,6 +55,11 @@ final class Product { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Product] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -194,6 +199,11 @@ final class Grid { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Grid] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -306,6 +316,11 @@ final class Address { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Address] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult
safeParse(Object? input) => $ack.safeParse(input); @@ -445,6 +460,11 @@ final class Person { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Person] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -576,6 +596,11 @@ final class Employee { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Employee] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -722,6 +747,11 @@ final class Modifier { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Modifier] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -907,6 +937,11 @@ final class TaggedItem { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [TaggedItem] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1075,6 +1110,11 @@ final class ContactList { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [ContactList] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1180,6 +1220,11 @@ final class Empty { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Empty] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1262,6 +1307,11 @@ final class Minimal { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Minimal] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1353,6 +1403,11 @@ final class NamedItem { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [NamedItem] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1444,6 +1499,11 @@ final class Item { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Item] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1534,6 +1594,11 @@ final class MyCustomSchema123 { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [MyCustomSchema123] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); diff --git a/example/lib/schema_types_primitives.ack.dart b/example/lib/schema_types_primitives.ack.dart index 92840265..6b8785e5 100644 --- a/example/lib/schema_types_primitives.ack.dart +++ b/example/lib/schema_types_primitives.ack.dart @@ -43,6 +43,11 @@ final class Password { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Password] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -133,6 +138,11 @@ final class Age { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Age] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -222,6 +232,11 @@ final class Price { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Price] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -311,6 +326,11 @@ final class Active { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Active] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -401,6 +421,11 @@ final class Tags { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Tags] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -493,6 +518,11 @@ final class Scores { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Scores] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -584,6 +614,11 @@ final class StatusLiteral { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [StatusLiteral] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -676,6 +711,11 @@ final class Role { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Role] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -765,6 +805,11 @@ final class UserRoleModel { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [UserRoleModel] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -858,6 +903,11 @@ final class StatusEnum { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [StatusEnum] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -948,6 +998,11 @@ final class OptionalStatus { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [OptionalStatus] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1041,6 +1096,11 @@ final class DefaultedEnum { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [DefaultedEnum] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1134,6 +1194,11 @@ final class ChainedEnumString { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [ChainedEnumString] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -1227,6 +1292,11 @@ final class RefinedAge { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [RefinedAge] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); diff --git a/example/lib/schema_types_simple.ack.dart b/example/lib/schema_types_simple.ack.dart index a7fc0aa2..d87a21f4 100644 --- a/example/lib/schema_types_simple.ack.dart +++ b/example/lib/schema_types_simple.ack.dart @@ -46,6 +46,11 @@ final class User { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [User] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); diff --git a/example/lib/schema_types_transforms.ack.dart b/example/lib/schema_types_transforms.ack.dart index 1fe12740..3eb9daae 100644 --- a/example/lib/schema_types_transforms.ack.dart +++ b/example/lib/schema_types_transforms.ack.dart @@ -43,6 +43,11 @@ final class ColorModel { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [ColorModel] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -168,6 +173,11 @@ final class Profile { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Profile] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); diff --git a/example/lib/user_with_color.ack.dart b/example/lib/user_with_color.ack.dart index 7c2c08ed..116774ec 100644 --- a/example/lib/user_with_color.ack.dart +++ b/example/lib/user_with_color.ack.dart @@ -43,6 +43,11 @@ final class ColorModel { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [ColorModel] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -134,6 +139,11 @@ final class Profile { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [Profile] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); @@ -266,6 +276,11 @@ final class UserWithColor { toRuntime: (model) => model._toAckRuntime(), ); + /// The Ack schema that parses input into [UserWithColor] and encodes it back. + /// + /// A shorthand for `$ack.modelSchema`, created once. + static final schema = $ack.modelSchema; + /// Validates [input] and returns the model or the validation failure. static SchemaResult safeParse(Object? input) => $ack.safeParse(input); diff --git a/example/test/class_first_models_test.dart b/example/test/class_first_models_test.dart index 08461d6f..32e7dd07 100644 --- a/example/test/class_first_models_test.dart +++ b/example/test/class_first_models_test.dart @@ -15,6 +15,24 @@ void main() { expect(AccountSchema.wireSchema, isNotNull); }); + test('explicit descriptions and tags become schema descriptions', () { + final jsonSchema = AccountSchema.toJsonSchema(); + final properties = jsonSchema['properties'] as Map; + + expect(jsonSchema['description'], "A signed-in user's account."); + expect(AccountSchema.schema.description, "A signed-in user's account."); + expect( + (properties['display_name'] as Map)['description'], + 'The name shown to other users.', + ); + expect(PetSchema.schema.description, 'A pet, identified by its `type`.'); + expect( + CatSchema.schema.description, + 'A cat with a limited number of lives.', + ); + expect(DogSchema.schema.description, isNull); + }); + test('class-first sealed models dispatch through a discriminator', () { final pet = PetSchema.parse({'type': 'cat', 'id': 'c1', 'lives': 9}); diff --git a/llms.txt b/llms.txt index 226c5681..29db1bc9 100644 --- a/llms.txt +++ b/llms.txt @@ -84,6 +84,9 @@ Generated object models provide: - a typed `copyWith` (omitted means keep; explicit `null` clears a nullable field; wrong argument types fail to compile), deep collection-aware `==`/`hashCode`, and `toString`; +- a static `User.schema` model-valued schema, shorthand for + `User.$ack.modelSchema` (omitted when a field, discriminator, annotated + declaration, or import prefix is named `schema`); - a public static `User.$ack` adapter for nested generated models; - an unmodifiable `additionalProperties` map for passthrough objects. @@ -114,6 +117,10 @@ classes use `@AckModel(discriminatorKey: ...)`; same-library concrete branches are included automatically. Use `@Optional()` or `@Required()` to override inferred key presence, `@NotNull()` to reject an explicit JSON `null` without requiring the key, and `@AckField(schema: ...)` for custom field codecs. +Use `@AckModel(description: ...)` and `@AckField(description: ...)` to export +schema descriptions. A single-line `@description` doc tag is a fallback. +Untagged prose does not export. A field description applies at its property +without changing a nested model's own description. Deprecated `AckFieldPresence` remains available during migration. Unknown properties use `unknownProperties: AckUnknownPropertyPolicy.` (`reject` by default). Raw `Ack.object(..., additionalProperties: true)` validation preserves extras; diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 22779a36..9ea99114 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 1.7.0-beta.2 ### Added diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index cea5a60d..af848114 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -1,6 +1,6 @@ name: ack description: Schema validation and bidirectional codecs for Dart and Flutter -version: 1.6.2 +version: 1.7.0-beta.2 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues homepage: https://concepta.dev/ack diff --git a/packages/ack_annotations/CHANGELOG.md b/packages/ack_annotations/CHANGELOG.md index 890424c4..498b8dc8 100644 --- a/packages/ack_annotations/CHANGELOG.md +++ b/packages/ack_annotations/CHANGELOG.md @@ -1,7 +1,9 @@ -## Unreleased +## 1.7.0-beta.2 ### Added +* `@AckModel(description: ...)` and `@AckField(description: ...)` set schema + descriptions. Explicit values take precedence over `@description` doc tags. * Constraint and field annotations can target function parameters. * `@Url()`, `@Uuid()`, and `@Date()` are string format annotations. * `format_annotations.dart` exports `@Uri()` and `@DateTime()` for prefixed diff --git a/packages/ack_annotations/README.md b/packages/ack_annotations/README.md index a0036b58..273dc0be 100644 --- a/packages/ack_annotations/README.md +++ b/packages/ack_annotations/README.md @@ -8,11 +8,11 @@ retained for Ack 1.1 extension-type compatibility. ```yaml dependencies: - ack: ^1.6.2 - ack_annotations: ^1.6.2 + ack: ^1.7.0-beta.2 + ack_annotations: ^1.7.0-beta.2 dev_dependencies: - ack_generator: ^1.6.2 + ack_generator: ^1.7.0-beta.2 build_runner: ^2.4.0 ``` @@ -64,7 +64,11 @@ final class Account with _$AccountAck { This generates the public `AccountSchema` facade plus validated `toJson`, `safeToJson`, `copyWith`, equality, and `toString` implementations. -Field documentation becomes the field schema's description. Format +Use `@AckModel(description: ...)` and `@AckField(description: ...)` for schema +descriptions. A single-line `@description` documentation tag is a fallback. +Untagged prose does not become schema data. Explicit annotation text takes +precedence over the tag. A field description applies to its property without +changing a nested model's description. Format annotations such as `@Url()`, `@Uuid()`, and `@Date()` constrain string fields. Import `package:ack_annotations/format_annotations.dart` with a prefix for `@formats.Uri()` and `@formats.DateTime()`. The prefix preserves diff --git a/packages/ack_annotations/lib/src/ack_field.dart b/packages/ack_annotations/lib/src/ack_field.dart index 98f5edc0..54d62feb 100644 --- a/packages/ack_annotations/lib/src/ack_field.dart +++ b/packages/ack_annotations/lib/src/ack_field.dart @@ -51,7 +51,9 @@ final class NotNull { /// /// [schema] must be a const tear-off of a top-level function returning an Ack /// schema. The generator validates the declaration and follows its expression. -/// A no-op `@AckField()` is rejected. +/// [description] describes this property, not a nested model's schema. +/// It takes precedence over an `@description` doc tag. A no-op `@AckField()` +/// is rejected. /// /// Prefer `@Optional()` and `@Required()` for key presence. [presence] is /// deprecated and will be removed in 2.0.0. @@ -60,6 +62,7 @@ final class AckField { /// Creates a field annotation. const AckField({ this.schema, + this.description, @Deprecated( 'Use @Optional() or @Required(). AckField.presence will be removed in ' '2.0.0.', @@ -70,6 +73,9 @@ final class AckField { /// Top-level schema-function tear-off followed by `ack_generator`. final Object Function()? schema; + /// Schema description for this property. + final String? description; + /// Presence override applied after constructor inference. @Deprecated( 'Use @Optional() or @Required(). AckField.presence will be removed in 2.0.0.', diff --git a/packages/ack_annotations/lib/src/ack_model.dart b/packages/ack_annotations/lib/src/ack_model.dart index f04a6df5..dbb78bcf 100644 --- a/packages/ack_annotations/lib/src/ack_model.dart +++ b/packages/ack_annotations/lib/src/ack_model.dart @@ -31,6 +31,7 @@ final class AckModel { /// Creates a class-first Ack model annotation. const AckModel({ this.schemaName, + this.description, this.caseStyle = AckCaseStyle.none, this.discriminatorKey, this.discriminatorValue, @@ -68,6 +69,10 @@ final class AckModel { /// `Schema`, preserving the model class's written capitalization. final String? schemaName; + /// Schema description. This takes precedence over an `@description` doc tag. + /// Untagged documentation does not become a schema description. + final String? description; + /// Naming convention applied to JSON field keys. final AckCaseStyle caseStyle; diff --git a/packages/ack_annotations/pubspec.yaml b/packages/ack_annotations/pubspec.yaml index b81b0f5b..b6322993 100644 --- a/packages/ack_annotations/pubspec.yaml +++ b/packages/ack_annotations/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_annotations description: Annotations for Ack schema-first models and class-first schema generation -version: 1.6.2 +version: 1.7.0-beta.2 repository: https://github.com/btwld/ack resolution: workspace topics: diff --git a/packages/ack_firebase_ai/CHANGELOG.md b/packages/ack_firebase_ai/CHANGELOG.md index 9f0dbc35..f62cd730 100644 --- a/packages/ack_firebase_ai/CHANGELOG.md +++ b/packages/ack_firebase_ai/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.7.0-beta.2 + +### Changed + +* Align with the coordinated Ack 1.7.0 beta. This package has no source or + public API changes from 1.6.2; compatible dependency minimums are preserved. + ## 1.6.2 ### Changed diff --git a/packages/ack_firebase_ai/README.md b/packages/ack_firebase_ai/README.md index d54215f9..62e8a12f 100644 --- a/packages/ack_firebase_ai/README.md +++ b/packages/ack_firebase_ai/README.md @@ -24,7 +24,7 @@ Always validate model output with the same ACK schema after generation. Firebase ```yaml dependencies: ack: ^1.2.0 - ack_firebase_ai: ^1.6.2 + ack_firebase_ai: ^1.7.0-beta.2 firebase_ai: ^3.12.2 ``` diff --git a/packages/ack_firebase_ai/pubspec.yaml b/packages/ack_firebase_ai/pubspec.yaml index 94ac1acf..fbd5cc1e 100644 --- a/packages/ack_firebase_ai/pubspec.yaml +++ b/packages/ack_firebase_ai/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_firebase_ai description: Firebase AI (Gemini) schema converter for the Ack validation library -version: 1.6.2 +version: 1.7.0-beta.2 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index a895c535..627ecf38 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 1.7.0-beta.2 ### Breaking @@ -26,14 +26,31 @@ lint rules and coverage reports cover only hand-written source. * Generated constructors, parsing and JSON members, `$ack`, and class-first facades now have doc comments. +* Requires `ack` and `ack_annotations` `^1.7.0-beta.2`. +* Schema descriptions come only from `description:` or an `@description` doc + tag. This replaces the unreleased development behavior that exported every + field doc comment. Untagged prose and `//` comments never become schema + data. Shared `AckSchemaInference.applyDescription` uses the same rule. ### Added -* Class-first generation uses field documentation as schema descriptions and - supports the new string format annotations. +* `@AckInfer()` models declare `static final schema` as a shorthand for + `$ack.modelSchema`. The generator omits this member when `schema` conflicts + with a field, discriminator, annotated declaration, or import prefix. +* Class-first generation supports explicit `@AckModel` and `@AckField` + descriptions, plus single-line `@description` doc tags. Explicit annotation + values take precedence. Class descriptions apply to the wire object and + model codec; field descriptions apply only to that property. +* Class-first generation supports the new string format annotations. * `inference.dart` exports shared type and constraint inference for other generators. The class-first generator uses this library. +### Fixed + +* Escape line terminators and other control characters in every generated + string literal. `@Pattern`, discriminator, and key values containing a + newline or carriage return now produce valid Dart source. + ## 1.6.2 ### Changed diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index a931a82e..2d75c380 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -36,6 +36,18 @@ void main() { } ``` +`User.schema` is the model-valued schema: it parses input into `User` and +encodes a `User` back, so it composes like any other Ack schema: + +```dart +final users = Ack.list(User.schema).parse(json)!; // List +``` + +It is a shorthand for `User.$ack.modelSchema`. The generator omits it when the +model has a field or union discriminator named `schema`, when the annotated +declaration is itself named `schema`, or when the library imports a prefix +named `schema`; use `$ack.modelSchema` there. + Constructors don't validate immediately. Use `parse` for untrusted input; `toJson` and `safeToJson` validate a directly constructed model while encoding it. Generated models don't implement `Map`, and there are no `fromMap` or @@ -79,13 +91,19 @@ void main() { } ``` -Field documentation becomes an Ack schema description. The generator shares -its type and constraint inference through +Use `@AckModel(description: ...)` and `@AckField(description: ...)` for schema +descriptions. A single-line `@description` tag in a `///` or `/** */` doc +comment is a fallback. Only the text after the tag, on the same line, is exported. Untagged +prose never becomes schema data. Duplicate or blank tags fail generation. +Annotation text takes precedence over a tag. A class description applies to +both the object schema and model codec, including union bases and branches. +A field description applies at its property without changing a nested model. +The generator shares its type and constraint inference through `package:ack_generator/inference.dart`. A second generator can use `AckSchemaInference.inferType`, `applyConstraints`, and `applyDescription`. The caller resolves application model types and import prefixes. For function parameters, read the documentation comment from the source AST and pass it as -`sourceComment` to `applyDescription`. +`sourceComment` to `applyDescription`. That method uses the same tag-only rule. ## Schema support diff --git a/packages/ack_generator/dart_test.yaml b/packages/ack_generator/dart_test.yaml new file mode 100644 index 00000000..ec3f23bd --- /dev/null +++ b/packages/ack_generator/dart_test.yaml @@ -0,0 +1,11 @@ +# Most tests resolve code with the analyzer through testBuilder, and the +# integration tests run real build_runner builds in temporary packages, so each +# suite is memory-heavy. The default concurrency is half the CPU cores (6 on a +# 12-core laptop), which can use more memory than a laptop has. Two suites at +# a time is what CI's 4-core runners already use. +concurrency: 2 + +tags: + integration: + # Creates a temporary package and runs build_runner, pub get, and the + # analyzer in it. Skip with `dart test -x integration` for a quick run. 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 bc17c781..2b24254b 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 @@ -13,10 +13,13 @@ import 'package:source_gen/source_gen.dart'; import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import '../inference/schema_inference.dart'; +import '../utils/doc_comment_utils.dart'; import 'generated_companion_visibility.dart'; +import '../utils/string_literal.dart'; typedef _ModelOptions = ({ String? schemaName, + String? description, String caseStyle, String? discriminatorKey, String? discriminatorValue, @@ -394,6 +397,7 @@ final class ClassModelGraphBuilder { _options(branch) ?? ( schemaName: null, + description: null, caseStyle: baseOptions.caseStyle, discriminatorKey: null, discriminatorValue: null, @@ -436,6 +440,7 @@ final class ClassModelGraphBuilder { runtimeRef: AckExternalTypeRef(name: base.name!), discriminatorKey: discriminatorKey, branches: branches, + description: _classDescription(base, baseOptions), ), ); } @@ -614,7 +619,7 @@ final class ClassModelGraphBuilder { isDiscriminator: isDiscriminator, ); var schema = isDiscriminator && discriminatorValue != null - ? '${_ack('Ack')}.literal(${_literal(discriminatorValue)})' + ? '${_ack('Ack')}.literal(${dartStringLiteral(discriminatorValue)})' : await _fieldSchema(field, futureType: futureType); schema = _inference.applyDescription(schema, field); schema = _applyPresence( @@ -661,6 +666,7 @@ final class ClassModelGraphBuilder { unionId: unionId, discriminatorKey: discriminatorKey, discriminatorValue: discriminatorValue, + description: _classDescription(element, options), ); _graph.complete(node); return node; @@ -1434,10 +1440,12 @@ final class ClassModelGraphBuilder { .objectValue .getField('index')! .toIntValue()!; - if (schemaMissing && presenceIndex == 0) { + if (schemaMissing && + reader.read('description').isNull && + presenceIndex == 0) { throw InvalidGenerationSource( '${field.enclosingElement.name}.${field.name} @AckField() is a ' - 'no-op; set schema or presence.', + 'no-op; set schema, description, or presence.', element: field, ); } @@ -1740,6 +1748,7 @@ final class ClassModelGraphBuilder { const styles = ['none', 'snake', 'kebab', 'pascal', 'screamingSnake']; return ( schemaName: _nullableString(reader, 'schemaName'), + description: _nullableString(reader, 'description'), caseStyle: styles[caseIndex], discriminatorKey: _nullableString(reader, 'discriminatorKey'), discriminatorValue: _nullableString(reader, 'discriminatorValue'), @@ -1753,6 +1762,29 @@ final class ClassModelGraphBuilder { ); } + String? _classDescription(ClassElement element, _ModelOptions options) { + final tag = _descriptionTag(element); + final explicit = options.description; + if (explicit != null && explicit.trim().isEmpty) { + throw InvalidGenerationSource( + '${element.name} @AckModel.description must not be blank.', + element: element, + ); + } + return explicit ?? tag; + } + + String? _descriptionTag(Element element) { + try { + return parseDescriptionTag(element.documentationComment); + } on FormatException catch (error) { + throw InvalidGenerationSource( + '${element.name} ${error.message}', + element: element, + ); + } + } + String? _nullableString(ConstantReader reader, String name) { final value = reader.read(name); return value.isNull ? null : value.stringValue; @@ -1940,14 +1972,6 @@ final class ClassModelGraphBuilder { return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; } - String _literal(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } - AckSchemaId _id(ClassElement element) => AckSchemaId( libraryUri: library.element.uri, declarationName: element.name!, 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 8bccd45d..f863af32 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -1,6 +1,7 @@ import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import 'data_class_emitter.dart'; +import '../utils/string_literal.dart'; /// Emits class-first schema codecs and top-level JSON/runtime glue. final class AckClassModelEmitter { @@ -106,10 +107,10 @@ final class AckClassModelEmitter { for (final entry in node.branches.entries) { final branch = nodes[entry.value]!; schemaEntries.add( - '${_literal(entry.key)}: ${ackClassRawObjectName(branch.className)}', + '${dartStringLiteral(entry.key)}: ${ackClassRawObjectName(branch.className)}', ); decodeCases.add( - '${_literal(entry.key)} => ' + '${dartStringLiteral(entry.key)} => ' '${ackClassFromRuntimeName(branch.className)}(value)', ); } @@ -146,14 +147,14 @@ final class AckClassModelEmitter { output ..writeln(''' final $rawName = ${_ack('Ack')}.discriminated( - discriminatorKey: ${_literal(node.discriminatorKey)}, + discriminatorKey: ${dartStringLiteral(node.discriminatorKey)}, schemas: {${schemaEntries.join(', ')}}, -); +)${_describe(node.description)}; final $wireName = ${_ack('Ack')}.preserveBoundary($rawName); final ${metadata.backingName} = $rawName.codec<${node.className}>( - decode: (value) => switch (value[${_literal(node.discriminatorKey)}]) { + decode: (value) => switch (value[${dartStringLiteral(node.discriminatorKey)}]) { ${decodeCases.join(',\n ')}, final unknown => throw StateError( 'Unknown ${node.discriminatorKey}: \$unknown', @@ -162,7 +163,7 @@ final ${metadata.backingName} = $rawName.codec<${node.className}>( encode: (model) => switch (model) { ${encodeCases.join(',\n ')}, }, -);''') +)${_describe(node.description)};''') ..writeln() ..writeln(_facade(node.className, metadata, wireName: wireName)) ..writeln() @@ -188,9 +189,16 @@ final ${metadata.backingName} = $rawName.codec<${node.className}>( 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 + /// only the object would leave `XSchema.schema.description` unset. + String _describe(String? description) => + description == null ? '' : '.describe(${dartStringLiteral(description)})'; + String _facade( String className, AckClassModelMetadata metadata, { @@ -253,14 +261,15 @@ abstract final class ${metadata.facadeName} { if (node.discriminatorKey case final key?) if (node.discriminatorValue case final value?) if (!node.fields.any((field) => field.jsonKey == key)) - '${_literal(key)}: ${_ack('Ack')}.literal(${_literal(value)}).optional()', + '${dartStringLiteral(key)}: ${_ack('Ack')}.literal(${dartStringLiteral(value)}).optional()', for (final field in node.fields) - '${_literal(field.jsonKey)}: ${field.schemaExpression}', + '${dartStringLiteral(field.jsonKey)}: ${field.schemaExpression}', ]; final additional = node.allowsUnknownProperties ? ', additionalProperties: true' : ''; - return '${_ack('Ack')}.object({${entries.join(', ')}}$additional)'; + return '${_ack('Ack')}.object({${entries.join(', ')}}$additional)' + '${_describe(node.description)}'; } String _fromRuntimeFunction(AckObjectModelNode node) { @@ -279,7 +288,7 @@ ${node.className} $function(Map value) { const declared = ${_keySet(declared)}; return $helper({ ...value, - ${_literal(captureJsonKey)}: Map.fromEntries( + ${dartStringLiteral(captureJsonKey)}: Map.fromEntries( value.entries.where((entry) => !declared.contains(entry.key)), ), }); @@ -313,10 +322,10 @@ Map $function(${node.className} model) => ..add('final result = {...$jsonHelper(model)};') ..addAll([ if (captureJsonKey != null) - 'result.remove(${_literal(captureJsonKey)});', + 'result.remove(${dartStringLiteral(captureJsonKey)});', for (final field in requiredNulls) 'if (model.${field.dartName} == null) { ' - 'result[${_literal(field.jsonKey)}] = null; }', + 'result[${dartStringLiteral(field.jsonKey)}] = null; }', ]); final entries = [ @@ -325,7 +334,7 @@ Map $function(${node.className} model) => ' if (!declared.contains(entry.key)) entry.key: entry.value', '...result', if (discriminatorKey != null && discriminatorValue != null) - '${_literal(discriminatorKey)}: ${_literal(discriminatorValue)}', + '${dartStringLiteral(discriminatorKey)}: ${dartStringLiteral(discriminatorValue)}', ]; lines.add('return {${entries.join(',\n ')}};'); return ''' @@ -456,7 +465,7 @@ Map $function(${node.className} model) { }; String _keySet(Set keys) => - '{${keys.map(_literal).join(', ')}}'; + '{${keys.map(dartStringLiteral).join(', ')}}'; AckClassModelMetadata _metadata(AckModelGraph graph, AckModelNode node) { final metadata = graph.classMetadataFor(node.id); @@ -470,12 +479,4 @@ Map $function(${node.className} model) { final prefix = ackPrefix; return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; } - - String _literal(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } } diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index 30cc0991..deb7d2df 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -3,14 +3,24 @@ import 'package:code_builder/code_builder.dart'; import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import 'data_class_emitter.dart'; +import '../utils/string_literal.dart'; /// Emits immutable model declarations solely from a normalized model graph. final class AckModelEmitter { - AckModelEmitter({this.ackPrefix, this.ackInferPrefix}); + AckModelEmitter({ + this.ackPrefix, + this.ackInferPrefix, + this.schemaPrefixInScope = false, + }); + + static const _schemaShorthandName = 'schema'; final String? ackPrefix; final String? ackInferPrefix; + /// Whether the annotated library imports a prefix named `schema`. + final bool schemaPrefixInScope; + List emit(AckModelGraph graph) { final nodes = {for (final node in graph.nodes) node.id: node}; final output = []; @@ -55,6 +65,11 @@ final class AckModelEmitter { for (final field in fields) _field(field), if (node.additionalProperties) _additionalPropertiesField(), _adapter(node, node.id.declarationName), + if (_exposesSchemaShorthand( + declarationName: node.id.declarationName, + fields: fields, + )) + _schemaShorthand(node), ]) ..constructors.addAll([ _objectConstructor(fields, node.additionalProperties), @@ -92,6 +107,11 @@ final class AckModelEmitter { ..type = refer(runtimeRef), ), _adapter(node, node.id.declarationName), + if (_exposesSchemaShorthand( + declarationName: node.id.declarationName, + fields: [_valueField(node)], + )) + _schemaShorthand(node), ]) ..constructors.addAll([ _valueConstructor(node, runtimeRef), @@ -153,7 +173,7 @@ final class AckModelEmitter { for (final entry in node.branches.entries) { final branch = nodes[entry.value]!; cases.add( - '${_literal(entry.key)} => ${branch.className}._fromAckRuntime(value)', + '${dartStringLiteral(entry.key)} => ${branch.className}._fromAckRuntime(value)', ); } return Class( @@ -161,7 +181,14 @@ final class AckModelEmitter { ..name = node.className ..sealed = true ..docs.addAll(_docs(node, 'Discriminated model base')) - ..fields.add(_adapter(node, node.id.declarationName)) + ..fields.addAll([ + _adapter(node, node.id.declarationName), + if (_exposesSchemaShorthand( + declarationName: node.id.declarationName, + discriminatorKey: node.discriminatorKey, + )) + _schemaShorthand(node), + ]) ..constructors.addAll([ Constructor( (c) => c @@ -197,7 +224,7 @@ final class AckModelEmitter { ), ) ..body = Code(''' -return switch (value[${_literal(node.discriminatorKey)}]) { +return switch (value[${dartStringLiteral(node.discriminatorKey)}]) { ${cases.join(',\n ')}, final unknown => throw StateError( 'Unknown ${node.discriminatorKey}: \$unknown', @@ -229,8 +256,14 @@ return switch (value[${_literal(node.discriminatorKey)}]) { if (node.additionalProperties) _additionalPropertiesField(), _adapter( node, - '${union.id.declarationName}.effectiveBranch(${_literal(value)})', + '${union.id.declarationName}.effectiveBranch(${dartStringLiteral(value)})', ), + if (_exposesSchemaShorthand( + declarationName: union.id.declarationName, + fields: fields, + discriminatorKey: discriminator, + )) + _schemaShorthand(node), ]) ..constructors.addAll([ _objectConstructor(fields, node.additionalProperties), @@ -246,7 +279,7 @@ return switch (value[${_literal(node.discriminatorKey)}]) { ..name = discriminator ..returns = refer('String') ..lambda = true - ..body = Code(_literal(value)), + ..body = Code(dartStringLiteral(value)), ), ..._valueMembers(node.className, _objectDataClass(node)), _objectFromRuntime( @@ -257,7 +290,7 @@ return switch (value[${_literal(node.discriminatorKey)}]) { _objectToRuntime( node, fields: fields, - leadingEntries: {discriminator: _literal(value)}, + leadingEntries: {discriminator: dartStringLiteral(value)}, isOverride: true, ), ..._fieldBridges(fields), @@ -300,6 +333,39 @@ ${_ack('AckModelAdapter')}( )'''), ); + /// Whether a model class can declare the static `schema` shorthand. + /// + /// Dart rejects a static and an instance member with the same name, so a + /// field or discriminator getter named `schema` suppresses the shorthand. + /// A schema declaration named `schema` suppresses it too, because the + /// static would shadow the declaration that [_adapter] reads. An import + /// prefix named `schema` suppresses it for the same reason: generated code + /// may qualify Ack or nested model types through that prefix. The model + /// still exposes the same schema as `$ack.modelSchema`. + bool _exposesSchemaShorthand({ + required String declarationName, + Iterable fields = const [], + String? discriminatorKey, + }) => + !schemaPrefixInScope && + declarationName != _schemaShorthandName && + discriminatorKey != _schemaShorthandName && + fields.every((field) => field.dartName != _schemaShorthandName); + + Field _schemaShorthand(AckModelNode node) => Field( + (f) => f + ..name = _schemaShorthandName + ..static = true + ..modifier = FieldModifier.final$ + ..docs.addAll([ + '/// The Ack schema that parses input into [${node.className}] and ' + 'encodes it back.', + '///', + '/// A shorthand for `\$ack.modelSchema`, created once.', + ]) + ..assignment = const Code(r'$ack.modelSchema'), + ); + Constructor _objectConstructor( List fields, bool additionalProperties, @@ -700,7 +766,7 @@ return $helper({ if (!needsBlock) { final entries = [ for (final entry in leadingEntries.entries) - '${_literal(entry.key)}: ${entry.value}', + '${dartStringLiteral(entry.key)}: ${entry.value}', '...$helper(this)', ]; m @@ -717,7 +783,7 @@ return $helper({ for (final field in requiredNulls) { lines.add( 'if (${field.dartName} == null) {' - ' result[${_literal(field.jsonKey)}] = null;' + ' result[${dartStringLiteral(field.jsonKey)}] = null;' ' }', ); } @@ -726,7 +792,7 @@ return $helper({ 'for (final entry in additionalProperties.entries)\n' ' if (!declared.contains(entry.key)) entry.key: entry.value', for (final entry in leadingEntries.entries) - '${_literal(entry.key)}: ${entry.value}', + '${dartStringLiteral(entry.key)}: ${entry.value}', '...result', ]; lines.add( @@ -957,7 +1023,7 @@ return $helper({ }) => {...additionalKeys, for (final field in fields) field.jsonKey}; String _declaredKeysLiteral(Set keys) => - '{${keys.map(_literal).join(', ')}}'; + '{${keys.map(dartStringLiteral).join(', ')}}'; AckInferRef _nonNullable(AckInferRef type) => switch (type) { AckNullableTypeRef(:final inner) => inner, @@ -1022,14 +1088,6 @@ return $helper({ return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; } - String _literal(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } - static const _runtimeMapType = 'Map'; static const _runtimeMapLiteral = ''; static const _objectJsonType = 'Map'; diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 9c87fcd2..a44727ec 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -7,6 +7,7 @@ import 'package:analyzer/dart/element/type.dart'; import 'package:source_gen/source_gen.dart'; import '../utils/doc_comment_utils.dart'; +import '../utils/string_literal.dart'; /// Infers Ack schema expressions from resolved Dart types and annotations. /// @@ -89,6 +90,10 @@ final class AckSchemaInference { annotations.UniqueItems, inPackage: 'ack_annotations', ); + static const _ackField = TypeChecker.typeNamed( + annotations.AckField, + inPackage: 'ack_annotations', + ); /// Infers a schema expression for [type]. /// @@ -185,7 +190,7 @@ final class AckSchemaInference { } else if (_pattern.isExactlyType(valueType)) { _require(declaration, type, '@Pattern', isString, 'String field'); output = - '$output.matches(${_literal(value.getField('pattern')!.toStringValue()!)})'; + '$output.matches(${dartStringLiteral(value.getField('pattern')!.toStringValue()!)})'; } else if (_email.isExactlyType(valueType)) { _require(declaration, type, '@Email', isString, 'String field'); output = '$output.email()'; @@ -268,7 +273,7 @@ final class AckSchemaInference { return output; } - /// Adds documentation as an Ack description. + /// Adds an explicit Ack description or a tagged documentation description. /// /// Analyzer does not attach parameter doc comments to parameter elements. /// A function generator passes the comment from its source AST. @@ -277,12 +282,32 @@ final class AckSchemaInference { Element declaration, { String? sourceComment, }) { - final description = parseDocComment( - sourceComment ?? declaration.documentationComment, - ); + final String? tag; + try { + tag = parseDescriptionTag( + sourceComment ?? declaration.documentationComment, + ); + } on FormatException catch (error) { + throw InvalidGenerationSource( + '${declaration.name} ${error.message}', + element: declaration, + ); + } + final annotation = _ackField.firstAnnotationOfExact(declaration); + final value = annotation == null + ? null + : ConstantReader(annotation).read('description'); + final explicit = value == null || value.isNull ? null : value.stringValue; + if (explicit != null && explicit.trim().isEmpty) { + throw InvalidGenerationSource( + '${declaration.name} @AckField.description must not be blank.', + element: declaration, + ); + } + final description = explicit ?? tag; return description == null ? schema - : '$schema.describe(${_literal(description)})'; + : '$schema.describe(${dartStringLiteral(description)})'; } String? _scalar(InterfaceType type) { @@ -319,14 +344,6 @@ final class AckSchemaInference { return (number.toIntValue() ?? number.toDoubleValue())!.toString(); } - String _literal(String value) { - final escaped = value - .replaceAll(r'\', r'\\') - .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); - return "'$escaped'"; - } - void _require( Element declaration, DartType type, diff --git a/packages/ack_generator/lib/src/model_generator.dart b/packages/ack_generator/lib/src/model_generator.dart index 876d2eb8..68dce8d9 100644 --- a/packages/ack_generator/lib/src/model_generator.dart +++ b/packages/ack_generator/lib/src/model_generator.dart @@ -122,6 +122,9 @@ final class AckModelGenerator extends Generator { final specs = AckModelEmitter( ackPrefix: schemaFirst.ackPrefix, ackInferPrefix: schemaFirst.ackInferPrefix, + schemaPrefixInScope: library.element.firstFragment.libraryImports.any( + (import) => import.prefix?.element.name == 'schema', + ), ).emit(schemaFirst.graph); output.add( Library((b) => b.body.addAll(specs)) diff --git a/packages/ack_generator/lib/src/utils/doc_comment_utils.dart b/packages/ack_generator/lib/src/utils/doc_comment_utils.dart index 0392f9e4..427ae07c 100644 --- a/packages/ack_generator/lib/src/utils/doc_comment_utils.dart +++ b/packages/ack_generator/lib/src/utils/doc_comment_utils.dart @@ -1,49 +1,70 @@ -/// Utilities for parsing Dart documentation comments. +/// Utilities for reading Ack tags from resolved Dart documentation comments. library; -/// Parses a Dart documentation comment into a clean description string. +/// Reads the text on one `@description` line in a Dart documentation comment. /// -/// Supports `///` and `/** ... */` doc comments, including multi-line variants, -/// and returns cleaned text as a single string. -/// -/// Returns `null` if the comment is empty or cannot be parsed. -/// -/// Example: -/// ```dart -/// final description = parseDocComment('/// User name field'); -/// // Returns: 'User name field' -/// ``` -String? parseDocComment(String? docComment) { - if (docComment == null || docComment.isEmpty) { - return null; - } +/// Ordinary prose and tags inside fenced code blocks do not become schema +/// descriptions. The tag must start a `///` or `/** */` documentation line. +/// A blank or duplicate tag is ambiguous and causes a [FormatException]. +String? parseDescriptionTag(String? docComment) { + if (docComment == null || docComment.isEmpty) return null; final normalized = docComment.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + final rawLines = normalized.split('\n'); + final isLineComment = normalized.trimLeft().startsWith('///'); + final isBlockComment = normalized.trimLeft().startsWith('/**'); + if (!isLineComment && !isBlockComment) return null; - // Handle /// style comments (check startsWith to avoid false matches) - if (normalized.startsWith('///')) { - final lines = normalized - .split('\n') - .map((line) => line.replaceFirst(RegExp(r'^\s*///\s?'), '')) - .where((line) => line.isNotEmpty) - .toList(); - - if (lines.isEmpty) return null; - return lines.join(' ').trim(); + String cleanLine(String line, int index) { + var value = line.trimLeft(); + if (isLineComment) { + if (!value.startsWith('///')) return ''; + value = value.substring(3); + } else { + if (index == 0) { + value = value.substring(3); + } else if (value.startsWith('*')) { + value = value.substring(1); + } + if (index == rawLines.length - 1 && value.endsWith('*/')) { + value = value.substring(0, value.length - 2); + } + } + return value.trimLeft(); } - // Handle /** */ style comments - if (normalized.startsWith('/**')) { - final content = normalized - .replaceFirst(RegExp(r'^/\*\*\s*'), '') - .replaceFirst(RegExp(r'\s*\*/$'), '') - .split('\n') - .map((line) => line.replaceFirst(RegExp(r'^\s*\*\s?'), '')) - .where((line) => line.isNotEmpty) - .join(' ') - .trim(); - - return content.isEmpty ? null : content; + String? description; + String? fence; + for (var index = 0; index < rawLines.length; index++) { + final line = cleanLine(rawLines[index], index); + final fenceMatch = RegExp(r'^(`{3,}|~{3,})').firstMatch(line); + if (fenceMatch != null) { + final marker = fenceMatch.group(1)!; + if (fence == null) { + fence = marker; + continue; + } + // A closing fence is a bare marker; a line with an info string such + // as ```dart stays inside the open fence. + final closes = + marker[0] == fence[0] && + marker.length >= fence.length && + line.substring(marker.length).trim().isEmpty; + if (closes) fence = null; + continue; + } + if (fence != null || !RegExp(r'^@description(?:\s|$)').hasMatch(line)) { + continue; + } + final text = line.substring('@description'.length).trim(); + if (text.isEmpty) { + throw const FormatException( + '@description requires text on the same documentation line.', + ); + } + if (description != null) { + throw const FormatException('Duplicate @description tags.'); + } + description = text; } - - return null; + return description; } diff --git a/packages/ack_generator/lib/src/utils/string_literal.dart b/packages/ack_generator/lib/src/utils/string_literal.dart new file mode 100644 index 00000000..f3614732 --- /dev/null +++ b/packages/ack_generator/lib/src/utils/string_literal.dart @@ -0,0 +1,16 @@ +/// Returns [value] as a single-quoted Dart string literal. +/// +/// Escapes backslashes, quotes, and `$`, and writes control characters and +/// Unicode line separators as `\uXXXX`, so generated source stays valid and +/// keeps the original string value. +String dartStringLiteral(String value) { + final escaped = value + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$') + .replaceAllMapped(RegExp(r'[\x00-\x1f\u2028\u2029]'), (match) { + final hex = match[0]!.codeUnitAt(0).toRadixString(16).padLeft(4, '0'); + return '\\u$hex'; + }); + return "'$escaped'"; +} diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 2aca111a..70302e1a 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_generator description: Generates immutable models from Ack schemas and validated schemas from Dart classes -version: 1.6.2 +version: 1.7.0-beta.2 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -28,8 +28,8 @@ dependencies: meta: ^1.15.0 # Ack packages (versions are overridden locally by Melos) - ack: ^1.5.0 - ack_annotations: ^1.4.0 + ack: ^1.7.0-beta.2 + ack_annotations: ^1.7.0-beta.2 dev_dependencies: build_runner: ^2.1.7 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 e5a96bdf..be98a740 100644 --- a/packages/ack_generator/test/integration/ack_infer_contract_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_contract_test.dart @@ -30,6 +30,21 @@ part 'schema.ack.dart'; part 'schema.ack.g.dart'; '''; +const _schemaShorthand = r'static final schema = $ack.modelSchema;'; + +/// Matches generated source whose `className` declaration has, or lacks, the +/// static `schema` shorthand. +Matcher _schemaShorthandOn(String className, {required bool present}) => + predicate((source) { + 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); + return body.contains(_schemaShorthand) == present; + }, '$className ${present ? 'declares' : 'omits'} the schema shorthand'); + void main() { test( 'object models expose only the AckInfer parse and JSON contract', @@ -127,6 +142,113 @@ final customSchema = Ack.string(); ); }); + test('every model kind declares a static schema shorthand', () async { + await _generate( + ''' +$_imports +@AckInfer() +final userSchema = Ack.object({'name': Ack.string()}); + +@AckInfer() +final occurredAtSchema = Ack.datetime(); + +@AckInfer() +final catSchema = Ack.object({'lives': Ack.integer()}); + +@AckInfer() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', + schemas: {'cat': catSchema}, +); +''', + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + allOf([ + _schemaShorthandOn('User', present: true), + _schemaShorthandOn('OccurredAt', present: true), + _schemaShorthandOn('Pet', present: true), + _schemaShorthandOn('Cat', present: true), + contains(r'static final $ack = AckModelAdapter'), + ]), + ), + }, + ); + }); + + test('the schema shorthand yields to colliding members', () async { + await _generate( + ''' +$_imports +@AckInfer() +final documentSchema = Ack.object({'schema': Ack.string()}); + +@AckInfer(name: 'Settings') +final schema = Ack.object({'theme': Ack.string()}); + +@AckInfer() +final draftSchema = Ack.object({'body': Ack.string()}); + +@AckInfer() +final revisionSchema = Ack.discriminated( + discriminatorKey: 'schema', + schemas: {'draft': draftSchema}, +); + +@AckInfer() +final circleSchema = Ack.object({'radius': Ack.integer()}); + +@AckInfer() +final ruleSchema = Ack.object({'schema': Ack.string()}); + +@AckInfer() +final shapeSchema = Ack.discriminated( + discriminatorKey: 'type', + schemas: {'circle': circleSchema, 'rule': ruleSchema}, +); +''', + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + allOf([ + // A field named `schema`. + _schemaShorthandOn('Document', present: false), + // The shorthand would shadow the `schema` declaration. + _schemaShorthandOn('Settings', present: false), + // The discriminator getter is named `schema`. + _schemaShorthandOn('Revision', present: false), + _schemaShorthandOn('Draft', present: false), + // Only the branch with a `schema` field loses the shorthand. + _schemaShorthandOn('Shape', present: true), + _schemaShorthandOn('Circle', present: true), + _schemaShorthandOn('Rule', present: false), + ]), + ), + }, + ); + }); + + test('an import prefix named schema suppresses the shorthand', () async { + await _generate( + ''' +import 'package:ack/ack.dart' as schema; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'schema.ack.dart'; +part 'schema.ack.g.dart'; + +@AckInfer() +final userSchema = schema.Ack.object({'name': schema.Ack.string()}); +''', + outputs: { + 'test_pkg|lib/schema.ack.dart': decodedMatches( + allOf([ + contains('schema.AckModelAdapter'), + _schemaShorthandOn('User', present: false), + ]), + ), + }, + ); + }); + test('rejects one-way transforms anywhere in the graph', () async { var sawError = false; await _generate( 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 e073e620..5058c81b 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 @@ -1,4 +1,7 @@ // Modern schema-first runtime integration tests. +@Tags(['integration']) +library; + import 'dart:io'; import 'package:path/path.dart' as p; @@ -221,6 +224,33 @@ final class Counted { } } +@AckInfer() +final documentSchema = Ack.object({'schema': Ack.string()}); + +@AckInfer(name: 'Settings') +final schema = Ack.object({'theme': Ack.string()}); + +@AckInfer() +final draftSchema = Ack.object({'body': Ack.string()}); + +@AckInfer() +final revisionSchema = Ack.discriminated( + discriminatorKey: 'schema', + schemas: {'draft': draftSchema}, +); + +@AckInfer() +final circleSchema = Ack.object({'radius': Ack.integer()}); + +@AckInfer() +final ruleSchema = Ack.object({'schema': Ack.string()}); + +@AckInfer() +final shapeSchema = Ack.discriminated( + discriminatorKey: 'type', + schemas: {'circle': circleSchema, 'rule': ruleSchema}, +); + @AckInfer(name: 'CountedModel') final countedSchema = Ack.object({ 'item': Ack.string().codec( @@ -272,11 +302,65 @@ final personSchema = Ack.object({ 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'; import 'package:test/test.dart'; void main() { + test('models expose their model schema as a static shorthand', () { + final AckSchema, Node> schema = Node.schema; + expect(identical(Node.schema, schema), isTrue); + expect(Node.schema, Node.$ack.modelSchema); + expect(Node.schema.hashCode, Node.$ack.modelSchema.hashCode); + + final nodes = Ack.list(Node.schema).parse([ + {'label': 'a'}, + {'label': 'b'}, + ])!; + expect(nodes, [Node(label: 'a'), Node(label: 'b')]); + expect(Ack.list(Node.schema).encode(nodes), [ + {'label': 'a'}, + {'label': 'b'}, + ]); + + final AckSchema member = MemberType.schema; + expect(member, MemberType.$ack.modelSchema); + expect(member.parse('admin'), MemberType('admin')); + + expect(Pet.schema, Pet.$ack.modelSchema); + expect(Pet.schema.parse({'kind': 'cat', 'lives': 9}), isA()); + expect(Cat.schema, Cat.$ack.modelSchema); + expect(Shape.schema.parse({'type': 'circle', 'radius': 1}), isA()); + expect(Circle.schema, Circle.$ack.modelSchema); + }); + + test('colliding models keep their members and the adapter schema', () { + final document = Document.$ack.modelSchema.parse({'schema': 'v1'})!; + expect(document.schema, 'v1'); + + final rule = Shape.schema.parse({'type': 'rule', 'schema': 'v2'}); + expect(rule, isA()); + expect((rule as Rule).schema, 'v2'); + expect(Rule.$ack.modelSchema.encode(rule), {'type': 'rule', 'schema': 'v2'}); + + final settings = Settings.$ack.modelSchema.parse({'theme': 'dark'})!; + expect(settings.theme, 'dark'); + // The adapter still reads the top-level `schema` declaration. + expect(Settings.$ack.schema, same(schema)); + + final revision = Revision.$ack.modelSchema.parse({ + 'schema': 'draft', + 'body': 'text', + })!; + expect(revision, isA()); + expect(revision.schema, 'draft'); + expect(Draft.$ack.modelSchema.encode(revision as Draft), { + 'schema': 'draft', + 'body': 'text', + }); + }); + test('schema-first adapter exposes a model-valued schema', () { final schema = Node.$ack.modelSchema; final node = schema.parse({'label': 'root'})!; 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 eec8e0df..e1d25ec6 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 @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:io'; import 'package:path/path.dart' as p; 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 bb039418..f7ff121d 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 @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:io'; import 'package:path/path.dart' as p; 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 f2957fb7..1aacd597 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 @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:io'; import 'package:path/path.dart' as p; @@ -507,6 +510,68 @@ final class ImplementingChild with _$ImplementingChildAck implements Parent { final String? note; } +/// A task the person can complete. +/// @description This tag has lower priority. +@AckModel(description: 'A task the person can complete.') +final class Task with _$TaskAck { + const Task({required this.id, required this.title}); + + final String id; + + /// What to do. + /// @description What to do. + final String title; +} + +/** + * A board that groups + * related tasks. + * @description A board that groups related tasks. + */ +@AckModel() +final class Board with _$BoardAck { + const Board({required this.focus, required this.backlog, this.pinned}); + + /// The task to do next. + /// @description This tag has lower priority. + @AckField(description: 'The task to do next.') + final Task focus; + final List backlog; + final Task? pinned; +} + +@AckModel() +final class Undocumented with _$UndocumentedAck { + const Undocumented({required this.value}); + + /// Ordinary field prose does not become schema data. + final String value; +} + +/// A shape to draw. +/// @description A shape to draw. +@AckModel(discriminatorKey: 'kind') +sealed class Shape with _$ShapeAck { + const Shape(); +} + +/// A circle, sized by its radius. +@AckModel( + discriminatorValue: 'circle', + description: 'A circle, sized by its radius.', +) +final class Circle extends Shape with _$CircleAck { + const Circle({required this.radius}); + + final double radius; +} + +final class Square extends Shape with _$SquareAck { + const Square({required this.side}); + + final double side; +} + @AckInfer() final legacySchema = Ack.object({'enabled': Ack.boolean()}); @@ -523,6 +588,8 @@ final class PlainJson { 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'; @@ -1021,6 +1088,62 @@ void main() { expect(PetSchema.safeParse({'id': 'c2', 'lives': 7}).isFail, isTrue); }); + test('explicit descriptions and tags describe class-first object schemas', () { + const task = 'A task the person can complete.'; + expect(TaskSchema.toJsonSchema()['description'], task); + expect(TaskSchema.schema.description, task); + expect(TaskSchema.wireSchema.description, task); + expect(TaskSchema.toSchemaModel().toJsonSchema()['description'], task); + final taskProperties = + TaskSchema.toJsonSchema()['properties'] as Map; + expect( + (taskProperties['title'] as Map)['description'], + 'What to do.', + ); + expect( + (taskProperties['id'] as Map)['description'], + isNull, + ); + + expect( + BoardSchema.schema.description, + 'A board that groups related tasks.', + ); + final board = BoardSchema.toJsonSchema(); + final boardProperties = board['properties'] as Map; + expect( + (boardProperties['focus'] as Map)['description'], + 'The task to do next.', + ); + expect( + ((boardProperties['backlog'] as Map)['items'] + as Map)['description'], + task, + ); + expect(jsonEncode(boardProperties['pinned']), contains(task)); + + expect(UndocumentedSchema.toJsonSchema().containsKey('description'), isFalse); + expect(UndocumentedSchema.schema.description, isNull); + expect(UndocumentedSchema.wireSchema.description, isNull); + }); + + test('union base and branch descriptions describe their schemas', () { + expect(ShapeSchema.schema.description, 'A shape to draw.'); + expect(ShapeSchema.toJsonSchema()['description'], 'A shape to draw.'); + expect(CircleSchema.schema.description, 'A circle, sized by its radius.'); + expect(SquareSchema.schema.description, isNull); + final branches = (ShapeSchema.toJsonSchema()['anyOf'] as List) + .cast>(); + expect(branches.map((branch) => branch['description']), [ + 'A circle, sized by its radius.', + null, + ]); + expect( + ShapeSchema.parse({'kind': 'circle', 'radius': 2}), + const Circle(radius: 2), + ); + }); + test('prefixed same-named imported types preserve identity', () { final pair = ImportedPairSchema.parse({'left': 'a', 'right': 2}); expect(pair.left, isA()); 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 02508b77..8ad4772a 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -21,13 +21,26 @@ Future _build( ); } +/// Matches generated [code] regardless of formatter line breaks. +/// +/// Whitespace and trailing commas are ignored on both sides. +Matcher _containsCode(String code) => predicate( + (output) => _compact(output).contains(_compact(code)), + 'contains code `$code`', +); + +String _compact(String code) => code + .replaceAll(RegExp(r'\s+'), '') + .replaceAll(',)', ')') + .replaceAll(',}', '}'); + const _imports = ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; '''; void main() { - test('class-first field docs describe the inferred schema', () async { + test('a field description tag describes the inferred schema', () async { await _build( { 'documented.dart': @@ -41,6 +54,7 @@ final class Documented with _\$DocumentedAck { const Documented({required this.title}); /// The item title. + /// @description The item title. final String title; } ''', @@ -53,6 +67,182 @@ final class Documented with _\$DocumentedAck { ); }); + test('description tags describe the object and its codec', () async { + const task = r"'A task the person\'s \$team can complete.'"; + await _build( + { + 'documented.dart': + ''' +$_imports +part 'documented.ack.dart'; +part 'documented.ack.g.dart'; + +/// A task the person's \$team can complete. +/// @description A task the person's \$team can complete. +/// +/// Shown in the task list. +@AckModel() +final class Task with _\$TaskAck { + const Task({required this.title}); + + /// What to do. + /// @description What to do. + final String title; +} + +/** + * A note + * without fields. + * @description A note without fields. + */ +@AckModel() +final class Note with _\$NoteAck { + const Note(); +} +''', + }, + outputs: { + 'test_pkg|lib/documented.ack.dart': decodedMatches( + allOf( + _containsCode( + 'final _taskObject = Ack.object({' + "'title': Ack.string().describe('What to do.')," + '}).describe($task);', + ), + _containsCode( + 'final _taskSchema = _taskObject.codec(' + r'decode: _$TaskFromRuntime, encode: _$TaskToRuntime,' + ').describe($task);', + ), + _containsCode( + "final _noteObject = Ack.object({}).describe('A note without " + "fields.');", + ), + _containsCode( + r'encode: _$NoteToRuntime,' + ").describe('A note without fields.');", + ), + ), + ), + }, + ); + }); + + test('untagged prose and line comments emit no description', () async { + await _build( + { + 'plain.dart': + ''' +$_imports +part 'plain.ack.dart'; +part 'plain.ack.g.dart'; + +/// Ordinary class prose is not schema data. +@AckModel() +final class Plain with _\$PlainAck { + const Plain({required this.title, this.note}); + + /// Ordinary field prose is not schema data. + final String title; + + // @description Line comments are not documentation. + final String? note; +} +''', + }, + outputs: { + 'test_pkg|lib/plain.ack.dart': decodedMatches( + isNot(contains('.describe(')), + ), + }, + ); + }); + + test('explicit descriptions escape control characters', () async { + await _build( + { + 'escaped.dart': + ''' +$_imports +part 'escaped.ack.dart'; +part 'escaped.ack.g.dart'; + +@AckModel(description: 'First\\nsecond') +final class Escaped with _\$EscapedAck { + const Escaped({required this.value}); + + @AckField(description: 'A\\tB') + final String value; +} +''', + }, + outputs: { + 'test_pkg|lib/escaped.ack.dart': decodedMatches( + allOf([ + contains(r".describe('First\u000asecond')"), + contains(r".describe('A\u0009B')"), + ]), + ), + }, + ); + }); + + test('union bases and branches carry their own description tags', () async { + await _build( + { + 'shapes.dart': + ''' +$_imports +part 'shapes.ack.dart'; +part 'shapes.ack.g.dart'; + +/// A shape to draw. +/// @description A shape to draw. +@AckModel(discriminatorKey: 'kind') +sealed class Shape with _\$ShapeAck { + const Shape(); +} + +/// A circle. +/// @description A circle. +@AckModel(discriminatorValue: 'circle') +final class Circle extends Shape with _\$CircleAck { + const Circle({required this.radius}); + + final double radius; +} + +final class Square extends Shape with _\$SquareAck { + const Square({required this.side}); + + final double side; +} +''', + }, + outputs: { + 'test_pkg|lib/shapes.ack.dart': decodedMatches( + allOf( + _containsCode("'radius': Ack.double(),}).describe('A circle.');"), + _containsCode( + r'encode: _$CircleToRuntime,' + ").describe('A circle.');", + ), + _containsCode("'side': Ack.double(),});"), + _containsCode(r'encode: _$SquareToRuntime,);'), + _containsCode( + "schemas: {'circle': _circleObject, 'Square': _squareObject}," + ").describe('A shape to draw.');", + ), + _containsCode( + r'Square() => _$SquareToRuntime(model),},' + ").describe('A shape to draw.');", + ), + ), + ), + }, + ); + }); + test('class-first format annotations constrain string fields', () async { await _build( { @@ -302,7 +492,7 @@ final class Color { AckSchema colorSchema() => Ack.string().codec( decode: Color.new, encode: (color) => color.value, -); +).describe('Authored color.'); AckSchema, Map> scoresSchema() => Ack.object({}, additionalProperties: true).codec>( @@ -324,6 +514,7 @@ final class Record with _\$RecordAck { required this.names, }); + /// Untagged field prose does not replace the authored description. @AckField(schema: colorSchema) final Color color; @AckField(schema: scoresSchema) @@ -340,6 +531,7 @@ final class Record with _\$RecordAck { 'test_pkg|lib/types.ack.dart': decodedMatches( allOf([ contains("'color': colorSchema()"), + isNot(contains("'color': colorSchema().describe(")), contains("'scores': scoresSchema()"), contains("'role': Ack.enumValues(Role.values)"), contains("'createdAt': Ack.datetime()"), 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 44affe4f..65017476 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -83,6 +83,59 @@ final class _\$UserCopyWith {} }, ); + test('rejects blank annotation descriptions', () async { + await _expectFailure( + ''' +@AckModel(description: ' ') +final class Item with _\$ItemAck { + const Item({required this.value}); + final String value; +} +''', + ['Item @AckModel.description must not be blank.'], + ); + await _expectFailure( + ''' +@AckModel() +final class Item with _\$ItemAck { + const Item({required this.value}); + @AckField(description: '') + final String value; +} +''', + ['value @AckField.description must not be blank.'], + ); + }); + + test('rejects duplicate class description tags at the class', () async { + await _expectFailure( + ''' +/// @description First. +/// @description Second. +@AckModel() +final class Item with _\$ItemAck { + const Item({required this.value}); + final String value; +} +''', + ['Item', 'Duplicate @description tags.'], + ); + }); + + test('rejects a blank field description tag at the field', () async { + await _expectFailure( + ''' +@AckModel() +final class Item with _\$ItemAck { + const Item({required this.value}); + /// @description + final String value; +} +''', + ['value', '@description requires text'], + ); + }); + test('rejects numeric sugar on a String field', () async { await _expectFailure( ''' 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 aa9fbf61..af879cf4 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:io'; import 'package:path/path.dart' as p; diff --git a/packages/ack_generator/test/integration/inference_library_test.dart b/packages/ack_generator/test/integration/inference_library_test.dart index 9c9d344a..e278e4b1 100644 --- a/packages/ack_generator/test/integration/inference_library_test.dart +++ b/packages/ack_generator/test/integration/inference_library_test.dart @@ -52,7 +52,8 @@ void main() { await readerWriter.testing.loadIsolateSources(); await testBuilder( const _InferenceProbe( - sourceComment: '/// The item title.\r\n/// It appears on screen.', + sourceComment: + '/// The item title.\r\n/// @description It appears on screen.\r\n/// More prose.', ), { 'test_pkg|lib/widget.dart': ''' @@ -69,7 +70,34 @@ void widget({ readerWriter: readerWriter, outputs: { 'test_pkg|lib/widget.probe': decodedMatches( - "Ack.string().minLength(2).describe('The item title. It appears on screen.')", + "Ack.string().minLength(2).describe('It appears on screen.')", + ), + }, + ); + }); + + test('an explicit parameter description overrides its source tag', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + const _InferenceProbe( + sourceComment: '/// @description Tag text.\n/// Following prose.', + ), + { + 'test_pkg|lib/widget.dart': ''' +import 'package:ack_annotations/ack_annotations.dart'; + +void widget({ + @AckField(description: 'Explicit text.') + required String title, +}) {} +''', + }, + generateFor: const {'test_pkg|lib/widget.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/widget.probe': decodedMatches( + "Ack.string().describe('Explicit text.')", ), }, ); diff --git a/packages/ack_generator/test/integration/inference_literal_test.dart b/packages/ack_generator/test/integration/inference_literal_test.dart new file mode 100644 index 00000000..99205a06 --- /dev/null +++ b/packages/ack_generator/test/integration/inference_literal_test.dart @@ -0,0 +1,103 @@ +import 'package:ack_generator/inference.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +final class _PatternProbe implements Builder { + const _PatternProbe(); + + @override + Map> get buildExtensions => const { + '.dart': ['.probe'], + }; + + @override + Future build(BuildStep step) async { + final library = await step.resolver.libraryFor(step.inputId); + final parameter = library.topLevelFunctions + .singleWhere((function) => function.name == 'widget') + .formalParameters + .single; + final expression = const AckSchemaInference().applyConstraints( + 'Ack.string()', + parameter, + parameter.type, + ); + await step.writeAsString( + step.inputId.changeExtension('.probe'), + expression, + ); + } +} + +final class _StringValues extends RecursiveAstVisitor { + final values = []; + + @override + void visitSimpleStringLiteral(SimpleStringLiteral node) { + values.add(node.value); + } +} + +void main() { + final cases = <({String name, String literal, String expected})>[ + ( + name: 'regular expression escapes', + literal: r"r'^\d+\.\d+$'", + expected: r'^\d+\.\d+$', + ), + ( + name: 'line endings', + literal: r"'first\nsecond\r\nthird'", + expected: 'first\nsecond\r\nthird', + ), + ( + name: 'quotes, backslashes, and interpolation markers', + literal: r"""'quote\' slash\\ dollar\$ \${notInterpolation}'""", + expected: "quote' slash\\ dollar\$ \${notInterpolation}", + ), + ( + name: 'control characters and Unicode line separators', + literal: r"'\u0000\t\b\f\u001f\u2028\u2029'", + expected: '\u0000\t\b\f\u001f\u2028\u2029', + ), + ]; + + for (final example in cases) { + test('pattern literal preserves ${example.name}', () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + const _PatternProbe(), + { + 'test_pkg|lib/widget.dart': + ''' +import 'package:ack_annotations/ack_annotations.dart'; + +void widget({@Pattern(${example.literal}) required String value}) {} +''', + }, + generateFor: const {'test_pkg|lib/widget.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/widget.probe': decodedMatches( + predicate((expression) { + final parsed = parseString( + content: 'final schema = $expression;', + throwIfDiagnostics: false, + ); + expect(parsed.errors, isEmpty); + final strings = _StringValues(); + parsed.unit.accept(strings); + expect(strings.values, [example.expected]); + return true; + }, 'valid Dart with the original annotation value'), + ), + }, + ); + }); + } +} 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 393ea83a..331d0d46 100644 --- a/packages/ack_generator/test/integration/json_serializable_build_test.dart +++ b/packages/ack_generator/test/integration/json_serializable_build_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:io'; import 'package:path/path.dart' as p; diff --git a/packages/ack_generator/test/integration/legacy_runtime_build_test.dart b/packages/ack_generator/test/integration/legacy_runtime_build_test.dart index 563a43ba..548486f2 100644 --- a/packages/ack_generator/test/integration/legacy_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/legacy_runtime_build_test.dart @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:io'; import 'package:path/path.dart' as p; 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 014764a9..051e5856 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 @@ -1,3 +1,6 @@ +@Tags(['integration']) +library; + import 'dart:io'; import 'package:path/path.dart' as p; diff --git a/packages/ack_generator/test/src/doc_comment_utils_test.dart b/packages/ack_generator/test/src/doc_comment_utils_test.dart new file mode 100644 index 00000000..e29d679a --- /dev/null +++ b/packages/ack_generator/test/src/doc_comment_utils_test.dart @@ -0,0 +1,59 @@ +import 'package:ack_generator/src/utils/doc_comment_utils.dart'; +import 'package:test/test.dart'; + +void main() { + test('only a description tag becomes schema text', () { + expect(parseDescriptionTag('/// Ordinary prose.'), isNull); + expect( + parseDescriptionTag( + '/// Ordinary prose.\r\n' + '/// @description The schema text.\r\n' + '/// More ordinary prose.', + ), + 'The schema text.', + ); + expect( + parseDescriptionTag('/**\r\n * @description Block text.\r\n */'), + 'Block text.', + ); + }); + + test('tags in fenced examples and inline prose do not export', () { + expect( + parseDescriptionTag( + '/// An example with @description in prose.\n' + '/// ```dart\n' + '/// @description Example text.\n' + '/// ```\n' + '/// @description Exported text.', + ), + 'Exported text.', + ); + expect( + parseDescriptionTag('/**\n * ~~~\n * @description Example.\n * ~~~\n */'), + isNull, + ); + expect( + parseDescriptionTag( + '/// ```\n' + '/// ```dart\n' + '/// @description Still inside the fence.\n' + '/// ```', + ), + isNull, + ); + }); + + test('blank and duplicate tags are invalid', () { + expect( + () => parseDescriptionTag('/// @description'), + throwsA(isA()), + ); + expect( + () => parseDescriptionTag( + '/// @description First.\n/// @description Second.', + ), + throwsA(isA()), + ); + }); +} diff --git a/packages/ack_generator/test/src/string_literal_test.dart b/packages/ack_generator/test/src/string_literal_test.dart new file mode 100644 index 00000000..1d0b9246 --- /dev/null +++ b/packages/ack_generator/test/src/string_literal_test.dart @@ -0,0 +1,31 @@ +import 'package:ack_generator/src/utils/string_literal.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:test/test.dart'; + +String _parsedValue(String literal) { + final unit = parseString(content: 'const value = $literal;').unit; + final variable = (unit.declarations.single as TopLevelVariableDeclaration) + .variables + .variables + .single; + return (variable.initializer! as SimpleStringLiteral).value; +} + +void main() { + test('literals round-trip through the Dart parser', () { + const values = [ + 'plain', + "quote' slash\\ dollar\$ \${notInterpolation}", + 'first\nsecond\r\nthird', + '\u0000\t\b\f\u001f\u007f

', + ]; + for (final value in values) { + expect(_parsedValue(dartStringLiteral(value)), value); + } + }); + + test('line terminators are escaped, not written raw', () { + expect(dartStringLiteral('a\nb\rc'), r"'a\u000ab\u000dc'"); + }); +} diff --git a/packages/ack_json_schema_builder/CHANGELOG.md b/packages/ack_json_schema_builder/CHANGELOG.md index 66879662..c082dab9 100644 --- a/packages/ack_json_schema_builder/CHANGELOG.md +++ b/packages/ack_json_schema_builder/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.7.0-beta.2 + +### Changed + +* Align with the coordinated Ack 1.7.0 beta. This package has no source or + public API changes from 1.6.2; compatible dependency minimums are preserved. + ## 1.6.2 ### Changed diff --git a/packages/ack_json_schema_builder/README.md b/packages/ack_json_schema_builder/README.md index 383283fb..3caaf2de 100644 --- a/packages/ack_json_schema_builder/README.md +++ b/packages/ack_json_schema_builder/README.md @@ -48,7 +48,7 @@ for the complete support matrix and reference/diagnostic behavior. ```yaml dependencies: ack: ^1.6.2 - ack_json_schema_builder: ^1.6.2 + ack_json_schema_builder: ^1.7.0-beta.2 json_schema_builder: ^0.1.3 ``` diff --git a/packages/ack_json_schema_builder/pubspec.yaml b/packages/ack_json_schema_builder/pubspec.yaml index bd5906e9..c38154eb 100644 --- a/packages/ack_json_schema_builder/pubspec.yaml +++ b/packages/ack_json_schema_builder/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_json_schema_builder description: JSON Schema Builder converter for the Ack validation library -version: 1.6.2 +version: 1.7.0-beta.2 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace diff --git a/packages/ack_mcp_dart/CHANGELOG.md b/packages/ack_mcp_dart/CHANGELOG.md index 863c8a3f..cdbaf5cd 100644 --- a/packages/ack_mcp_dart/CHANGELOG.md +++ b/packages/ack_mcp_dart/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.7.0-beta.2 + +### Changed + +* Align with the coordinated Ack 1.7.0 beta. This package has no source or + public API changes from 1.6.2; compatible dependency minimums are preserved. + ## 1.6.2 ### Changed diff --git a/packages/ack_mcp_dart/README.md b/packages/ack_mcp_dart/README.md index c2c2f601..029e0c7e 100644 --- a/packages/ack_mcp_dart/README.md +++ b/packages/ack_mcp_dart/README.md @@ -16,7 +16,7 @@ Transport, negotiation, cancellation, and lifecycle remain in `mcp_dart`. ```yaml dependencies: ack: ^1.2.0 - ack_mcp_dart: ^1.6.2 + ack_mcp_dart: ^1.7.0-beta.2 mcp_dart: ^2.4.2 ``` diff --git a/packages/ack_mcp_dart/pubspec.yaml b/packages/ack_mcp_dart/pubspec.yaml index b88b2c6c..a12660fe 100644 --- a/packages/ack_mcp_dart/pubspec.yaml +++ b/packages/ack_mcp_dart/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_mcp_dart description: MCP tool schemas and typed argument validation for Ack and mcp_dart -version: 1.6.2 +version: 1.7.0-beta.2 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace diff --git a/pubspec.yaml b/pubspec.yaml index dbd0bbdc..69f939aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -118,6 +118,12 @@ melos: packageFilters: scope: ack_generator + test:gen:unit: + run: dart run melos exec -c 1 -- dart test -x integration + description: Run generator tests except the build_runner integration tests + packageFilters: + scope: ack_generator + firebase-ai-fixtures: run: cd packages/ack_firebase_ai && dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart description: Regenerate Firebase AI adapter and native schema fixtures