From 4d8f693c465fb9169b2640521b26ee3c640f69ac Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 10 Jul 2026 18:32:24 -0400 Subject: [PATCH 1/4] fix: validation edge cases, schema consistency, and tooling reliability Fix numeric/IP/date-time constraint validation, safeParse exception handling, union/enum/lazy-schema edge cases, and generator diagnostics; clean up workspace tooling and remove stale IDE/build artifacts. --- .github/copilot-instructions.md | 28 +- .gitignore | 3 +- CHANGELOG.md | 8 + CONTRIBUTING.md | 12 +- PUBLISHING.md | 10 +- README.md | 32 +- coverage/lcov.info | 1263 ----------------- docs/api-reference/index.mdx | 9 +- docs/core-concepts/codecs.mdx | 8 +- docs/core-concepts/schemas.mdx | 4 +- docs/core-concepts/typesafe-schemas.mdx | 7 +- docs/core-concepts/validation.mdx | 5 +- docs/getting-started/installation.mdx | 2 +- example/README.md | 4 +- example/melos_ack_example.iml | 16 - llms.txt | 13 +- melos_ack_workspace.iml | 12 - packages/ack/CHANGELOG.md | 17 + packages/ack/lib/src/ack.dart | 76 +- .../constraints/comparison_constraint.dart | 133 +- .../src/constraints/pattern_constraint.dart | 102 +- .../src/constraints/string_ip_constraint.dart | 18 +- .../ack_schema_model_builder.dart | 46 +- .../ack/lib/src/schemas/any_of_schema.dart | 4 + .../ack/lib/src/schemas/codec_schema.dart | 27 +- packages/ack/lib/src/schemas/enum_schema.dart | 5 + .../extensions/string_schema_extensions.dart | 15 +- packages/ack/lib/src/schemas/lazy_schema.dart | 77 +- packages/ack/lib/src/schemas/list_schema.dart | 2 +- .../ack/lib/src/schemas/object_schema.dart | 2 +- packages/ack/lib/src/schemas/schema.dart | 77 +- .../ack/lib/src/utils/collection_utils.dart | 13 +- .../ack/lib/src/validation/schema_result.dart | 10 +- packages/ack/melos_ack.iml | 16 - .../string_ip_constraint_test.dart | 11 + .../error_recovery_examples_test.dart | 3 - .../ack/test/reference_semantics_test.dart | 183 +++ .../schemas/any_of_null_and_default_test.dart | 7 +- .../ack/test/schemas/core_schema_test.dart | 4 + .../schemas/datetime_validation_test.dart | 54 + .../extensions/numeric_extensions_test.dart | 28 + .../ack/test/schemas/lazy_schema_test.dart | 67 + packages/ack/test/schemas/refine_test.dart | 17 + .../test/schemas/schema_equality_test.dart | 19 + .../transform_optional_nullable_test.dart | 82 +- packages/ack_annotations/README.md | 2 +- .../ack_annotations/melos_ack_annotations.iml | 16 - packages/ack_firebase_ai/README.md | 2 +- .../ack_firebase_ai/melos_ack_firebase_ai.iml | 29 - packages/ack_generator/CHANGELOG.md | 10 + packages/ack_generator/README.md | 2 +- packages/ack_generator/build.yaml | 8 - .../lib/src/analyzer/schema_ast_analyzer.dart | 169 ++- packages/ack_generator/lib/src/generator.dart | 43 +- .../ack_generator/melos_ack_generator.iml | 16 - .../test/block_emission_test.dart | 132 -- .../test/bugs/schema_variable_bugs_test.dart | 20 +- .../ack_type_cross_file_resolution_test.dart | 35 +- .../ack_type_discriminated_test.dart | 57 +- .../integration/ack_type_getter_test.dart | 43 +- .../example_folder_build_test.dart | 16 +- packages/ack_generator/test/run_tests.sh | 43 - .../test_utils/generation_test_utils.dart | 27 + .../tool/show_generator_output.dart | 84 -- .../ack_generator/tool/update_goldens.dart | 179 --- .../melos_ack_json_schema_builder.iml | 16 - pubspec.yaml | 76 +- scripts/api_check.dart | 132 +- scripts/setup.sh | 53 - scripts/src/release_changelog.dart | 58 + scripts/update_release_changelog.dart | 88 +- setup.sh | 246 +--- test/scripts/api_check_test.dart | 68 + test/scripts/release_changelog_test.dart | 141 ++ tools/README.md | 4 +- tools/npm-shrinkwrap.json | 18 +- 76 files changed, 1663 insertions(+), 2721 deletions(-) delete mode 100644 coverage/lcov.info delete mode 100644 example/melos_ack_example.iml delete mode 100644 melos_ack_workspace.iml delete mode 100644 packages/ack/melos_ack.iml create mode 100644 packages/ack/test/reference_semantics_test.dart delete mode 100644 packages/ack_annotations/melos_ack_annotations.iml delete mode 100644 packages/ack_firebase_ai/melos_ack_firebase_ai.iml delete mode 100644 packages/ack_generator/melos_ack_generator.iml delete mode 100644 packages/ack_generator/test/block_emission_test.dart delete mode 100755 packages/ack_generator/test/run_tests.sh create mode 100644 packages/ack_generator/test/test_utils/generation_test_utils.dart delete mode 100644 packages/ack_generator/tool/show_generator_output.dart delete mode 100644 packages/ack_generator/tool/update_goldens.dart delete mode 100644 packages/ack_json_schema_builder/melos_ack_json_schema_builder.iml delete mode 100644 scripts/setup.sh create mode 100644 scripts/src/release_changelog.dart create mode 100644 test/scripts/api_check_test.dart create mode 100644 test/scripts/release_changelog_test.dart diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8a3a7816..f43782cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,7 +8,7 @@ - `packages/ack`: core runtime validation library. - `packages/ack_annotations`: source annotation for `@AckType()` schema generation. -- `packages/ack_generator`: build_runner generator + golden tests. +- `packages/ack_generator`: build_runner generator + unit/integration tests. - `packages/ack_firebase_ai`: Firebase AI schema adapter. - `packages/ack_json_schema_builder`: JSON Schema adapter. - `example`: sample usage. @@ -16,20 +16,20 @@ ## Environment and setup - Required SDKs: Dart `>=3.8.0 <4.0.0`, Flutter `>=3.16.0` (see `/pubspec.yaml`). - Use from repo root: - 1. `dart pub global activate melos` - 2. `melos bootstrap` -- Optional one-shot setup script: `./setup.sh` (installs Dart/Melos/Node tools where possible). + 1. `dart pub get` + 2. `dart run melos bootstrap` +- Optional one-shot setup script: `./setup.sh` (validates the Flutter SDK, bootstraps the workspace, and installs Node tools when npm is available). ## Commands you should run -- Full CI-equivalent local check: `melos run test --no-select` +- Full CI-equivalent local check: `dart run melos run test --no-select` - Runs strict analyze (`dart analyze . --fatal-infos`) and package tests. - Useful targeted commands: - - `melos run analyze` - - `melos run test:dart` - - `melos run test:flutter` - - `melos run build` (when generator-related code changes) - - `melos run test:gen` / `melos run update-golden:all` (for generator golden updates) - - `melos run validate-jsonschema` (for JSON Schema conformance tooling) + - `dart run melos run analyze` + - `dart run melos run test:dart` + - `dart run melos run test:flutter` + - `dart run melos run build` (when generator-related code changes) + - `dart run melos run test:gen` (for generator changes) + - `dart run melos run validate-jsonschema` (for JSON Schema conformance tooling) ## Change-scope guidance - Keep changes minimal and package-scoped; do not refactor unrelated files. @@ -39,12 +39,12 @@ ## CI and release notes - CI is defined in `/.github/workflows/ci.yml` and delegates to `btwld/dart-actions/.github/workflows/ci.yml@main` with DCM enabled. - Conventional Commits are expected for commit messages. -- Publishing/versioning flows are documented in `/PUBLISHING.md` (`melos version`, `melos publish`). +- Publishing/versioning flows are documented in `/PUBLISHING.md` (`dart run melos version`, `dart run melos publish`). ## Errors encountered during onboarding and workarounds 1. **Error:** `melos: command not found` when running checks in a fresh environment. - **Workaround:** install Melos (`dart pub global activate melos`) and/or invoke via `dart run melos ...` after Dart is available. + **Workaround:** resolve root dependencies with `dart pub get`, then invoke the workspace-local executable via `dart run melos ...`. 2. **Error:** `dart: command not found` in bare sandbox environments. - **Workaround:** install Dart SDK first (or run `./setup.sh`), then bootstrap with Melos. + **Workaround:** install Flutter (which includes Dart), then run `./setup.sh`. 3. **Observed CI state:** workflow run may show `conclusion: action_required` with no jobs for PR contexts awaiting approval/permissions. **Workaround:** have a maintainer approve/enable the run, then re-run CI. diff --git a/.gitignore b/.gitignore index 2e57a3a9..f98ee3c0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,15 +38,14 @@ packages/ack/coverage # Coverage coverage/* -!coverage/lcov.info packages/*/coverage/* -!packages/*/coverage/lcov.info # Keep .claude directory .claude/* !.claude/skills/ CLAUDE.md .idea +*.iml # Node.js dependencies for tools tools/node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d00e5560..6edf597d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## Unreleased + +* Fix validation edge cases, schema export consistency, schema immutability, + generator diagnostics, and workspace/release tooling reliability. +* Bound lazy recursion through wrappers and fluent copies, align RFC date-time + and IPv6 behavior, run root tooling tests in CI, prevent stale API reports, + and avoid partial changelog updates when validation fails. + ## 1.0.0 - 2026-06-26 * See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.0) for details. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 393925d2..7ba23d89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,8 @@ review. ## Development setup ```bash -dart pub global activate melos -melos bootstrap +dart pub get +dart run melos bootstrap ``` Ack uses a Melos workspace. Run commands from the repository root unless a @@ -22,20 +22,20 @@ package README says otherwise. 4. Run the relevant checks: ```bash -melos analyze -melos test +dart run melos run analyze +dart run melos run test ``` For code generation changes, also run: ```bash -melos run test:gen +dart run melos run test:gen ``` For JSON Schema export changes, also run: ```bash -melos run validate-jsonschema +dart run melos run validate-jsonschema ``` ## Commit style diff --git a/PUBLISHING.md b/PUBLISHING.md index 515d4ab5..e7e744a7 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -17,10 +17,10 @@ The Ack project uses GitHub Releases to manage versioning and publishing. This a Before creating a release: 1. Ensure all changes are committed and pushed to the `main` branch -2. Verify that all tests pass by running `melos test` (include `melos run validate-jsonschema` and `melos run test:gen` for full coverage) +2. Verify that all tests pass by running `dart run melos run test` (include `dart run melos run validate-jsonschema` and `dart run melos run test:gen` for full coverage) 3. Check that the documentation is up to date across the repo and docs site 4. Decide on the new version number following [Semantic Versioning](https://semver.org/) and apply it consistently to every publishable package (`ack`, `ack_annotations`, `ack_generator`, `ack_firebase_ai`, `ack_json_schema_builder`) -5. Ensure package CHANGELOG entries are finalized before tagging. If you want a link-only entry for a version, you can run `dart scripts/update_release_changelog.dart [tag]` after `melos version`. +5. Ensure package CHANGELOG entries are finalized before tagging. If you want a link-only entry for a version, you can run `dart scripts/update_release_changelog.dart [tag]` after `dart run melos version`. ### 2. Create a GitHub Release @@ -86,10 +86,10 @@ If needed, you can version packages locally from conventional commits: ```bash # Propose/apply version and changelog updates -melos version +dart run melos version # Non-interactive -melos version --yes +dart run melos version --yes # Push the changes and tags git push --follow-tags @@ -106,7 +106,7 @@ for pkg in ack ack_annotations ack_generator ack_json_schema_builder ack_firebas done # Actual publish (no dry-run) -melos run publish +dart run melos run publish ``` ## Troubleshooting diff --git a/README.md b/README.md index 801f22c1..65ef8dfe 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ final userSchema = Ack.object({ Run the generator: ```bash -dart run build_runner build --delete-conflicting-outputs +dart run build_runner build ``` This emits a `UserType` extension type with `parse`/`safeParse` and typed @@ -185,55 +185,55 @@ This project uses [Melos](https://github.com/invertase/melos) to manage the mono ### Setup ```bash -# Install Melos (if not already installed) -dart pub global activate melos +# Resolve the workspace-local Melos dependency +dart pub get # Bootstrap the workspace (installs dependencies for all packages) -melos bootstrap +dart run melos bootstrap ``` ### Common commands (run from root) ```bash # Run tests across all packages -melos test +dart run melos run test # Format code across all packages -melos format +dart run melos run format # Analyze code across all packages -melos analyze +dart run melos run analyze # Check for outdated dependencies -melos deps-outdated +dart run melos run deps-outdated # Run build_runner for packages that need it (e.g., ack_generator, example) -melos build +dart run melos run build # Clean build artifacts -melos clean +dart run melos run clean # Propose/apply version and changelog updates -melos version +dart run melos version # Dry-run pub.dev validation for one package (cd packages/ack && dart pub publish --dry-run) # Publish all packages (no dry-run) -melos run publish +dart run melos run publish ``` ### Development tools ```bash # JSON Schema validation (JSON Schema Draft-7 compatibility) -melos validate-jsonschema +dart run melos run validate-jsonschema # API compatibility check (for semantic versioning) -melos api-check v0.2.0 +dart run melos run api-check -- v0.2.0 # See all available scripts -melos list-scripts +dart run melos run --list ``` Additional development documentation is available in the `tools/` directory. @@ -249,6 +249,6 @@ Contributions are welcome. Follow these steps: 1. Fork the repository 2. Create a feature branch 3. Add your changes -4. Run tests with `melos test` +4. Run tests with `dart run melos run test` 5. Follow [Conventional Commits](https://www.conventionalcommits.org/) in your commit messages 6. Submit a pull request diff --git a/coverage/lcov.info b/coverage/lcov.info deleted file mode 100644 index 01c31163..00000000 --- a/coverage/lcov.info +++ /dev/null @@ -1,1263 +0,0 @@ -SF:/Users/leofarias/Projects/ack/lib/src/ack.dart -DA:12,0 -DA:14,0 -DA:15,0 -DA:16,2 -DA:17,2 -DA:18,0 -DA:20,1 -DA:22,0 -DA:24,1 -DA:28,0 -DA:30,3 -DA:33,0 -DA:35,3 -DA:40,0 -DA:41,0 -DA:42,1 -DA:43,3 -DA:44,0 -DA:45,0 -DA:46,1 -DA:47,5 -LF:21 -LH:10 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/constraints/constraint.dart -DA:8,43 -DA:10,4 -DA:11,12 -DA:14,2 -DA:15,8 -DA:23,18 -DA:25,30 -DA:27,15 -DA:29,3 -DA:30,12 -DA:33,1 -DA:34,4 -DA:48,114 -DA:50,90 -DA:57,16 -DA:58,32 -LF:16 -LH:16 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/constraints/constraint_extensions.dart -DA:8,2 -DA:9,4 -DA:12,6 -DA:15,3 -DA:18,2 -DA:21,6 -DA:24,6 -DA:27,1 -DA:28,2 -DA:31,0 -DA:34,1 -DA:35,2 -DA:38,4 -DA:41,2 -DA:44,2 -DA:48,9 -DA:51,6 -DA:54,9 -DA:57,6 -DA:60,1 -DA:61,2 -DA:74,0 -DA:75,0 -DA:86,0 -DA:87,0 -DA:99,2 -DA:100,6 -DA:109,4 -DA:110,12 -DA:118,2 -DA:119,6 -LF:31 -LH:26 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/constraints/validators.dart -DA:15,10 -DA:16,10 -DA:18,10 -DA:21,10 -DA:23,30 -DA:28,7 -DA:29,7 -DA:34,6 -DA:46,25 -DA:47,2 -DA:52,2 -DA:53,2 -DA:55,2 -DA:57,2 -DA:59,0 -DA:60,0 -DA:71,25 -DA:72,2 -DA:77,2 -DA:80,2 -DA:86,8 -DA:87,6 -DA:88,6 -DA:91,2 -DA:94,2 -DA:96,2 -DA:98,0 -DA:99,0 -DA:113,4 -DA:114,4 -DA:116,4 -DA:119,4 -DA:120,8 -DA:122,4 -DA:124,8 -DA:126,4 -DA:128,20 -DA:130,4 -DA:133,0 -DA:134,0 -DA:144,3 -DA:145,3 -DA:159,2 -DA:160,2 -DA:178,2 -DA:179,2 -DA:182,10 -DA:183,2 -DA:186,2 -DA:187,4 -DA:189,2 -DA:191,4 -DA:203,26 -DA:204,3 -DA:209,3 -DA:210,3 -DA:212,2 -DA:225,23 -DA:226,0 -DA:231,0 -DA:234,0 -DA:235,0 -DA:246,0 -DA:248,0 -DA:264,3 -DA:268,3 -DA:269,3 -DA:270,3 -DA:274,6 -DA:275,0 -DA:277,12 -DA:278,0 -DA:282,3 -DA:285,6 -DA:287,3 -DA:293,2 -DA:295,6 -DA:298,0 -DA:299,0 -DA:301,0 -DA:303,0 -DA:313,25 -DA:314,2 -DA:319,2 -DA:320,2 -DA:322,2 -DA:324,2 -DA:339,3 -DA:340,3 -DA:342,3 -DA:345,3 -DA:346,9 -DA:348,3 -DA:350,9 -DA:353,0 -DA:354,0 -DA:368,3 -DA:369,3 -DA:371,3 -DA:374,3 -DA:375,9 -DA:377,3 -DA:380,9 -DA:383,0 -DA:384,0 -DA:395,3 -DA:396,3 -DA:401,3 -DA:402,6 -DA:404,3 -DA:406,15 -DA:408,3 -DA:411,0 -DA:412,0 -DA:426,5 -DA:427,5 -DA:429,5 -DA:432,5 -DA:433,15 -DA:435,5 -DA:437,15 -DA:440,0 -DA:441,0 -DA:455,3 -DA:456,3 -DA:458,3 -DA:461,3 -DA:462,9 -DA:464,3 -DA:466,9 -DA:469,0 -DA:470,0 -DA:490,3 -DA:492,3 -DA:494,3 -DA:496,2 -DA:497,8 -DA:499,1 -DA:501,1 -DA:502,0 -DA:503,2 -DA:506,0 -DA:507,0 -DA:508,0 -DA:509,0 -DA:522,1 -DA:523,1 -DA:525,1 -DA:528,1 -DA:529,3 -DA:531,1 -DA:533,2 -DA:534,4 -DA:537,0 -DA:538,0 -DA:558,3 -DA:560,3 -DA:562,3 -DA:565,2 -DA:566,8 -DA:568,2 -DA:570,2 -DA:571,0 -DA:572,4 -DA:575,0 -DA:576,0 -DA:577,0 -DA:578,0 -DA:603,2 -DA:605,2 -DA:607,2 -DA:610,2 -DA:612,14 -DA:614,2 -DA:616,6 -DA:619,0 -DA:620,0 -DA:621,0 -DA:622,0 -DA:623,0 -DA:624,0 -DA:639,0 -DA:640,0 -DA:642,0 -DA:645,0 -DA:646,0 -DA:648,0 -DA:650,0 -DA:653,0 -DA:654,0 -DA:668,0 -DA:669,0 -DA:671,0 -DA:674,0 -DA:675,0 -DA:677,0 -DA:679,0 -DA:682,0 -DA:683,0 -DA:694,6 -DA:695,6 -DA:698,18 -DA:701,6 -DA:702,42 -DA:704,6 -DA:705,12 -DA:707,12 -DA:709,0 -DA:711,0 -DA:713,0 -DA:726,7 -DA:727,7 -DA:729,14 -DA:732,7 -DA:734,28 -DA:737,3 -DA:739,3 -DA:740,3 -DA:741,3 -DA:742,9 -DA:743,3 -DA:745,3 -DA:756,3 -DA:757,3 -DA:760,3 -DA:764,3 -DA:767,3 -DA:768,6 -DA:769,12 -DA:770,3 -DA:771,3 -DA:775,3 -DA:778,3 -DA:779,6 -DA:780,12 -DA:781,12 -DA:782,7 -DA:783,3 -DA:786,3 -DA:788,6 -DA:789,6 -DA:792,2 -DA:794,2 -DA:795,2 -DA:798,2 -DA:799,2 -DA:800,4 -DA:801,2 -DA:811,3 -DA:812,3 -DA:817,3 -DA:820,6 -DA:825,6 -DA:829,9 -DA:832,3 -DA:834,6 -DA:835,9 -DA:839,7 -LF:258 -LH:191 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/converters/open_api_schema.dart -DA:14,0 -DA:15,1 -DA:20,0 -DA:21,1 -DA:24,1 -DA:27,0 -DA:28,0 -DA:30,0 -DA:31,0 -DA:32,0 -DA:34,1 -DA:35,1 -DA:37,0 -DA:38,2 -DA:39,0 -DA:40,1 -DA:41,1 -DA:42,4 -DA:45,3 -DA:47,0 -DA:48,0 -DA:50,0 -DA:59,0 -DA:62,1 -DA:67,0 -DA:69,0 -DA:70,1 -DA:72,1 -DA:73,0 -DA:75,0 -DA:76,1 -DA:77,0 -DA:78,1 -DA:79,1 -DA:80,1 -DA:81,0 -DA:83,1 -DA:84,2 -DA:86,0 -DA:87,3 -DA:89,1 -DA:90,0 -DA:91,1 -DA:92,0 -DA:93,1 -DA:94,0 -DA:95,3 -DA:97,2 -DA:99,0 -DA:100,0 -DA:101,0 -DA:102,1 -DA:103,5 -DA:104,2 -DA:105,0 -DA:106,0 -DA:107,1 -DA:109,3 -DA:110,1 -DA:111,1 -DA:112,1 -DA:115,1 -DA:116,1 -DA:117,1 -DA:121,0 -DA:122,0 -DA:126,0 -DA:127,0 -DA:128,0 -DA:129,0 -DA:131,1 -DA:132,1 -DA:133,1 -DA:134,1 -DA:135,0 -DA:136,1 -DA:137,0 -DA:138,0 -DA:139,0 -DA:140,1 -DA:141,1 -DA:142,4 -DA:143,2 -DA:144,1 -DA:146,0 -DA:148,2 -DA:149,2 -DA:150,0 -DA:151,5 -DA:153,0 -DA:154,2 -DA:155,2 -DA:156,0 -DA:157,0 -DA:158,1 -DA:159,2 -DA:160,1 -DA:161,1 -DA:162,1 -DA:163,0 -DA:164,1 -DA:165,2 -DA:167,1 -DA:168,2 -DA:169,1 -DA:171,0 -DA:172,0 -DA:173,1 -DA:174,2 -DA:175,0 -DA:176,1 -DA:177,2 -DA:179,1 -DA:180,2 -DA:181,0 -DA:183,0 -DA:186,1 -DA:187,0 -DA:188,2 -DA:189,0 -DA:190,0 -DA:191,0 -DA:192,1 -DA:193,0 -DA:194,1 -DA:195,1 -DA:196,1 -DA:197,1 -DA:198,1 -DA:199,1 -DA:200,1 -DA:201,0 -DA:207,0 -DA:210,0 -DA:212,0 -DA:213,1 -DA:214,0 -DA:216,1 -DA:218,2 -DA:219,0 -DA:220,0 -DA:222,0 -DA:225,0 -LF:143 -LH:80 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/helpers/template.dart -DA:5,1 -DA:10,1 -DA:12,1 -DA:15,1 -DA:22,1 -DA:24,1 -DA:25,1 -DA:31,2 -DA:32,1 -DA:35,1 -DA:37,1 -DA:39,2 -DA:40,2 -DA:41,1 -DA:43,1 -DA:45,1 -DA:46,1 -DA:51,2 -DA:56,1 -DA:57,1 -DA:59,1 -DA:60,0 -DA:62,1 -DA:65,1 -DA:66,1 -DA:70,2 -DA:73,1 -DA:76,1 -DA:77,1 -DA:81,1 -DA:82,2 -DA:83,1 -DA:84,1 -DA:89,2 -DA:90,1 -DA:91,1 -DA:92,1 -DA:93,2 -DA:94,1 -DA:97,1 -DA:98,2 -DA:99,2 -DA:103,2 -DA:104,1 -DA:107,1 -DA:113,1 -DA:114,1 -DA:115,1 -DA:116,1 -DA:117,1 -DA:118,1 -DA:121,1 -DA:130,1 -DA:131,1 -DA:134,2 -DA:135,2 -DA:136,1 -DA:138,1 -DA:140,0 -DA:141,0 -DA:142,0 -DA:149,1 -DA:153,0 -DA:156,2 -DA:159,0 -DA:161,0 -DA:162,0 -DA:163,0 -DA:166,0 -LF:69 -LH:59 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/schema.dart -DA:74,63 -DA:76,1 -DA:85,30 -DA:94,15 -DA:95,0 -DA:97,30 -DA:98,33 -DA:99,15 -DA:100,15 -DA:105,0 -DA:106,0 -DA:107,0 -DA:108,0 -DA:109,0 -DA:111,6 -DA:113,6 -DA:134,12 -DA:137,2 -DA:140,2 -DA:143,2 -DA:146,2 -DA:156,15 -DA:161,7 -DA:162,7 -DA:163,12 -DA:164,18 -DA:165,6 -DA:168,0 -DA:169,15 -DA:170,0 -DA:172,10 -DA:173,10 -DA:174,10 -DA:175,20 -DA:176,0 -DA:177,10 -DA:182,15 -DA:183,0 -DA:184,15 -DA:185,11 -DA:186,11 -DA:188,11 -DA:193,15 -DA:194,0 -DA:195,0 -DA:196,0 -DA:198,0 -DA:199,0 -DA:203,0 -DA:205,0 -DA:206,0 -DA:211,15 -DA:212,15 -DA:213,45 -DA:214,30 -DA:215,0 -DA:216,0 -DA:217,0 -DA:218,0 -DA:219,0 -DA:220,0 -DA:221,0 -DA:222,2 -DA:223,2 -DA:224,4 -DA:225,6 -DA:226,2 -DA:227,2 -DA:228,2 -DA:235,0 -DA:244,0 -DA:245,0 -DA:251,9 -DA:252,36 -DA:254,2 -DA:255,8 -DA:256,0 -DA:261,0 -DA:266,4 -DA:267,0 -DA:268,0 -DA:269,0 -DA:276,1 -DA:277,2 -DA:282,1 -DA:289,61 -DA:290,0 -DA:297,15 -DA:299,0 -DA:301,0 -DA:302,0 -DA:303,6 -DA:304,10 -DA:305,7 -DA:306,6 -DA:314,0 -DA:315,7 -DA:316,11 -DA:317,6 -DA:318,6 -DA:326,0 -DA:327,0 -DA:328,0 -DA:329,0 -DA:334,2 -DA:336,0 -DA:345,15 -DA:347,15 -DA:348,9 -DA:349,16 -DA:350,12 -DA:356,0 -DA:363,0 -DA:364,0 -DA:371,7 -DA:372,0 -DA:373,0 -DA:374,0 -DA:375,0 -DA:378,14 -DA:379,5 -DA:380,7 -DA:381,6 -DA:382,7 -DA:386,1 -DA:388,4 -LF:126 -LH:76 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/list/list_schema.dart -DA:6,7 -DA:13,7 -DA:15,2 -DA:17,6 -DA:19,6 -DA:20,0 -DA:21,6 -DA:23,5 -DA:25,6 -DA:26,0 -DA:27,5 -DA:29,15 -DA:30,20 -DA:32,5 -DA:33,0 -DA:34,0 -DA:37,10 -DA:38,0 -DA:39,0 -DA:40,0 -DA:41,0 -DA:44,6 -DA:45,0 -DA:46,6 -DA:48,6 -DA:49,12 -DA:50,12 -DA:53,0 -DA:55,6 -DA:61,5 -DA:62,0 -DA:63,0 -DA:64,0 -DA:65,0 -DA:66,0 -DA:67,0 -DA:68,5 -DA:69,5 -DA:70,1 -DA:71,5 -DA:72,5 -DA:73,5 -DA:77,0 -DA:78,0 -DA:84,0 -DA:86,0 -DA:88,0 -DA:92,0 -DA:94,0 -LF:49 -LH:26 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/num/num_schema.dart -DA:7,28 -DA:13,4 -DA:20,35 -DA:26,11 -DA:30,35 -LF:5 -LH:5 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/string/string_schema.dart -DA:7,54 -DA:13,7 -LF:2 -LH:2 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/discriminated/discriminated_object_schema.dart -DA:8,4 -DA:17,4 -DA:19,0 -DA:20,3 -DA:21,6 -DA:24,0 -DA:25,0 -DA:27,2 -DA:28,0 -DA:29,0 -DA:30,4 -DA:32,3 -DA:34,3 -DA:35,0 -DA:36,3 -DA:38,3 -DA:40,4 -DA:42,3 -DA:43,6 -DA:44,6 -DA:45,9 -DA:46,3 -DA:47,3 -DA:49,3 -DA:50,3 -DA:51,3 -DA:52,3 -DA:53,3 -DA:56,0 -DA:58,3 -DA:59,0 -DA:60,6 -DA:61,0 -DA:62,3 -DA:65,0 -DA:70,0 -DA:74,0 -DA:80,0 -DA:84,1 -DA:89,0 -DA:90,0 -DA:91,0 -DA:92,0 -DA:93,1 -DA:94,0 -DA:95,1 -DA:96,1 -DA:97,1 -DA:98,1 -DA:99,1 -DA:101,0 -DA:102,0 -DA:103,0 -DA:104,0 -DA:105,0 -DA:106,0 -DA:107,0 -DA:108,0 -DA:109,0 -DA:115,0 -DA:117,0 -DA:121,0 -DA:123,0 -DA:124,0 -DA:125,0 -DA:127,0 -DA:129,0 -DA:130,0 -DA:134,0 -DA:135,0 -LF:70 -LH:32 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/object/object_schema.dart -DA:11,0 -DA:12,8 -DA:21,0 -DA:22,8 -DA:23,24 -DA:24,1 -DA:25,5 -DA:29,0 -DA:30,0 -DA:31,16 -DA:32,0 -DA:33,0 -DA:34,0 -DA:35,16 -DA:36,1 -DA:38,0 -DA:40,2 -DA:44,0 -DA:47,0 -DA:49,0 -DA:50,4 -DA:51,0 -DA:52,4 -DA:53,2 -DA:54,2 -DA:55,0 -DA:56,2 -DA:57,0 -DA:58,3 -DA:59,2 -DA:60,1 -DA:61,1 -DA:62,1 -DA:63,1 -DA:64,0 -DA:66,2 -DA:67,0 -DA:69,0 -DA:70,7 -DA:71,0 -DA:72,2 -DA:76,5 -DA:78,0 -DA:83,14 -DA:85,12 -DA:87,12 -DA:89,6 -DA:91,6 -DA:92,6 -DA:93,6 -DA:95,0 -DA:96,6 -DA:97,6 -DA:99,18 -DA:100,6 -DA:104,6 -DA:105,0 -DA:106,6 -DA:107,0 -DA:108,6 -DA:109,0 -DA:110,6 -DA:111,0 -DA:112,7 -DA:113,0 -DA:114,6 -DA:115,0 -DA:116,18 -DA:117,6 -DA:118,6 -DA:120,6 -DA:121,0 -DA:122,6 -DA:124,6 -DA:125,8 -DA:127,0 -DA:129,10 -DA:131,4 -DA:132,8 -DA:138,0 -DA:140,0 -DA:142,0 -DA:147,0 -DA:155,0 -DA:156,0 -DA:157,0 -DA:164,0 -DA:165,0 -DA:166,3 -DA:174,0 -DA:176,3 -DA:177,2 -DA:178,2 -DA:179,2 -DA:180,3 -DA:181,1 -DA:182,3 -DA:183,3 -DA:184,0 -DA:185,0 -DA:186,0 -DA:187,2 -DA:188,0 -DA:189,2 -DA:190,2 -DA:191,2 -DA:192,7 -DA:193,4 -DA:194,4 -DA:195,0 -DA:197,0 -DA:198,0 -DA:199,0 -DA:200,0 -DA:201,0 -DA:202,0 -LF:116 -LH:67 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/schemas/boolean/boolean_schema.dart -DA:23,29 -DA:29,5 -LF:2 -LH:2 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/validation/ack_exception.dart -DA:7,4 -DA:9,2 -DA:10,6 -DA:13,6 -DA:15,1 -DA:17,2 -LF:6 -LH:6 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/validation/schema_error.dart -DA:10,0 -DA:12,17 -DA:16,0 -DA:18,0 -DA:19,2 -DA:20,2 -DA:21,2 -DA:22,4 -DA:23,2 -DA:24,2 -DA:26,0 -DA:27,0 -DA:28,0 -DA:29,0 -DA:30,0 -DA:34,0 -DA:36,0 -DA:37,0 -DA:40,0 -DA:41,0 -DA:42,0 -DA:43,0 -DA:44,0 -DA:47,0 -DA:48,0 -DA:50,0 -DA:51,0 -DA:52,0 -DA:56,0 -DA:57,0 -DA:58,16 -DA:60,0 -DA:61,16 -DA:63,16 -DA:64,16 -DA:65,16 -DA:66,0 -DA:67,0 -DA:68,3 -DA:70,3 -DA:72,10 -DA:73,50 -DA:76,0 -DA:77,0 -DA:78,0 -DA:80,0 -DA:81,0 -DA:82,0 -DA:83,0 -DA:90,0 -DA:91,2 -DA:93,2 -DA:94,2 -DA:95,12 -DA:96,0 -DA:97,0 -DA:98,0 -DA:103,4 -DA:104,4 -DA:105,0 -DA:106,4 -DA:107,4 -DA:108,4 -DA:109,0 -DA:110,8 -DA:114,2 -DA:115,6 -DA:118,0 -DA:120,0 -DA:125,0 -DA:126,1 -DA:127,1 -DA:128,0 -DA:129,1 -DA:130,1 -DA:131,1 -DA:135,1 -DA:137,2 -DA:138,1 -DA:139,5 -DA:141,2 -DA:142,2 -DA:143,0 -DA:144,0 -DA:145,0 -DA:146,0 -DA:148,0 -DA:151,1 -DA:152,2 -DA:153,0 -DA:156,0 -DA:157,0 -DA:158,0 -DA:159,0 -DA:166,0 -DA:175,0 -DA:180,0 -DA:181,0 -DA:182,0 -DA:184,0 -DA:187,0 -LF:101 -LH:41 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/validation/schema_result.dart -DA:10,16 -DA:13,16 -DA:14,16 -DA:18,16 -DA:19,16 -DA:22,8 -DA:23,8 -DA:25,0 -DA:29,26 -DA:30,0 -DA:34,32 -DA:36,0 -DA:37,0 -DA:38,0 -DA:40,6 -DA:41,6 -DA:42,2 -DA:43,6 -DA:48,12 -DA:49,25 -DA:50,0 -DA:51,0 -DA:52,0 -DA:53,0 -DA:56,1 -DA:57,5 -DA:61,0 -DA:62,0 -DA:63,0 -DA:64,3 -DA:65,3 -DA:66,4 -DA:67,6 -DA:74,0 -DA:77,13 -DA:79,0 -DA:81,0 -DA:83,13 -DA:84,12 -DA:86,12 -DA:88,0 -DA:89,16 -DA:96,1 -DA:97,2 -DA:104,1 -DA:105,4 -DA:108,0 -DA:114,0 -DA:115,0 -DA:116,16 -DA:118,0 -DA:120,0 -DA:124,0 -DA:125,0 -DA:129,16 -DA:138,0 -LF:56 -LH:32 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/helpers.dart -DA:3,4 -DA:4,4 -DA:5,4 -DA:7,4 -DA:10,5 -DA:12,0 -DA:15,0 -DA:16,10 -DA:19,5 -DA:20,0 -DA:21,0 -DA:24,10 -DA:25,10 -DA:29,5 -DA:34,5 -DA:35,5 -DA:36,0 -DA:37,3 -DA:38,2 -DA:39,0 -DA:40,4 -DA:42,0 -DA:45,0 -DA:47,5 -DA:48,15 -DA:49,5 -DA:50,3 -DA:53,15 -DA:55,0 -DA:57,5 -DA:60,0 -DA:61,0 -DA:64,5 -DA:65,0 -DA:67,5 -DA:69,5 -DA:71,0 -DA:72,6 -DA:73,6 -DA:76,20 -DA:77,15 -DA:78,0 -DA:79,15 -DA:81,10 -DA:83,15 -DA:85,15 -DA:88,15 -DA:89,10 -DA:90,15 -DA:91,10 -DA:93,15 -DA:97,5 -DA:99,0 -DA:101,10 -DA:103,0 -DA:104,0 -DA:105,0 -DA:106,0 -DA:107,0 -DA:109,0 -DA:111,2 -DA:115,2 -DA:116,4 -DA:117,2 -DA:118,3 -DA:119,2 -DA:121,2 -DA:123,0 -DA:124,0 -DA:126,0 -DA:127,0 -DA:128,0 -DA:129,27 -DA:130,0 -DA:131,16 -DA:133,22 -DA:135,11 -DA:136,11 -DA:137,0 -DA:138,21 -DA:139,10 -DA:140,5 -DA:142,10 -DA:147,0 -DA:148,0 -DA:149,27 -DA:151,1 -DA:152,3 -DA:153,0 -DA:154,10 -DA:155,20 -DA:156,10 -DA:167,2 -DA:168,2 -DA:169,2 -DA:172,4 -DA:173,3 -DA:178,5 -DA:183,1 -DA:184,1 -DA:185,1 -DA:186,1 -DA:187,1 -DA:188,1 -DA:189,1 -DA:190,1 -LF:106 -LH:75 -end_of_record -SF:/Users/leofarias/Projects/ack/lib/src/context.dart -DA:7,30 -DA:14,40 -DA:21,15 -DA:25,45 -DA:28,15 -DA:29,15 -DA:30,60 -DA:33,15 -DA:35,45 -DA:37,0 -DA:38,0 -DA:41,0 -DA:47,23 -DA:48,0 -LF:14 -LH:10 -end_of_record diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 22d3f6d0..8d07e818 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -110,7 +110,8 @@ Schema for validating strings. See [String Validation](../core-concepts/validati ### Date and Time - `date()`: Must be valid ISO 8601 date (YYYY-MM-DD) -- `datetime()`: Must be valid ISO 8601 datetime +- `datetime()`: Must be a valid ISO 8601 datetime; announced RFC leap seconds + are accepted and preserved as strings - `time()`: Must be valid time format (HH:MM:SS) ### Transformations @@ -246,7 +247,7 @@ Schema reference for recursive object graphs. Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builder to turn annotations into extension types. After adding the annotations below, run: ```bash -dart run build_runner build --delete-conflicting-outputs +dart run build_runner build ``` ### `@AckType()` @@ -337,7 +338,9 @@ reverse direction. See [Codecs](../core-concepts/codecs.mdx). **Built-in codecs:** - `Ack.date()` → `CodecSchema` (ISO `YYYY-MM-DD` ↔ local-midnight `DateTime`) -- `Ack.datetime()` → `CodecSchema` (ISO 8601 ↔ UTC `DateTime`) +- `Ack.datetime()` → `CodecSchema` (ISO 8601 ↔ UTC + `DateTime`; leap-second strings are rejected because Dart cannot represent + them) - `Ack.uri()` → `CodecSchema` (absolute URI string ↔ `Uri`) - `Ack.duration()` → `CodecSchema` (milliseconds ↔ `Duration`) - `Ack.enumCodec(List values)` → `CodecSchema` (enum `.name` ↔ enum value) diff --git a/docs/core-concepts/codecs.mdx b/docs/core-concepts/codecs.mdx index 825811aa..5ea8d3cb 100644 --- a/docs/core-concepts/codecs.mdx +++ b/docs/core-concepts/codecs.mdx @@ -31,7 +31,7 @@ Ack ships codecs for the most common boundary conversions: | Factory | Boundary ↔ Runtime | Runtime invariant | | --- | --- | --- | | `Ack.date()` | ISO `YYYY-MM-DD` `String` ↔ `DateTime` | local midnight (no time-of-day) | -| `Ack.datetime()` | ISO 8601 `String` ↔ `DateTime` | must be UTC | +| `Ack.datetime()` | ISO 8601 `String` ↔ `DateTime` | must be UTC; leap-second strings are rejected | | `Ack.uri()` | `String` ↔ `Uri` | absolute URI (scheme + host) | | `Ack.duration()` | milliseconds `int` ↔ `Duration` | whole milliseconds | | `Ack.enumCodec(values)` | enum-name `String` ↔ enum value | one of `values` | @@ -54,7 +54,11 @@ final event = schema.parse({ // event['timeout'] is a Duration (5 seconds) ``` -Each built-in enforces a runtime invariant on encode. `Ack.datetime()` requires a UTC `DateTime`; convert local values with `.toUtc()` before encoding. +Each built-in enforces a runtime invariant on encode. `Ack.datetime()` requires +a UTC `DateTime`; convert local values with `.toUtc()` before encoding. It also +rejects RFC leap-second strings before decoding because Dart normalizes `:60`. +Use `Ack.string().datetime()` when leap-second text must be validated and +preserved. ## Custom codecs diff --git a/docs/core-concepts/schemas.mdx b/docs/core-concepts/schemas.mdx index b09f2ffb..b15e092e 100644 --- a/docs/core-concepts/schemas.mdx +++ b/docs/core-concepts/schemas.mdx @@ -61,7 +61,9 @@ final roleSchema = Ack.enumValues(Role.values); > **`Ack.string().date()` vs `Ack.date()`:** `Ack.string().date()` checks the > format and keeps the value a `String`. [`Ack.date()`](./codecs.mdx) (a codec) > validates the same format but returns a `DateTime`. The same applies to -> `Ack.string().datetime()` vs `Ack.datetime()`. +> `Ack.string().datetime()` vs `Ack.datetime()`, except announced leap seconds: +> the string schema preserves them, while the codec rejects them because Dart's +> `DateTime` cannot represent `:60`. ### Number diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 9e8589b3..59b61cff 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -8,7 +8,7 @@ Tired of writing `data['name'] as String` after every parse? Annotate a top-leve 1. Define schemas with the Ack fluent API. 2. Annotate each top-level schema variable or getter with `@AckType()`. -3. Run `dart run build_runner build --delete-conflicting-outputs`. +3. Run `dart run build_runner build`. 4. Use the generated `TypeName.parse()` / `TypeName.safeParse()` helpers. ## Basic usage @@ -106,7 +106,8 @@ Conflicting discriminator fields, broad `Ack.string()`, and transformed or refin - `@AckType()` only works on top-level schema variables and getters. - Nullable top-level schemas do not emit extension types. -- List element nullability may not be preserved in all chained modifier cases. +- `Ack.list(...)` rejects nullable item schemas. Make the list itself nullable + with `Ack.list(item).nullable()` when the whole field may be null. - Use `.transform(...)` with an explicit output type so the generator can infer the representation type. ## Build checklist @@ -114,7 +115,7 @@ Conflicting discriminator fields, broad `Ack.string()`, and transformed or refin 1. Add `ack_annotations`, `ack_generator`, and `build_runner` to your pubspec. 2. Add `part '.g.dart';` to the file. 3. Annotate top-level schema variables or getters with `@AckType()`. -4. Run `dart run build_runner build --delete-conflicting-outputs`. +4. Run `dart run build_runner build`. ## Next steps diff --git a/docs/core-concepts/validation.mdx b/docs/core-concepts/validation.mdx index 07bb1e7e..33943181 100644 --- a/docs/core-concepts/validation.mdx +++ b/docs/core-concepts/validation.mdx @@ -82,7 +82,10 @@ Ack.string().date() ``` ### `datetime()` -Requires a valid ISO 8601 date-time string. +Requires a valid ISO 8601 date-time string with a timezone. Announced RFC 3339 +leap seconds are accepted and preserved because this schema returns the input +`String`. The `Ack.datetime()` codec rejects leap seconds because Dart's +`DateTime` cannot represent them without normalization. ```dart Ack.string().datetime() ``` diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 6fac59fb..1de94512 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -62,7 +62,7 @@ final userSchema = Ack.object({ Run the generator: ```bash -dart run build_runner build --delete-conflicting-outputs +dart run build_runner build ``` See [TypeSafe Schemas](../core-concepts/typesafe-schemas.mdx) for more `@AckType` examples and supported schema shapes. diff --git a/example/README.md b/example/README.md index 0b0d2586..957f553c 100644 --- a/example/README.md +++ b/example/README.md @@ -17,8 +17,8 @@ This package demonstrates Ack schemas built directly in source and typed with ## Running the examples ```bash -melos bootstrap +dart run melos bootstrap cd example -dart run build_runner build --delete-conflicting-outputs +dart run build_runner build dart test ``` diff --git a/example/melos_ack_example.iml b/example/melos_ack_example.iml deleted file mode 100644 index 389d07a1..00000000 --- a/example/melos_ack_example.iml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/llms.txt b/llms.txt index 331580d7..fb4a6422 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # Ack -> A schema validation library for Dart and Flutter with a fluent runtime API and `@AckType()`-driven extension-type generation. Version 1.0.0. +> A schema validation library for Dart and Flutter with a fluent runtime API and `@AckType()`-driven extension-type generation. Version 1.0.1. Ack validates external data with hand-written schemas built using the `Ack` factory. When you want typed wrappers over validated values, annotate top-level @@ -37,7 +37,8 @@ Codecs decode a boundary value (wire shape) into a runtime value and encode it back. `parse`/`safeParse` decode; `encode`/`safeEncode` encode. - Built-in: `Ack.date()` (ISO `YYYY-MM-DD` <-> local-midnight `DateTime`), - `Ack.datetime()` (ISO 8601 <-> UTC `DateTime`), `Ack.uri()` (`String` <-> `Uri`), + `Ack.datetime()` (ISO 8601 <-> UTC `DateTime`; rejects leap-second strings + because Dart cannot represent them), `Ack.uri()` (`String` <-> `Uri`), `Ack.duration()` (milliseconds `int` <-> `Duration`), `Ack.enumCodec(values)` (enum-name `String` <-> enum value). - Custom: `Ack.codec(input: ..., decode: ..., encode: ...)`, or @@ -172,6 +173,8 @@ AckType generation is intentionally strict: - `schema.parse(data)` throws on invalid input - `schema.safeParse(data)` returns `SchemaResult` +- `safeParse` does not throw; exceptions from validation/refinement callbacks + are returned as contextual validation failures - `SchemaResult.getOrThrow()` returns the validated value or throws `AckException` - `.optional()` allows a field to be omitted - `.nullable()` allows a present field to hold `null` @@ -180,9 +183,13 @@ AckType generation is intentionally strict: - `schema.toJsonSchema()` renders `schema.toSchemaModel().toJsonSchema()` - adapter packages should render from `AckSchemaModel`, not by traversing `AckSchema` subclasses - `Ack.list(...)` does not support nullable item schemas; make the list itself nullable when needed +- `Ack.object`, `Ack.anyOf`, and enum factories snapshot their input collections; + unions and enum value lists must be non-empty, and enum values must be unique +- lengths and item counts must be non-negative; numeric bounds must be finite; + `multipleOf` must be finite and greater than zero ## Build command ```bash -dart run build_runner build --delete-conflicting-outputs +dart run build_runner build ``` diff --git a/melos_ack_workspace.iml b/melos_ack_workspace.iml deleted file mode 100644 index 96815595..00000000 --- a/melos_ack_workspace.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index a4ef385d..0027a387 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,3 +1,20 @@ +## Unreleased + +### Fixed + +* Keep `safeParse` non-throwing when refinements fail with exceptions. +* Validate numeric `multipleOf`, IPv6, and RFC 3339 date-time edge cases + strictly; preserve announced leap seconds in string schemas while rejecting + them in `Ack.datetime()`, where Dart cannot represent them; and reject invalid + constraint configuration at schema construction. +* Snapshot factory collections; reject empty unions and empty or duplicate + enum inputs. +* Preserve intersecting constraints in JSON Schema output and correct + behavior-based schema/deep-collection equality. +* Reject union schemas that can produce nullable list items. +* Bound direct, indirect, wrapper-mediated, and fluent-copy lazy-schema alias + recursion. + ## 1.0.1 * See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.1) for details. diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 3c7d650d..57df7d43 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -51,8 +51,16 @@ final class Ack { ) => ListSchema(itemSchema); /// Creates an enum schema for validating enum values. - static EnumSchema enumValues(List values) => - EnumSchema(values: values); + static EnumSchema enumValues(List values) { + if (values.isEmpty) { + throw ArgumentError.value(values, 'values', 'Must not be empty.'); + } + final names = values.map((value) => value.name).toSet(); + if (names.length != values.length) { + throw ArgumentError.value(values, 'values', 'Must be unique.'); + } + return EnumSchema(values: List.unmodifiable(values)); + } /// Creates a bidirectional codec for Dart enums, with boundary `String` and /// runtime [T]. @@ -67,16 +75,28 @@ final class Ack { /// (e.g. adding constraints, copying with new flags). Prefer [enumCodec] /// when downstream code expects every value-shape to be a `CodecSchema`. static CodecSchema enumCodec(List values) => - enumValues( - values, - ).codec(decode: (value) => value, encode: (value) => value); + enumValues(values).codec(decode: _identity, encode: _identity); /// Creates a string schema that only accepts one of the given [values]. - static StringSchema enumString(List values) => - string().withConstraint(PatternConstraint.enumString(values)); + static StringSchema enumString(List values) { + if (values.isEmpty) { + throw ArgumentError.value(values, 'values', 'Must not be empty.'); + } + if (values.toSet().length != values.length) { + throw ArgumentError.value(values, 'values', 'Must be unique.'); + } + return string().withConstraint( + PatternConstraint.enumString(List.unmodifiable(values)), + ); + } /// Creates a schema that can be one of many types. - static AnyOfSchema anyOf(List schemas) => AnyOfSchema(schemas); + static AnyOfSchema anyOf(List schemas) { + if (schemas.isEmpty) { + throw ArgumentError.value(schemas, 'schemas', 'Must not be empty.'); + } + return AnyOfSchema(List.unmodifiable(schemas)); + } /// Creates a schema that accepts any non-null JSON-safe value. /// @@ -157,13 +177,20 @@ final class Ack { /// Bidirectional datetime codec: ISO 8601 datetime strings ↔ UTC /// `DateTime` runtime values. /// + /// Announced RFC 3339 leap seconds are valid for `Ack.string().datetime()`, + /// but this codec rejects them because Dart normalizes `:60` to the following + /// minute instead of preserving the instant's textual representation. + /// /// Runtime invariant: the encoded `DateTime` must be UTC. Local-time /// values fail validation; convert with `.toUtc()` before encoding. static CodecSchema datetime() { return CodecSchema.create( - inputSchema: string().datetime(), + inputSchema: string().datetime().refine( + isDateTimeSecondRepresentableByDart, + message: 'Dart DateTime cannot represent leap seconds.', + ), outputSchema: InstanceSchema().refine( - (value) => value.isUtc, + _isUtcDateTime, message: 'Expected a UTC DateTime.', ), decoder: DateTime.parse, @@ -179,11 +206,11 @@ final class Ack { return CodecSchema.create( inputSchema: string().uri(), outputSchema: InstanceSchema().refine( - (u) => u.hasScheme && u.host.isNotEmpty, + _isAbsoluteUri, message: 'Expected an absolute URI with scheme and host.', ), decoder: Uri.parse, - encoder: (value) => value.toString(), + encoder: _encodeUri, ); } @@ -196,16 +223,17 @@ final class Ack { return CodecSchema.create( inputSchema: integer(), outputSchema: InstanceSchema().refine( - (value) => - value.inMicroseconds % Duration.microsecondsPerMillisecond == 0, + _isWholeMillisecondDuration, message: 'Expected a whole-millisecond Duration.', ), - decoder: (ms) => Duration(milliseconds: ms), - encoder: (value) => value.inMilliseconds, + decoder: _decodeDuration, + encoder: _encodeDuration, ); } } +T _identity(T value) => value; + bool _isLocalMidnightDate(DateTime value) { if (value.isUtc) return false; return value.hour == 0 && @@ -215,6 +243,22 @@ bool _isLocalMidnightDate(DateTime value) { value.microsecond == 0; } +bool _isUtcDateTime(DateTime value) => value.isUtc; + +bool _isAbsoluteUri(Uri value) => value.hasScheme && value.host.isNotEmpty; + +String _encodeUri(Uri value) => value.toString(); + +bool _isWholeMillisecondDuration(Duration value) { + return value.inMicroseconds % Duration.microsecondsPerMillisecond == 0; +} + +Duration _decodeDuration(int milliseconds) { + return Duration(milliseconds: milliseconds); +} + +int _encodeDuration(Duration value) => value.inMilliseconds; + String _encodeIsoDate(DateTime value) { final y = value.year.toString().padLeft(4, '0'); final m = value.month.toString().padLeft(2, '0'); diff --git a/packages/ack/lib/src/constraints/comparison_constraint.dart b/packages/ack/lib/src/constraints/comparison_constraint.dart index de13fd1f..8c6eb279 100644 --- a/packages/ack/lib/src/constraints/comparison_constraint.dart +++ b/packages/ack/lib/src/constraints/comparison_constraint.dart @@ -22,6 +22,20 @@ _ConstraintCategory _categorize(String constraintKey) { return _ConstraintCategory.numeric; } +int _requireNonNegative(int value, String name) { + if (value < 0) { + throw ArgumentError.value(value, name, 'Must not be negative.'); + } + return value; +} + +N _requireFinite(N value, String name) { + if (!value.isFinite) { + throw ArgumentError.value(value, name, 'Must be finite.'); + } + return value; +} + /// A generic constraint for various comparison-based validations. /// /// This versatile constraint handles comparisons like minimum/maximum length for strings/lists, @@ -39,14 +53,14 @@ class ComparisonConstraint extends Constraint /// Optional custom message builder. If provided, overrides default messages. final String Function(T value, num extractedValue)? customMessageBuilder; - /// Tolerance for floating-point multipleOf comparisons. + /// Machine epsilon used for floating-point multipleOf comparisons. /// - /// Accounts for IEEE 754 floating-point representation errors when - /// checking if a number is a multiple of another. The value 1e-10 was chosen - /// to handle typical double precision errors (around 1e-15 to 1e-16) while - /// providing a safe margin for accumulated rounding in common use cases - /// like currency (0.01 multiples) and percentages (0.1 multiples). - static const _multipleOfEpsilon = 1e-10; + /// The tolerance is scaled to the reconstructed value below so it follows + /// floating-point precision instead of accepting a fixed quotient error. + static const _doubleMachineEpsilon = 2.220446049250313e-16; + + /// Allows for rounding in division, integer rounding, and multiplication. + static const _multipleOfPrecisionFactor = 4; const ComparisonConstraint({ required super.constraintKey, @@ -68,7 +82,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint stringMinLength(int min) => ComparisonConstraint( type: ComparisonType.gte, - threshold: min, + threshold: _requireNonNegative(min, 'min'), valueExtractor: (s) => s.length, constraintKey: 'string_min_length', description: 'String must be at least $min characters.', @@ -78,7 +92,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint stringMaxLength(int max) => ComparisonConstraint( type: ComparisonType.lte, - threshold: max, + threshold: _requireNonNegative(max, 'max'), valueExtractor: (s) => s.length, constraintKey: 'string_max_length', description: 'String must be at most $max characters.', @@ -88,7 +102,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint stringExactLength(int length) => ComparisonConstraint( type: ComparisonType.eq, - threshold: length, + threshold: _requireNonNegative(length, 'length'), valueExtractor: (s) => s.length, constraintKey: 'string_exact_length', description: 'String must be exactly $length characters.', @@ -100,7 +114,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint numberMin(N min) => ComparisonConstraint( type: ComparisonType.gte, - threshold: min, + threshold: _requireFinite(min, 'min'), valueExtractor: (n) => n, constraintKey: 'number_min', description: 'Number must be at least $min.', @@ -108,7 +122,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint numberMax(N max) => ComparisonConstraint( type: ComparisonType.lte, - threshold: max, + threshold: _requireFinite(max, 'max'), valueExtractor: (n) => n, constraintKey: 'number_max', description: 'Number must be at most $max.', @@ -116,7 +130,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint numberExclusiveMin(N min) => ComparisonConstraint( type: ComparisonType.gt, - threshold: min, + threshold: _requireFinite(min, 'min'), valueExtractor: (n) => n, constraintKey: 'number_exclusive_min', description: 'Number must be greater than $min.', @@ -124,20 +138,31 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint numberExclusiveMax(N max) => ComparisonConstraint( type: ComparisonType.lt, - threshold: max, + threshold: _requireFinite(max, 'max'), valueExtractor: (n) => n, constraintKey: 'number_exclusive_max', description: 'Number must be less than $max.', ); - static ComparisonConstraint numberRange(N min, N max) => - ComparisonConstraint( - type: ComparisonType.range, - threshold: min, - maxThreshold: max, - valueExtractor: (n) => n, - constraintKey: 'number_range', - description: 'Number must be between $min and $max (inclusive).', + static ComparisonConstraint numberRange(N min, N max) { + _requireFinite(min, 'min'); + _requireFinite(max, 'max'); + if (min > max) { + throw ArgumentError.value( + max, + 'max', + 'Must be greater than or equal to min ($min).', ); + } + return ComparisonConstraint( + type: ComparisonType.range, + threshold: min, + maxThreshold: max, + valueExtractor: (n) => n, + constraintKey: 'number_range', + description: 'Number must be between $min and $max (inclusive).', + ); + } + static ComparisonConstraint numberMultipleOf(N multiple) { if (multiple == 0) { throw ArgumentError.value( @@ -146,12 +171,19 @@ class ComparisonConstraint extends Constraint 'multipleOf value cannot be zero', ); } + if (!multiple.isFinite || multiple < 0) { + throw ArgumentError.value( + multiple, + 'multiple', + 'multipleOf value must be finite and greater than zero', + ); + } return ComparisonConstraint( type: ComparisonType.eq, threshold: 0, multipleValue: multiple, - valueExtractor: (n) => n.remainder(multiple), // Check if remainder is 0 + valueExtractor: (n) => n, constraintKey: 'number_multiple_of', description: 'Number must be a multiple of $multiple.', customMessageBuilder: (value, _) => @@ -183,7 +215,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint> listMinItems(int min) => ComparisonConstraint>( type: ComparisonType.gte, - threshold: min, + threshold: _requireNonNegative(min, 'min'), valueExtractor: (l) => l.length, constraintKey: 'list_min_items', description: 'List must have at least $min items.', @@ -193,7 +225,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint> listMaxItems(int max) => ComparisonConstraint>( type: ComparisonType.lte, - threshold: max, + threshold: _requireNonNegative(max, 'max'), valueExtractor: (l) => l.length, constraintKey: 'list_max_items', description: 'List must have at most $max items.', @@ -203,7 +235,7 @@ class ComparisonConstraint extends Constraint static ComparisonConstraint> listExactItems(int length) => ComparisonConstraint>( type: ComparisonType.eq, - threshold: length, + threshold: _requireNonNegative(length, 'length'), valueExtractor: (l) => l.length, constraintKey: 'list_exact_items', description: 'List must have exactly $length items.', @@ -216,7 +248,7 @@ class ComparisonConstraint extends Constraint int min, ) => ComparisonConstraint>( type: ComparisonType.gte, - threshold: min, + threshold: _requireNonNegative(min, 'min'), valueExtractor: (m) => m.keys.length, constraintKey: 'object_min_properties', description: 'Object must have at least $min properties.', @@ -227,7 +259,7 @@ class ComparisonConstraint extends Constraint int max, ) => ComparisonConstraint>( type: ComparisonType.lte, - threshold: max, + threshold: _requireNonNegative(max, 'max'), valueExtractor: (m) => m.keys.length, constraintKey: 'object_max_properties', description: 'Object must have at most $max properties.', @@ -251,14 +283,24 @@ class ComparisonConstraint extends Constraint ComparisonType.lte => extracted <= threshold, ComparisonType.eq => () { if (multipleValue != null && constraintKey == 'number_multiple_of') { - // Due to IEEE 754 floating-point errors, remainder can be: - // - Close to 0 (e.g., 1.5 % 0.5 = 0.0) - // - Close to the multiple itself (e.g., 0.6 % 0.1 = 0.0999... ≈ 0.1) - final rem = extracted.abs(); - final multiple = multipleValue!.abs(); - - return rem < _multipleOfEpsilon || - (multiple - rem).abs() < _multipleOfEpsilon; + if (!extracted.isFinite) return false; + if (extracted is int && multipleValue is int) { + return extracted.remainder(multipleValue!) == 0; + } + + final quotient = extracted / multipleValue!; + if (!quotient.isFinite) return false; + + final nearestMultiple = quotient.roundToDouble(); + final reconstructed = nearestMultiple * multipleValue!; + if (!reconstructed.isFinite) return false; + + final magnitude = extracted.abs() > reconstructed.abs() + ? extracted.abs() + : reconstructed.abs(); + final tolerance = + magnitude * _doubleMachineEpsilon * _multipleOfPrecisionFactor; + return (extracted - reconstructed).abs() <= tolerance; } return extracted == threshold; @@ -322,14 +364,21 @@ class ComparisonConstraint extends Constraint if (constraintKey == 'number_multiple_of' && multipleValue != null) { return {'multipleOf': multipleValue}; } - if (category == _ConstraintCategory.stringLength) { - return { + return switch (category) { + _ConstraintCategory.stringLength => { 'minLength': threshold.toInt(), 'maxLength': threshold.toInt(), - }; - } - - return {'const': threshold}; + }, + _ConstraintCategory.listItems => { + 'minItems': threshold.toInt(), + 'maxItems': threshold.toInt(), + }, + _ConstraintCategory.objectProperties => { + 'minProperties': threshold.toInt(), + 'maxProperties': threshold.toInt(), + }, + _ConstraintCategory.numeric => {'const': threshold}, + }; }(), ComparisonType.range => switch (category) { _ConstraintCategory.stringLength => { diff --git a/packages/ack/lib/src/constraints/pattern_constraint.dart b/packages/ack/lib/src/constraints/pattern_constraint.dart index d60d1fa6..8cc036a3 100644 --- a/packages/ack/lib/src/constraints/pattern_constraint.dart +++ b/packages/ack/lib/src/constraints/pattern_constraint.dart @@ -180,23 +180,7 @@ class PatternConstraint extends Constraint static PatternConstraint dateTimeIso8601() => PatternConstraint( type: PatternType.format, - // RFC 3339 / ISO-8601 validation using Dart's built-in DateTime parser - // Dart's tryParse implements RFC 3339, which is a subset of ISO-8601 - formatValidator: (v) { - // Use Dart's built-in RFC 3339/ISO-8601 parser - final dt = DateTime.tryParse(v); - if (dt == null) return false; - - // Must contain 'T' separator (distinguishes datetime from date-only) - // Must contain timezone indicator (Z or +/-HH:MM) - final hasTimeSeparator = v.contains('T') || v.contains('t'); - final hasTimezone = - v.endsWith('Z') || - v.endsWith('z') || - RegExp(r'[+-]\d{2}:\d{2}$').hasMatch(v); - - return hasTimeSeparator && hasTimezone; - }, + formatValidator: _isValidIso8601DateTime, constraintKey: 'string_format_datetime', description: 'Must be a valid ISO 8601 date-time string.', example: '2023-10-27T10:30:00Z', @@ -361,3 +345,87 @@ class PatternConstraint extends Constraint ); } } + +final _iso8601DateTimePattern = RegExp( + r'^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|[+-](\d{2}):(\d{2}))$', +); + +/// Whether the seconds field in [value] can be represented by [DateTime]. +/// +/// Dart normalizes an RFC 3339 leap second (`:60`) to the following minute, +/// so a lossless datetime codec must reject it before decoding. +bool isDateTimeSecondRepresentableByDart(String value) { + final match = _iso8601DateTimePattern.firstMatch(value); + return match != null && match[6] != '60'; +} + +bool _isValidIso8601DateTime(String value) { + final match = _iso8601DateTimePattern.firstMatch(value); + if (match == null) return false; + + final year = int.parse(match[1]!); + final month = int.parse(match[2]!); + final day = int.parse(match[3]!); + final hour = int.parse(match[4]!); + final minute = int.parse(match[5]!); + final second = int.parse(match[6]!); + final offsetHour = int.tryParse(match[7] ?? '') ?? 0; + final offsetMinute = int.tryParse(match[8] ?? '') ?? 0; + + if (hour > 23 || minute > 59 || second > 60) return false; + if (offsetHour > 23 || offsetMinute > 59) return false; + + final date = DateTime.utc(year, month, day); + final validDate = date.year == year && date.month == month && date.day == day; + if (!validDate) return false; + + return second < 60 || _isAnnouncedLeapSecond(value); +} + +bool _isAnnouncedLeapSecond(String value) { + final normalized = DateTime.tryParse(value)?.toUtc(); + if (normalized == null) return false; + + final precedingSecond = normalized.subtract(const Duration(seconds: 1)); + final utcDate = ( + precedingSecond.year, + precedingSecond.month, + precedingSecond.day, + ); + return precedingSecond.hour == 23 && + precedingSecond.minute == 59 && + precedingSecond.second == 59 && + _announcedLeapSecondUtcDates.contains(utcDate); +} + +// Positive leap seconds announced through IERS Bulletin C 72 (July 2026). +// Source: https://hpiers.obspm.fr/iers/bul/bulc/Leap_Second.dat +const _announcedLeapSecondUtcDates = <(int, int, int)>{ + (1972, 6, 30), + (1972, 12, 31), + (1973, 12, 31), + (1974, 12, 31), + (1975, 12, 31), + (1976, 12, 31), + (1977, 12, 31), + (1978, 12, 31), + (1979, 12, 31), + (1981, 6, 30), + (1982, 6, 30), + (1983, 6, 30), + (1985, 6, 30), + (1987, 12, 31), + (1989, 12, 31), + (1990, 12, 31), + (1992, 6, 30), + (1993, 6, 30), + (1994, 6, 30), + (1995, 12, 31), + (1997, 6, 30), + (1998, 12, 31), + (2005, 12, 31), + (2008, 12, 31), + (2012, 6, 30), + (2015, 6, 30), + (2016, 12, 31), +}; diff --git a/packages/ack/lib/src/constraints/string_ip_constraint.dart b/packages/ack/lib/src/constraints/string_ip_constraint.dart index 21489d30..1bdd8782 100644 --- a/packages/ack/lib/src/constraints/string_ip_constraint.dart +++ b/packages/ack/lib/src/constraints/string_ip_constraint.dart @@ -9,11 +9,6 @@ class StringIpConstraint extends Constraint r'^((25[0-5]|(2[0-4]|1[0-9]|[1-9]|)[0-9])(\.(?!$)|$)){4}$', ); - // Regex for IPv6. - static final _ipv6Regex = RegExp( - r'(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))', - ); - const StringIpConstraint({this.version}) : super( constraintKey: 'string.ip', @@ -24,9 +19,18 @@ class StringIpConstraint extends Constraint @override bool isValid(String value) { if (version == 4) return _ipv4Regex.hasMatch(value); - if (version == 6) return _ipv6Regex.hasMatch(value); + if (version == 6) return _isIpv6(value); + + return _ipv4Regex.hasMatch(value) || _isIpv6(value); + } - return _ipv4Regex.hasMatch(value) || _ipv6Regex.hasMatch(value); + static bool _isIpv6(String value) { + try { + Uri.parseIPv6Address(value); + return true; + } on FormatException { + return false; + } } @override diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 24ea9474..a9cd35fe 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -353,6 +353,11 @@ AckSchemaModel _applyConstraints( AckSchema schema, ) { var next = model; + const deepEquality = DeepCollectionEquality(); + final appliedKeywordValues = { + for (final entry in _renderedKeywords(model).entries) + entry.key: [entry.value], + }; for (final constraint in schema.constraints) { if (constraint is DateTimeConstraint) { next = _applyDateTimeConstraint(next, constraint); @@ -360,13 +365,52 @@ AckSchemaModel _applyConstraints( } if (constraint is JsonSchemaSpec) { - next = next.withJsonSchemaKeywords(constraint.toJsonSchema()); + final keywords = constraint.toJsonSchema(); + final newKeywords = {}; + final conflicts = {}; + for (final entry in keywords.entries) { + final seenValues = appliedKeywordValues[entry.key]; + if (seenValues == null) { + appliedKeywordValues[entry.key] = [entry.value]; + newKeywords[entry.key] = entry.value; + } else if (!seenValues.any( + (value) => deepEquality.equals(value, entry.value), + )) { + seenValues.add(entry.value); + conflicts[entry.key] = entry.value; + } + } + if (newKeywords.isNotEmpty) { + next = next.withJsonSchemaKeywords(newKeywords); + } + if (conflicts.isNotEmpty) { + final existingAllOf = switch (next.extensions['allOf']) { + final List value => value, + _ => const [], + }; + next = next.withExtensions({ + ...next.extensions, + 'allOf': [...existingAllOf, conflicts], + }); + } } } return next; } +Map _renderedKeywords(AckSchemaModel model) { + final rendered = model.toJsonSchema(); + final branches = rendered['anyOf']; + if (model.nullable && branches is List && branches.isNotEmpty) { + final nonNullBranch = branches.first; + if (nonNullBranch is Map) { + return Map.from(nonNullBranch); + } + } + return rendered; +} + AckSchemaModel _applyDateTimeConstraint( AckSchemaModel model, DateTimeConstraint constraint, diff --git a/packages/ack/lib/src/schemas/any_of_schema.dart b/packages/ack/lib/src/schemas/any_of_schema.dart index 4449be8d..1c73d578 100644 --- a/packages/ack/lib/src/schemas/any_of_schema.dart +++ b/packages/ack/lib/src/schemas/any_of_schema.dart @@ -12,6 +12,10 @@ final class AnyOfSchema extends AckSchema with FluentSchema { final List schemas; + /// Creates a low-level union from an immutable, non-empty list of [schemas]. + /// + /// Prefer `Ack.anyOf`, which validates and snapshots caller-owned lists. + /// Direct callers must not mutate [schemas] after construction. const AnyOfSchema( this.schemas, { super.isNullable, diff --git a/packages/ack/lib/src/schemas/codec_schema.dart b/packages/ack/lib/src/schemas/codec_schema.dart index 94592308..9ac8b54e 100644 --- a/packages/ack/lib/src/schemas/codec_schema.dart +++ b/packages/ack/lib/src/schemas/codec_schema.dart @@ -24,19 +24,25 @@ final class CodecSchema final Runtime Function(Object value) _decoder; final Object Function(Runtime value)? _encoder; + final Object _decoderIdentity; + final Object? _encoderIdentity; CodecSchema._({ required this.inputSchema, required this.outputSchema, required Runtime Function(Object value) decoder, required Object Function(Runtime value)? encoder, + required Object decoderIdentity, + required Object? encoderIdentity, super.isNullable, super.isOptional, super.description, super.constraints, super.refinements, }) : _decoder = decoder, - _encoder = encoder; + _encoder = encoder, + _decoderIdentity = decoderIdentity, + _encoderIdentity = encoderIdentity; /// Creates a codec while preserving the input schema's runtime type. static CodecSchema create< @@ -59,6 +65,8 @@ final class CodecSchema outputSchema: outputSchema, decoder: (value) => decoder(value as InputRuntime), encoder: encoder, + decoderIdentity: decoder, + encoderIdentity: encoder, isNullable: isNullable, isOptional: isOptional, description: description, @@ -160,6 +168,8 @@ final class CodecSchema outputSchema: outputSchema, decoder: _decoder, encoder: _encoder, + decoderIdentity: _decoderIdentity, + encoderIdentity: _encoderIdentity, isNullable: isNullable, isOptional: isOptional, description: description, @@ -181,6 +191,8 @@ final class CodecSchema outputSchema: outputSchema, decoder: _decoder, encoder: _encoder, + decoderIdentity: _decoderIdentity, + encoderIdentity: _encoderIdentity, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, @@ -196,7 +208,9 @@ final class CodecSchema return baseFieldsEqual(other) && inputSchema == other.inputSchema && - outputSchema == other.outputSchema; + outputSchema == other.outputSchema && + _decoderIdentity == other._decoderIdentity && + _encoderIdentity == other._encoderIdentity; } @override @@ -206,6 +220,11 @@ final class CodecSchema SchemaType get schemaType => inputSchema.schemaType; @override - int get hashCode => - Object.hash(baseFieldsHashCode, inputSchema, outputSchema); + int get hashCode => Object.hash( + baseFieldsHashCode, + inputSchema, + outputSchema, + _decoderIdentity, + _encoderIdentity, + ); } diff --git a/packages/ack/lib/src/schemas/enum_schema.dart b/packages/ack/lib/src/schemas/enum_schema.dart index 2a5c1dba..35af5e63 100644 --- a/packages/ack/lib/src/schemas/enum_schema.dart +++ b/packages/ack/lib/src/schemas/enum_schema.dart @@ -7,6 +7,11 @@ final class EnumSchema extends AckSchema with FluentSchema> { final List values; + /// Creates a low-level enum schema from an immutable, non-empty set of + /// uniquely named [values]. + /// + /// Prefer `Ack.enumValues`, which validates and snapshots caller-owned lists. + /// Direct callers must not mutate [values] after construction. const EnumSchema({ required this.values, super.isNullable, diff --git a/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart b/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart index 9a232fac..cf097596 100644 --- a/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart +++ b/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart @@ -115,6 +115,9 @@ extension StringSchemaExtensions on StringSchema { /// Adds a constraint that the string must be a valid IP address. /// If [version] is provided, it must be 4 or 6. StringSchema ip({int? version}) { + if (version != null && version != 4 && version != 6) { + throw ArgumentError.value(version, 'version', 'Must be 4 or 6.'); + } return withConstraint(StringIpConstraint(version: version)); } @@ -127,18 +130,24 @@ extension StringSchemaExtensions on StringSchema { /// Trims leading and trailing whitespace from the string before validation. /// Returns a one-way codec that applies String.trim() to the input. CodecSchema trim() { - return transform((s) => s.trim()); + return transform(_trimString); } /// Converts the string to lowercase after validation. /// Returns a one-way codec that applies String.toLowerCase() to the input. CodecSchema toLowerCase() { - return transform((s) => s.toLowerCase()); + return transform(_lowercaseString); } /// Converts the string to uppercase after validation. /// Returns a one-way codec that applies String.toUpperCase() to the input. CodecSchema toUpperCase() { - return transform((s) => s.toUpperCase()); + return transform(_uppercaseString); } } + +String _trimString(String value) => value.trim(); + +String _lowercaseString(String value) => value.toLowerCase(); + +String _uppercaseString(String value) => value.toUpperCase(); diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index 556350b9..da87c880 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -21,6 +21,7 @@ final class LazySchema final int maxDepth; final AckSchema Function() _builder; + final Object _recursionToken; late final AckSchema _target = _builder(); @@ -33,7 +34,25 @@ final class LazySchema super.description, super.constraints, super.refinements, + }) : _recursionToken = Object() { + _validateMaxDepth(maxDepth); + } + + LazySchema._copy( + this.name, + this._builder, + this._recursionToken, { + this.maxDepth = defaultMaxDepth, + super.isNullable, + super.isOptional, + super.description, + super.constraints, + super.refinements, }) { + _validateMaxDepth(maxDepth); + } + + static void _validateMaxDepth(int maxDepth) { if (maxDepth < 1) { throw ArgumentError.value(maxDepth, 'maxDepth', 'Must be >= 1.'); } @@ -56,10 +75,12 @@ final class LazySchema final nullResult = handleNullInput(value, context); if (nullResult != null) return nullResult; - final depthResult = _checkMaxDepth(context); + final recursionContext = _enterRecursion(value, context); + final depthResult = _checkMaxDepth(recursionContext); if (depthResult != null) return depthResult; - final result = _target.parseWithContext(value, context); + final target = _target; + final result = target.parseWithContext(value, recursionContext); if (result.isFail) return SchemaResult.fail(result.getError()); final runtime = result.getOrNull(); @@ -77,10 +98,12 @@ final class LazySchema final nullResult = handleNullInput(value, context); if (nullResult != null) return nullResult; - final depthResult = _checkMaxDepth(context); + final recursionContext = _enterRecursion(value, context); + final depthResult = _checkMaxDepth(recursionContext); if (depthResult != null) return depthResult; - final result = _target.validateRuntimeWithContext(value, context); + final target = _target; + final result = target.validateRuntimeWithContext(value, recursionContext); if (result.isFail) return SchemaResult.fail(result.getError()); final runtime = result.getOrNull(); @@ -95,19 +118,34 @@ final class LazySchema Runtime value, SchemaContext context, ) { - final depthResult = _checkMaxDepth(context); + final recursionContext = _enterRecursion(value, context); + final depthResult = _checkMaxDepth(recursionContext); if (depthResult != null) return depthResult; final ownChecked = applyConstraintsAndRefinements(value, context); if (ownChecked.isFail) return SchemaResult.fail(ownChecked.getError()); - return _target.encodeWithContext(ownChecked.getOrThrow()!, context); + final target = _target; + return target.encodeWithContext(ownChecked.getOrThrow()!, recursionContext); + } + + SchemaContext _enterRecursion(Object? value, SchemaContext context) { + return _LazyRecursionContext( + name: name, + owner: this as AnyAckSchema, + recursionToken: _recursionToken, + value: value, + parent: context, + ); } SchemaResult? _checkMaxDepth(SchemaContext context) { var depth = 0; for (SchemaContext? c = context; c != null; c = c.parent) { - if (identical(c.schema, this)) depth++; + if (c is _LazyRecursionContext && + identical(c.recursionToken, _recursionToken)) { + depth++; + } } if (depth <= maxDepth) return null; @@ -134,9 +172,10 @@ final class LazySchema List>? refinements, int? maxDepth, }) { - return LazySchema( + return LazySchema._copy( name, _builder, + _recursionToken, maxDepth: maxDepth ?? this.maxDepth, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, @@ -178,6 +217,28 @@ final class LazySchema } } +/// Marks one active lazy invocation without conflating it with structural +/// child contexts created by object, list, union, or wrapper schemas. +final class _LazyRecursionContext extends SchemaContext { + _LazyRecursionContext({ + required String name, + required this.owner, + required this.recursionToken, + required Object? value, + required SchemaContext parent, + }) : super( + name: name, + schema: owner, + value: value, + parent: parent, + pathSegment: '', + operation: parent.operation, + ); + + final AnyAckSchema owner; + final Object recursionToken; +} + final class _LazyMaxDepthConstraint extends Constraint { _LazyMaxDepthConstraint(this.maxDepth) : super( diff --git a/packages/ack/lib/src/schemas/list_schema.dart b/packages/ack/lib/src/schemas/list_schema.dart index 3bd083cd..a0848e0d 100644 --- a/packages/ack/lib/src/schemas/list_schema.dart +++ b/packages/ack/lib/src/schemas/list_schema.dart @@ -21,7 +21,7 @@ final class ListSchema super.constraints, super.refinements, }) { - if (itemSchema.isNullable) { + if (itemSchema.acceptsNull) { throw ArgumentError.value( itemSchema, 'itemSchema', diff --git a/packages/ack/lib/src/schemas/object_schema.dart b/packages/ack/lib/src/schemas/object_schema.dart index d3070415..a2f8253e 100644 --- a/packages/ack/lib/src/schemas/object_schema.dart +++ b/packages/ack/lib/src/schemas/object_schema.dart @@ -33,7 +33,7 @@ final class ObjectSchema extends AckSchema super.description, super.constraints, super.refinements, - }) : properties = properties ?? const {}; + }) : properties = Map.unmodifiable(properties ?? const {}); @override @protected diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 313c4f83..148cd75d 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -79,6 +79,12 @@ abstract class AckSchema { Iterable get _refinementsForEquality => _refinements; + /// Creates the shared configuration for a concrete schema. + /// + /// Direct concrete-schema constructors retain [constraints] and [refinements] + /// without copying so they can remain `const`. Prefer the `Ack` factories and + /// fluent methods, which own the collections they create; callers using a + /// concrete constructor must not mutate supplied collections afterward. const AckSchema({ this.isNullable = false, this.isOptional = false, @@ -137,7 +143,23 @@ abstract class AckSchema { Runtime value, SchemaContext context, ) { - final constraintViolations = _checkConstraints(value); + final constraintViolations = []; + for (final constraint in _constraints) { + if (constraint is! Validator) continue; + try { + final violation = constraint.validate(value); + if (violation != null) constraintViolations.add(violation); + } catch (error, stackTrace) { + return SchemaResult.fail( + SchemaValidationError( + message: 'Constraint "${constraint.constraintKey}" threw: $error', + context: context, + cause: error, + stackTrace: stackTrace, + ), + ); + } + } if (constraintViolations.isNotEmpty) { return SchemaResult.fail( SchemaConstraintsError( @@ -149,23 +171,22 @@ abstract class AckSchema { return _runRefinements(value, context); } - List _checkConstraints(Runtime value) { - if (_constraints.isEmpty) return const []; - final errors = []; - for (final constraint in _constraints) { - if (constraint is Validator) { - final error = constraint.validate(value); - if (error != null) { - errors.add(error); - } - } - } - return errors; - } - SchemaResult _runRefinements(Runtime value, SchemaContext context) { for (final refinement in _refinements) { - if (!refinement.validate(value)) { + final bool isValid; + try { + isValid = refinement.validate(value); + } catch (error, stackTrace) { + return SchemaResult.fail( + SchemaValidationError( + message: 'Refinement threw: $error', + context: context, + cause: error, + stackTrace: stackTrace, + ), + ); + } + if (!isValid) { return SchemaResult.fail( SchemaValidationError(message: refinement.message, context: context), ); @@ -337,7 +358,18 @@ abstract class AckSchema { debugName: debugName, operation: SchemaOperation.parse, ); - return parseWithContext(value, context); + try { + return parseWithContext(value, context); + } catch (error, stackTrace) { + return SchemaResult.fail( + SchemaValidationError( + message: 'Validation threw: $error', + context: context, + cause: error, + stackTrace: stackTrace, + ), + ); + } } /// Parses and validates a value, then maps the validated value to [TOut]. @@ -522,6 +554,17 @@ class _ConstraintMessageOverride extends Constraint } return const {}; } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _ConstraintMessageOverride && + inner == other.inner && + customMessage == other.customMessage; + } + + @override + int get hashCode => Object.hash(runtimeType, inner, customMessage); } /// Returns [value] if it is composed entirely of JSON-safe primitives diff --git a/packages/ack/lib/src/utils/collection_utils.dart b/packages/ack/lib/src/utils/collection_utils.dart index f713518a..059060ba 100644 --- a/packages/ack/lib/src/utils/collection_utils.dart +++ b/packages/ack/lib/src/utils/collection_utils.dart @@ -34,16 +34,17 @@ bool deepEquals(Object? a, Object? b) { // Handle Sets (order-independent comparison) if (a is Set && b is Set) { if (a.length != b.length) return false; - // For each element in a, check if b contains an equal element + final unmatched = b.toList(); for (final itemA in a) { - var found = false; - for (final itemB in b) { - if (deepEquals(itemA, itemB)) { - found = true; + var matchIndex = -1; + for (var i = 0; i < unmatched.length; i++) { + if (deepEquals(itemA, unmatched[i])) { + matchIndex = i; break; } } - if (!found) return false; + if (matchIndex == -1) return false; + unmatched.removeAt(matchIndex); } return true; diff --git a/packages/ack/lib/src/validation/schema_result.dart b/packages/ack/lib/src/validation/schema_result.dart index cce609ab..373084ba 100644 --- a/packages/ack/lib/src/validation/schema_result.dart +++ b/packages/ack/lib/src/validation/schema_result.dart @@ -36,9 +36,11 @@ sealed class SchemaResult { }; } - /// Returns the contained value if this result is successful; otherwise, returns `null`. - /// The returned value itself can be `null` if `T` is nullable (e.g. `T = String?`) - /// and the validation resulted in `Ok(null)`. + /// Returns the contained value if this result is successful; otherwise, + /// returns `null`. + /// + /// Although [T] is non-nullable, the payload can be `null` to represent a + /// nullable schema that validated `null`. T? getOrNull() { return switch (this) { Ok(value: final v) => v, @@ -93,7 +95,7 @@ sealed class SchemaResult { } /// Executes [action] if this result is successful. - /// The [value] passed to the action can be `null` if `T` is nullable. + /// The [value] passed to the action can be `null` for nullable schemas. void ifOk(void Function(T? value) action) { if (this case Ok(value: final v)) { action(v); diff --git a/packages/ack/melos_ack.iml b/packages/ack/melos_ack.iml deleted file mode 100644 index 389d07a1..00000000 --- a/packages/ack/melos_ack.iml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/ack/test/constraints/string_ip_constraint_test.dart b/packages/ack/test/constraints/string_ip_constraint_test.dart index 9227e2d7..086cf955 100644 --- a/packages/ack/test/constraints/string_ip_constraint_test.dart +++ b/packages/ack/test/constraints/string_ip_constraint_test.dart @@ -78,13 +78,20 @@ void main() { test('accepts valid IPv6 addresses - link-local', () { expect(ipv6Constraint.isValid('fe80::1'), isTrue); + expect(ipv6Constraint.isValid('FE80::1'), isTrue); expect(ipv6Constraint.isValid('fe80::204:61ff:fe9d:f156'), isTrue); }); + test('rejects scoped notation outside JSON Schema IPv6 format', () { + expect(ipv6Constraint.isValid('fe80::1%eth0'), isFalse); + expect(ipv6Constraint.isValid('ff02::1%eth0'), isFalse); + }); + test('rejects invalid IPv6 addresses', () { expect(ipv6Constraint.isValid(''), isFalse); expect(ipv6Constraint.isValid('not an ipv6'), isFalse); expect(ipv6Constraint.isValid('192.168.1.1'), isFalse); // IPv4 + expect(ipv6Constraint.isValid('prefix ::1 suffix'), isFalse); }); test('rejects IPv4 addresses when version is 6', () { @@ -178,6 +185,10 @@ void main() { expect(ipSchema.safeParse('::1').isOk, isTrue); expect(ipSchema.safeParse('invalid').isOk, isFalse); }); + + test('rejects unsupported IP versions at construction', () { + expect(() => Ack.string().ip(version: 5), throwsArgumentError); + }); }); }); } diff --git a/packages/ack/test/documentation/error_recovery_examples_test.dart b/packages/ack/test/documentation/error_recovery_examples_test.dart index 53ee235a..3a7cecfe 100644 --- a/packages/ack/test/documentation/error_recovery_examples_test.dart +++ b/packages/ack/test/documentation/error_recovery_examples_test.dart @@ -283,9 +283,6 @@ void main() { String formatUserFriendlyError(SchemaError error) { final message = error.message.toLowerCase(); - // Debug: print the actual message to understand its format - // print('Debug: Error message = "$message"'); - // Convert technical messages to user-friendly ones if (message.contains('email') && message.contains('valid')) { return 'Please enter a valid email address (e.g., user@example.com)'; diff --git a/packages/ack/test/reference_semantics_test.dart b/packages/ack/test/reference_semantics_test.dart new file mode 100644 index 00000000..e5130bfa --- /dev/null +++ b/packages/ack/test/reference_semantics_test.dart @@ -0,0 +1,183 @@ +import 'package:ack/ack.dart'; +import 'package:ack/src/helpers.dart'; +import 'package:ack/src/schemas/schema.dart' show AnyAckSchema; +import 'package:test/test.dart'; + +enum _Status { active, disabled } + +void main() { + group('canonical factory invariants', () { + test('object schemas snapshot caller-owned property maps', () { + final properties = {'name': Ack.string()}; + final schema = Ack.object(properties); + + properties.clear(); + + expect(schema.safeParse({'name': 'Ada'}).isOk, isTrue); + expect(schema.safeParse({}).isFail, isTrue); + expect(schema.toJsonSchema()['properties'], contains('name')); + }); + + test('union and enum schemas snapshot caller-owned lists', () { + final branches = [Ack.string(), Ack.integer()]; + final union = Ack.anyOf(branches); + final values = [_Status.active, _Status.disabled]; + final enumSchema = Ack.enumValues(values); + + branches.clear(); + values.clear(); + + expect(union.safeParse('value').isOk, isTrue); + expect(enumSchema.safeParse('active').isOk, isTrue); + }); + + test('rejects empty unions and empty or duplicate enums', () { + expect(() => Ack.anyOf([]), throwsArgumentError); + expect(() => Ack.enumValues(<_Status>[]), throwsArgumentError); + expect( + () => Ack.enumValues([_Status.active, _Status.active]), + throwsArgumentError, + ); + expect(() => Ack.enumString([]), throwsArgumentError); + expect(() => Ack.enumString(['same', 'same']), throwsArgumentError); + }); + + test('rejects invalid JSON Schema constraint arguments', () { + expect(() => Ack.string().minLength(-1), throwsArgumentError); + expect(() => Ack.string().maxLength(-1), throwsArgumentError); + expect(() => Ack.string().length(-1), throwsArgumentError); + expect(() => Ack.list(Ack.string()).minItems(-1), throwsArgumentError); + expect(() => Ack.list(Ack.string()).maxItems(-1), throwsArgumentError); + expect(() => Ack.double().min(double.infinity), throwsArgumentError); + expect(() => Ack.double().max(double.nan), throwsArgumentError); + }); + }); + + group('portable schema semantics', () { + test('preserves intersecting constraints with duplicate keywords', () { + final schema = Ack.string().minLength(10).minLength(5); + + expect(schema.safeParse('1234567').isFail, isTrue); + expect(schema.toJsonSchema(), { + 'type': 'string', + 'minLength': 10, + 'allOf': [ + {'minLength': 5}, + ], + }); + }); + + test('keeps intersecting constraints inside nullable branches', () { + final schema = Ack.string().nullable().minLength(10).minLength(5); + + expect(schema.toJsonSchema(), { + 'anyOf': [ + { + 'type': 'string', + 'minLength': 10, + 'allOf': [ + {'minLength': 5}, + ], + }, + {'type': 'null'}, + ], + }); + }); + + test('preserves constraints across codec layers', () { + final outerConstraint = Ack.string().minLength(5).constraints.single; + final schema = Ack.string() + .minLength(10) + .transform((value) => value) + .withConstraint(outerConstraint); + + expect(schema.safeParse('1234567').isFail, isTrue); + expect(schema.toJsonSchema(), { + 'type': 'string', + 'minLength': 10, + 'x-transformed': true, + 'allOf': [ + {'minLength': 5}, + ], + }); + }); + + test('exports exact list lengths as item-count constraints', () { + final schema = Ack.list(Ack.string()).exactLength(2); + + expect(schema.safeParse(['one']).isFail, isTrue); + expect(schema.toJsonSchema(), { + 'type': 'array', + 'items': {'type': 'string'}, + 'minItems': 2, + 'maxItems': 2, + }); + }); + }); + + group('total validation', () { + test('preserves nested context when a constraint throws', () { + final schema = Ack.object({ + 'name': Ack.string().constrain(const _ThrowingConstraint()), + }); + + final result = schema.safeParse({'name': 'Ada'}); + + expect(result.isFail, isTrue); + final nested = result.getError() as SchemaNestedError; + final error = nested.errors.single; + expect(error, isA()); + expect(error.path, '#/name'); + expect(error.cause, isA()); + }); + }); + + group('behavioral equality', () { + test('deep set comparison consumes each matching element once', () { + final repeated = { + [1], + [1], + }; + final distinct = { + [1], + [2], + }; + + expect(deepEquals(repeated, distinct), isFalse); + expect(deepEquals(distinct, repeated), isFalse); + }); + + test('different codec callbacks produce unequal schemas', () { + int length(String value) => value.length; + int constant(String _) => 1; + + final first = Ack.string().transform(length); + final second = Ack.string().transform(constant); + + expect(first, isNot(equals(second))); + }); + + test('different constraint messages produce unequal schemas', () { + final constraint = Ack.string().minLength(2).constraints.single; + final first = Ack.string().constrain(constraint, message: 'first'); + final second = Ack.string().constrain(constraint, message: 'second'); + + expect(first, isNot(equals(second))); + }); + }); +} + +final class _ThrowingConstraint extends Constraint + with Validator { + const _ThrowingConstraint() + : super( + constraintKey: 'throwing_test_constraint', + description: 'Throws for test coverage.', + ); + + @override + bool isValid(String value) => throw StateError('constraint exploded'); + + @override + String buildMessage(String value) => 'unreachable'; +} diff --git a/packages/ack/test/schemas/any_of_null_and_default_test.dart b/packages/ack/test/schemas/any_of_null_and_default_test.dart index 4e7c634a..d9976595 100644 --- a/packages/ack/test/schemas/any_of_null_and_default_test.dart +++ b/packages/ack/test/schemas/any_of_null_and_default_test.dart @@ -167,11 +167,8 @@ void main() { }); group('AnyOf Edge Cases', () { - test('should handle empty schemas list gracefully', () { - final schema = Ack.anyOf([]); - - final result = schema.safeParse('anything'); - expect(result.isFail, isTrue, reason: 'Empty anyOf should always fail'); + test('should reject an empty schemas list', () { + expect(() => Ack.anyOf([]), throwsArgumentError); }); test('should handle single schema in anyOf', () { diff --git a/packages/ack/test/schemas/core_schema_test.dart b/packages/ack/test/schemas/core_schema_test.dart index 60ea92f4..c5155081 100644 --- a/packages/ack/test/schemas/core_schema_test.dart +++ b/packages/ack/test/schemas/core_schema_test.dart @@ -196,6 +196,10 @@ void main() { group('ListSchema', () { test('should reject nullable item schemas at construction', () { expect(() => Ack.list(Ack.string().nullable()), throwsArgumentError); + expect( + () => Ack.list(Ack.anyOf([Ack.string().nullable(), Ack.integer()])), + throwsArgumentError, + ); }); }); }); diff --git a/packages/ack/test/schemas/datetime_validation_test.dart b/packages/ack/test/schemas/datetime_validation_test.dart index f477d50b..5ffdbec8 100644 --- a/packages/ack/test/schemas/datetime_validation_test.dart +++ b/packages/ack/test/schemas/datetime_validation_test.dart @@ -97,6 +97,60 @@ void main() { throwsA(isA()), ); }); + + test('rejects calendar and time components normalized by DateTime', () { + final schema = Ack.datetime(); + + expect(schema.safeParse('2025-02-30T10:30:00Z').isFail, isTrue); + expect(schema.safeParse('2025-06-15T24:00:00Z').isFail, isTrue); + expect(schema.safeParse('2025-06-15T10:60:00Z').isFail, isTrue); + expect(schema.safeParse('2025-06-15T10:30:60Z').isFail, isTrue); + expect(schema.safeParse('2025-06-15T10:30:00+24:00').isFail, isTrue); + }); + + test('accepts fractional seconds', () { + final schema = Ack.datetime(); + + expect(schema.safeParse('2025-06-15T10:30:00.123Z').isOk, isTrue); + expect(schema.safeParse('2025-06-15T10:30:00.123456Z').isOk, isTrue); + }); + + test('string validation accepts announced leap seconds', () { + final schema = Ack.string().datetime(); + + final utc = schema.safeParse('1990-12-31T23:59:60Z'); + final offset = schema.safeParse('1990-12-31T15:59:60-08:00'); + + expect(utc.isOk, isTrue); + expect(offset.isOk, isTrue); + expect(utc.getOrThrow(), '1990-12-31T23:59:60Z'); + expect(offset.getOrThrow(), '1990-12-31T15:59:60-08:00'); + }); + + test('datetime codec rejects unrepresentable leap seconds', () { + final schema = Ack.datetime(); + + for (final value in [ + '1990-12-31T23:59:60Z', + '1990-12-31T15:59:60-08:00', + ]) { + final result = schema.safeParse(value); + expect(result.isFail, isTrue); + expect( + result.getError().message, + contains('Dart DateTime cannot represent leap seconds'), + ); + } + + expect(schema.safeParse('1991-01-01T00:00:00Z').isOk, isTrue); + }); + + test('string validation rejects unannounced leap seconds', () { + final schema = Ack.string().datetime(); + + expect(schema.safeParse('2025-06-30T23:59:60Z').isFail, isTrue); + expect(schema.safeParse('2025-12-31T23:59:60Z').isFail, isTrue); + }); }); group('.min() Constraint', () { diff --git a/packages/ack/test/schemas/extensions/numeric_extensions_test.dart b/packages/ack/test/schemas/extensions/numeric_extensions_test.dart index cf180187..b5e86ab5 100644 --- a/packages/ack/test/schemas/extensions/numeric_extensions_test.dart +++ b/packages/ack/test/schemas/extensions/numeric_extensions_test.dart @@ -232,6 +232,34 @@ void main() { ); }, ); + + test('rejects negative and non-finite divisors', () { + expect(() => DoubleSchema().multipleOf(-0.5), throwsArgumentError); + expect( + () => DoubleSchema().multipleOf(double.infinity), + throwsArgumentError, + ); + expect( + () => DoubleSchema().multipleOf(double.nan), + throwsArgumentError, + ); + }); + + test('handles floating-point and tiny divisors proportionally', () { + final decimal = DoubleSchema().multipleOf(0.1); + expect(decimal.safeParse(0.6).isOk, isTrue); + + final tiny = DoubleSchema().multipleOf(1e-12); + expect(tiny.safeParse(2e-12).isOk, isTrue); + expect(tiny.safeParse(1.5e-12).isFail, isTrue); + }); + + test('rejects large values that are only near a multiple', () { + final schema = DoubleSchema().multipleOf(1e12); + + expect(schema.safeParse(2e12).isOk, isTrue); + expect(schema.safeParse(1e12 + 1).isFail, isTrue); + }); }); }); diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index 391baedf..8a30ddce 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -388,6 +388,73 @@ void main() { expect(capped.hashCode, matchingCap.hashCode); expect(capped, isNot(uncapped)); }); + + test('terminates direct and indirect lazy aliases', () { + late final LazySchema direct; + direct = Ack.lazy('Direct', () => direct, maxDepth: 1); + + late final LazySchema first; + late final LazySchema second; + first = Ack.lazy('First', () => second, maxDepth: 1); + second = Ack.lazy('Second', () => first, maxDepth: 1); + + for (final result in [direct.safeParse('x'), first.safeParse('x')]) { + expect(result.isFail, isTrue); + final error = result.getError(); + expect(error, isA()); + expect(error.message, contains('Maximum recursion depth')); + } + }); + + test('terminates lazy aliases hidden behind wrappers', () { + late final LazySchema defaultWrapped; + defaultWrapped = Ack.lazy( + 'DefaultWrapped', + () => defaultWrapped.withDefault('fallback'), + maxDepth: 1, + ); + + late final LazySchema codecWrapped; + codecWrapped = Ack.lazy( + 'CodecWrapped', + () => codecWrapped.codec( + decode: (value) => value, + encode: (value) => value, + ), + maxDepth: 1, + ); + + for (final schema in [defaultWrapped, codecWrapped]) { + for (final result in [ + schema.safeParse('value'), + schema.safeEncode('value'), + ]) { + expect(result.isFail, isTrue); + final error = result.getError(); + expect(error, isA()); + expect(error.message, contains('Maximum recursion depth')); + } + } + }); + + test('terminates lazy aliases hidden behind fluent copies', () { + late final LazySchema optionalAlias; + optionalAlias = Ack.lazy( + 'OptionalAlias', + () => optionalAlias.optional(), + maxDepth: 1, + ); + + for (final result in [ + optionalAlias.safeParse('value'), + optionalAlias.safeEncode('value'), + ]) { + expect(result.isFail, isTrue); + final error = result.getError(); + expect(error, isA()); + expect(error.message, contains('Maximum recursion depth')); + } + }); }); } diff --git a/packages/ack/test/schemas/refine_test.dart b/packages/ack/test/schemas/refine_test.dart index 2571521c..9dce726f 100644 --- a/packages/ack/test/schemas/refine_test.dart +++ b/packages/ack/test/schemas/refine_test.dart @@ -97,5 +97,22 @@ void main() { 'Passwords do not match', ); }); + + test('safeParse converts refinement exceptions to failures', () { + final schema = Ack.string().refine( + (_) => throw StateError('refinement exploded'), + ); + + final result = schema.safeParse('value', debugName: 'explosiveField'); + + expect(result.isFail, isTrue); + final error = result.getError(); + expect(error, isA()); + expect(error.message, contains('Refinement threw')); + expect(error.name, 'explosiveField'); + expect(error.value, 'value'); + expect(error.cause, isA()); + expect(error.stackTrace, isNotNull); + }); }); } diff --git a/packages/ack/test/schemas/schema_equality_test.dart b/packages/ack/test/schemas/schema_equality_test.dart index ce93318e..73e29577 100644 --- a/packages/ack/test/schemas/schema_equality_test.dart +++ b/packages/ack/test/schemas/schema_equality_test.dart @@ -197,6 +197,25 @@ void main() { expect(a, equals(b)); expect(a.hashCode, equals(b.hashCode)); }); + + test('equivalent built-in transformers are equal', () { + expect(Ack.string().trim(), equals(Ack.string().trim())); + expect(Ack.string().toLowerCase(), equals(Ack.string().toLowerCase())); + expect(Ack.string().toUpperCase(), equals(Ack.string().toUpperCase())); + }); + }); + + group('Built-in CodecSchema', () { + test('equivalent factory schemas are equal', () { + expect(Ack.date(), equals(Ack.date())); + expect(Ack.datetime(), equals(Ack.datetime())); + expect(Ack.uri(), equals(Ack.uri())); + expect(Ack.duration(), equals(Ack.duration())); + expect( + Ack.enumCodec(TestColor.values), + equals(Ack.enumCodec(TestColor.values)), + ); + }); }); group('Cross-type inequality', () { diff --git a/packages/ack/test/schemas/transform_optional_nullable_test.dart b/packages/ack/test/schemas/transform_optional_nullable_test.dart index d88860fb..edd6d8c1 100644 --- a/packages/ack/test/schemas/transform_optional_nullable_test.dart +++ b/packages/ack/test/schemas/transform_optional_nullable_test.dart @@ -1,25 +1,17 @@ import 'package:ack/ack.dart'; import 'package:test/test.dart'; -/// Test to verify whether .optional().nullable().transform() works correctly. -/// This test investigates the skipped test in optional_nullable_semantics_test.dart:134-135 void main() { group('Transform with optional().nullable() combination', () { test( - 'should handle .optional().nullable().transform() - missing field', + 'should handle .optional().nullable().transform() - non-null value', () { - final schema = Ack.string().optional().nullable().transform((val) { - return val.toUpperCase(); - }); + final schema = Ack.string().optional().nullable().transform( + (value) => value.toUpperCase(), + ); final result = schema.safeParse('hello'); - print('Result isOk: ${result.isOk}'); - print('Result value: ${result.getOrNull()}'); - if (result.isFail) { - print('Error: ${result.getError()}'); - } - expect( result.isOk, isTrue, @@ -30,18 +22,12 @@ void main() { ); test('should handle .optional().nullable().transform() - null value', () { - final schema = Ack.string().optional().nullable().transform((val) { - return val.toUpperCase(); - }); + final schema = Ack.string().optional().nullable().transform( + (value) => value.toUpperCase(), + ); final result = schema.safeParse(null); - print('Result isOk: ${result.isOk}'); - print('Result value: ${result.getOrNull()}'); - if (result.isFail) { - print('Error: ${result.getError()}'); - } - expect(result.isOk, isTrue, reason: 'Should handle null value'); // Null passes through without calling transformer expect(result.getOrNull(), isNull); @@ -52,40 +38,17 @@ void main() { () { final schema = Ack.object({ 'name': Ack.string(), - 'nickname': Ack.string().optional().nullable().transform((val) { - return val.toUpperCase(); - }), + 'nickname': Ack.string().optional().nullable().transform( + (value) => value.toUpperCase(), + ), }); - // Test 1: Missing field final result1 = schema.safeParse({'name': 'John'}); - print('Test 1 - Missing field:'); - print(' Result isOk: ${result1.isOk}'); - print(' Result value: ${result1.getOrNull()}'); - if (result1.isFail) { - print(' Error: ${result1.getError()}'); - } - - // Test 2: Null value final result2 = schema.safeParse({'name': 'John', 'nickname': null}); - print('Test 2 - Null value:'); - print(' Result isOk: ${result2.isOk}'); - print(' Result value: ${result2.getOrNull()}'); - if (result2.isFail) { - print(' Error: ${result2.getError()}'); - } - - // Test 3: Actual value final result3 = schema.safeParse({ 'name': 'John', 'nickname': 'Johnny', }); - print('Test 3 - Actual value:'); - print(' Result isOk: ${result3.isOk}'); - print(' Result value: ${result3.getOrNull()}'); - if (result3.isFail) { - print(' Error: ${result3.getError()}'); - } expect( result1.isOk, @@ -95,6 +58,8 @@ void main() { expect(result2.isOk, isTrue, reason: 'Should handle null value'); expect(result3.isOk, isTrue, reason: 'Should handle actual value'); + expect(result1.getOrNull(), {'name': 'John'}); + expect(result2.getOrNull(), {'name': 'John', 'nickname': null}); expect(result3.getOrNull()?['nickname'], equals('JOHNNY')); }, ); @@ -111,29 +76,18 @@ void main() { expect(result.getOrNull(), isNull); }); - test('order matters: .nullable().optional().transform()', () { - final schema = Ack.string().nullable().optional().transform((val) { - return val.toUpperCase(); - }); + test('supports .nullable().optional().transform()', () { + final schema = Ack.string().nullable().optional().transform( + (value) => value.toUpperCase(), + ); final result1 = schema.safeParse(null); - print('Nullable then Optional - null value:'); - print(' Result isOk: ${result1.isOk}'); - print(' Result value: ${result1.getOrNull()}'); - if (result1.isFail) { - print(' Error: ${result1.getError()}'); - } - final result2 = schema.safeParse('hello'); - print('Nullable then Optional - string value:'); - print(' Result isOk: ${result2.isOk}'); - print(' Result value: ${result2.getOrNull()}'); - if (result2.isFail) { - print(' Error: ${result2.getError()}'); - } expect(result1.isOk, isTrue); expect(result2.isOk, isTrue); + expect(result1.getOrNull(), isNull); + expect(result2.getOrNull(), 'HELLO'); }); }); } diff --git a/packages/ack_annotations/README.md b/packages/ack_annotations/README.md index 20c3205a..efcb99ac 100644 --- a/packages/ack_annotations/README.md +++ b/packages/ack_annotations/README.md @@ -38,7 +38,7 @@ plus `parse()` and `safeParse()` helpers. Generate the wrapper with: ```bash -dart run build_runner build --delete-conflicting-outputs +dart run build_runner build ``` ## Custom names diff --git a/packages/ack_annotations/melos_ack_annotations.iml b/packages/ack_annotations/melos_ack_annotations.iml deleted file mode 100644 index 389d07a1..00000000 --- a/packages/ack_annotations/melos_ack_annotations.iml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/ack_firebase_ai/README.md b/packages/ack_firebase_ai/README.md index f7531f1b..058f0b55 100644 --- a/packages/ack_firebase_ai/README.md +++ b/packages/ack_firebase_ai/README.md @@ -192,5 +192,5 @@ dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart From the workspace root, the same generator is available through Melos: ```bash -melos run firebase-ai-fixtures +dart run melos run firebase-ai-fixtures ``` diff --git a/packages/ack_firebase_ai/melos_ack_firebase_ai.iml b/packages/ack_firebase_ai/melos_ack_firebase_ai.iml deleted file mode 100644 index 9fc8ce79..00000000 --- a/packages/ack_firebase_ai/melos_ack_firebase_ai.iml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 2d157d7a..203651ce 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +### Changed + +* Use the current source_gen generation error type and keep diagnostic output in + the build pipeline instead of writing undeclared debug files. +* Reject direct and referenced nullable list element schemas during generation, + matching the runtime schema contract. +* Remove obsolete exploratory and golden-test utilities. + ## 1.0.1 * See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.1) for details. diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 5a6641fa..2aaaae77 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -84,7 +84,7 @@ validate through the union's effective branch. ## Build commands ```bash -dart run build_runner build --delete-conflicting-outputs +dart run build_runner build dart run build_runner watch ``` diff --git a/packages/ack_generator/build.yaml b/packages/ack_generator/build.yaml index 011742ef..55e4086b 100644 --- a/packages/ack_generator/build.yaml +++ b/packages/ack_generator/build.yaml @@ -5,11 +5,3 @@ builders: build_extensions: {".dart": [".g.dart"]} auto_apply: dependents build_to: source - required_inputs: [".dart"] - -# Global configuration for performance -global_options: - ack_generator|ack_generator: - options: - # Enable verbose logging for debugging - verbose: false diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart index 84fa4a06..03f4656c 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart @@ -143,7 +143,7 @@ class SchemaAstAnalyzer { // getParsedLibraryByElement returns a SomeParsedLibraryResult which might not have getElementDeclaration // We need to check if it's actually a ParsedLibraryResult if (parsedLibResult is! ParsedLibraryResult) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not get parsed library for "${element.name3}"', element: element, ); @@ -151,7 +151,7 @@ class SchemaAstAnalyzer { final declaration = parsedLibResult.getFragmentDeclaration(fragment); if (declaration == null || declaration.node is! VariableDeclaration) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not find variable declaration for "${element.name3}"', element: element, ); @@ -161,7 +161,7 @@ class SchemaAstAnalyzer { final initializer = varDecl.initializer; if (initializer == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Schema variable "${element.name3}" must have an initializer', element: element, ); @@ -189,7 +189,7 @@ class SchemaAstAnalyzer { return _withSchemaIdentity(model, element); } - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Schema variable "${element.name3}" must be initialized with a schema ' '(e.g., Ack.object({...}))', element: element, @@ -211,7 +211,7 @@ class SchemaAstAnalyzer { final parsedLibResult = session.getParsedLibraryByElement2(library); if (parsedLibResult is! ParsedLibraryResult) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not get parsed library for getter "${element.name3}"', element: element, ); @@ -219,7 +219,7 @@ class SchemaAstAnalyzer { final declaration = parsedLibResult.getFragmentDeclaration(fragment); if (declaration == null || declaration.node is! FunctionDeclaration) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not find getter declaration for "${element.name3}"', element: element, ); @@ -227,7 +227,7 @@ class SchemaAstAnalyzer { final getterDecl = declaration.node as FunctionDeclaration; if (!getterDecl.isGetter) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '"${element.name3}" is not a getter declaration', element: element, ); @@ -241,7 +241,7 @@ class SchemaAstAnalyzer { } else if (body is BlockFunctionBody) { final statements = body.block.statements; if (statements.length != 1 || statements.first is! ReturnStatement) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Schema getter "${element.name3}" must return a schema expression', element: element, todo: @@ -275,7 +275,7 @@ class SchemaAstAnalyzer { return _withSchemaIdentity(model, element); } - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Schema getter "${element.name3}" must return an Ack schema invocation or schema reference', element: element, todo: @@ -292,7 +292,7 @@ class SchemaAstAnalyzer { final resolved = _resolveSchemaReference(reference, element); if (resolved == null) { final referenceLabel = _formatSchemaReference(reference); - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not resolve schema alias "$variableName" ' 'to "$referenceLabel"', element: element, @@ -351,7 +351,7 @@ class SchemaAstAnalyzer { } if (baseInvocation == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Schema must be an Ack.xxx() method call (e.g., Ack.object(), Ack.string()) or a schema reference.', element: element, ); @@ -422,7 +422,7 @@ class SchemaAstAnalyzer { case 'list': final typeProvider = element.library2?.typeProvider; if (typeProvider == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not get type provider for library', element: element, ); @@ -508,7 +508,7 @@ class SchemaAstAnalyzer { ); break; default: - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Unsupported schema type for @AckType: Ack.$methodName(). ' 'Supported types: object, string, integer, double, boolean, list, literal, enumString, enumValues, uri, date, datetime, duration, discriminated', element: element, @@ -533,7 +533,7 @@ class SchemaAstAnalyzer { final resolved = _resolveSchemaReference(schemaReference, element); if (resolved == null) { final referenceLabel = _formatSchemaReference(schemaReference); - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not resolve schema reference "$referenceLabel" for "$variableName".', element: element, ); @@ -584,7 +584,7 @@ class SchemaAstAnalyzer { // Extract the properties map from the first argument final args = baseInvocation.argumentList.arguments; if (args.isEmpty) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.object() requires a properties map argument', element: element, ); @@ -592,7 +592,7 @@ class SchemaAstAnalyzer { final firstArg = args.first; if (firstArg is! SetOrMapLiteral) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.object() first argument must be a map literal', element: element, ); @@ -641,7 +641,7 @@ class SchemaAstAnalyzer { String? customTypeName, }) { if (isNullable) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...) cannot be nullable when used with @AckType.', element: element, todo: 'Remove `.nullable()` from the discriminated base schema.', @@ -658,7 +658,7 @@ class SchemaAstAnalyzer { if (name == 'discriminatorKey') { final expression = argument.expression; if (expression is! SimpleStringLiteral) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): `discriminatorKey` must be a string literal.', element: element, ); @@ -667,7 +667,7 @@ class SchemaAstAnalyzer { } else if (name == 'schemas') { final expression = argument.expression; if (expression is! SetOrMapLiteral) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): `schemas` must be a map literal.', element: element, ); @@ -677,7 +677,7 @@ class SchemaAstAnalyzer { // contexts (e.g., build_test / source_gen pipelines). if (expression.elements.isNotEmpty && expression.elements.first is! MapLiteralEntry) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): `schemas` must be a map literal.', element: element, ); @@ -688,7 +688,7 @@ class SchemaAstAnalyzer { final resolvedDiscriminatorKey = discriminatorKey; if (resolvedDiscriminatorKey == null || resolvedDiscriminatorKey.isEmpty) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): missing required `discriminatorKey` string literal.', element: element, ); @@ -696,13 +696,13 @@ class SchemaAstAnalyzer { final resolvedSchemasLiteral = schemasLiteral; if (resolvedSchemasLiteral == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): missing required `schemas` map literal.', element: element, ); } if (resolvedSchemasLiteral.elements.isEmpty) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): `schemas` must contain at least one branch.', element: element, ); @@ -713,7 +713,7 @@ class SchemaAstAnalyzer { for (final schemaEntry in resolvedSchemasLiteral.elements) { if (schemaEntry is! MapLiteralEntry) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): `schemas` must contain key/value map entries.', element: element, ); @@ -721,7 +721,7 @@ class SchemaAstAnalyzer { final keyExpression = schemaEntry.key; if (keyExpression is! SimpleStringLiteral) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): discriminator values in `schemas` must be string literals.', element: element, ); @@ -729,7 +729,7 @@ class SchemaAstAnalyzer { final discriminatorValue = keyExpression.value; if (subtypeNames.containsKey(discriminatorValue)) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): duplicate discriminator value "$discriminatorValue".', element: element, ); @@ -737,7 +737,7 @@ class SchemaAstAnalyzer { final branchReference = _extractSchemaReference(schemaEntry.value); if (branchReference == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): branch "$discriminatorValue" must reference a top-level schema variable/getter.', element: element, todo: @@ -747,42 +747,42 @@ class SchemaAstAnalyzer { final resolvedBranch = _resolveSchemaReference(branchReference, element); if (resolvedBranch == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): could not resolve branch reference "${_formatSchemaReference(branchReference)}".', element: element, ); } if (!resolvedBranch.hasAckTypeAnnotation) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be annotated with @AckType.', element: element, ); } if (resolvedBranch.modelInfo.representationType != kMapType) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be an object schema (Map representation).', element: element, ); } if (resolvedBranch.modelInfo.isNullableSchema) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" cannot be nullable.', element: element, ); } if (resolvedBranch.sourceLibraryUri != currentLibraryUri) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be declared in the same library as "$variableName".', element: element, ); } if (resolvedBranch.modelInfo.isDiscriminatedBase) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" is itself a discriminated base. ' 'Nested discriminated unions are not supported.', element: element, @@ -800,7 +800,7 @@ class SchemaAstAnalyzer { ); if (discriminatorCompatibilityError != null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" ' '$discriminatorCompatibilityError', element: element, @@ -1255,7 +1255,7 @@ class SchemaAstAnalyzer { // Key should be a string literal if (key is! SimpleStringLiteral) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Map keys must be string literals in schema definition', element: element, ); @@ -1347,7 +1347,7 @@ class SchemaAstAnalyzer { final typeProvider = library?.typeProvider; if (typeProvider == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not get type provider for library', element: element, ); @@ -1355,7 +1355,7 @@ class SchemaAstAnalyzer { final resolvedReference = _resolveSchemaReference(schemaReference, element); if (resolvedReference == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not resolve schema reference "$schemaVarName" for field ' '"$fieldName".', element: element, @@ -1385,7 +1385,7 @@ class SchemaAstAnalyzer { resolvedReference.hasAckTypeAnnotation && !hasTransformOverride; final isObjectRepresentation = representationType == kMapType; if (isObjectRepresentation && !hasTypedReference) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Field "$fieldName" references object schema "$schemaVarName" ' 'without @AckType. This would fall back to Map.', element: element, @@ -1444,7 +1444,7 @@ class SchemaAstAnalyzer { if (baseInvocation == null) { if (chain.wasTruncated) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Field "$fieldName" schema method chain exceeded max depth of 20. ' '@AckType requires statically analyzable schema chains.', element: element, @@ -1453,7 +1453,7 @@ class SchemaAstAnalyzer { ); } - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not determine schema type for field "$fieldName"', element: element, ); @@ -1473,7 +1473,7 @@ class SchemaAstAnalyzer { ); if (schemaMethod == 'object') { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Field "$fieldName" uses anonymous inline Ack.object(...). ' 'Strict typed generation requires a named schema reference.', element: element, @@ -1565,7 +1565,7 @@ class SchemaAstAnalyzer { } if (baseInvocation == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not determine schema type for "${invocation.toSource()}".', element: element, ); @@ -1705,7 +1705,7 @@ class SchemaAstAnalyzer { listElementIsCustomType: false, ); default: - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Unsupported schema method: Ack.$schemaMethod()', element: element, ); @@ -2065,7 +2065,7 @@ class SchemaAstAnalyzer { final args = listInvocation.argumentList.arguments; if (args.isEmpty) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.list(...) requires an element schema argument for strict typed generation.', element: element, todo: @@ -2078,6 +2078,7 @@ class SchemaAstAnalyzer { final ref = _resolveListElementRef(firstArg); if (ref.invocation != null) { final chain = _analyzeSchemaChain(ref.invocation!); + _rejectNullableListElement(chain.isNullable, element); final baseInvocation = chain.ackBase; final transformOutputTypeString = _requireTransformOutputType( chain, @@ -2087,7 +2088,7 @@ class SchemaAstAnalyzer { if (baseInvocation != null && baseInvocation.methodName.name == 'object') { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.list(Ack.object(...)) uses an anonymous inline object schema. ' 'Strict typed generation requires a named schema reference.', element: element, @@ -2097,6 +2098,14 @@ class SchemaAstAnalyzer { } if (chain.schemaReference != null) { + final resolved = _resolveSchemaReference( + chain.schemaReference!, + element, + ); + _rejectNullableListElement( + resolved?.modelInfo.isNullableSchema ?? false, + element, + ); final mapping = _resolveSchemaVariableType( chain.schemaReference!, element, @@ -2116,7 +2125,7 @@ class SchemaAstAnalyzer { if (baseInvocation == null) { final rawExpression = firstArg.toSource(); - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not statically resolve Ack.list($rawExpression) element type.', element: element, todo: @@ -2159,6 +2168,11 @@ class SchemaAstAnalyzer { } if (ref.schemaRef != null) { + final resolved = _resolveSchemaReference(ref.schemaRef!, element); + _rejectNullableListElement( + resolved?.modelInfo.isNullableSchema ?? false, + element, + ); final mapping = _resolveSchemaVariableType( ref.schemaRef!, element, @@ -2174,7 +2188,7 @@ class SchemaAstAnalyzer { } final rawExpression = firstArg.toSource(); - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not statically resolve Ack.list($rawExpression) element type.', element: element, todo: @@ -2182,6 +2196,17 @@ class SchemaAstAnalyzer { ); } + void _rejectNullableListElement(bool isNullable, Element2 element) { + if (!isNullable) return; + + throw InvalidGenerationSource( + 'Ack.list(...) does not support nullable element schemas.', + element: element, + todo: + 'Remove `.nullable()` from the element schema. Make the list itself nullable with `Ack.list(item).nullable()` when needed.', + ); + } + _SchemaTypeMapping _wrapListElementMapping( _SchemaTypeMapping elementMapping, TypeProvider typeProvider, @@ -2209,7 +2234,7 @@ class SchemaAstAnalyzer { }) { final resolved = _resolveSchemaReference(schemaReference, element); if (resolved == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not resolve schema reference "${schemaReference.name}" ' 'used in Ack.list(...)', element: element, @@ -2239,7 +2264,7 @@ class SchemaAstAnalyzer { resolved.hasAckTypeAnnotation && !hasTransformOverride; final isObjectRepresentation = representationType == kMapType; if (isObjectRepresentation && !hasTypedReference) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.list(${schemaReference.name}) references object schema ' '"${schemaReference.name}" without @AckType. This would fall back to ' 'Map.', @@ -2305,7 +2330,7 @@ class SchemaAstAnalyzer { } if (_schemaVariableTypeStack.contains(cacheKey)) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Circular schema variable reference detected for ' '"${schemaReference.name}" in Ack.list(...).', element: element, @@ -2320,7 +2345,7 @@ class SchemaAstAnalyzer { String? resolvedType; try { if (library == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not resolve library while analyzing schema reference ' '"${schemaReference.name}"', element: element, @@ -2329,7 +2354,7 @@ class SchemaAstAnalyzer { final resolved = _resolveSchemaReference(schemaReference, element); if (resolved == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not resolve schema reference "${schemaReference.name}" ' 'used in Ack.list(...)', element: element, @@ -2346,7 +2371,7 @@ class SchemaAstAnalyzer { transformedRepresentationType == null; if (representationType == kMapType && !hasTypedReference) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Ack.list(${schemaReference.name}) references object schema ' '"${schemaReference.name}" without @AckType. This would fall back to ' 'Map.', @@ -2387,7 +2412,7 @@ class SchemaAstAnalyzer { if (_schemaReferenceResolutionStack.contains(cacheKey)) { final referenceLabel = _formatSchemaReference(reference); - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Circular schema reference detected for "$referenceLabel".', element: contextElement, todo: @@ -2495,7 +2520,7 @@ class SchemaAstAnalyzer { ); shouldCacheResult = true; return resolvedReference; - } on InvalidGenerationSourceError { + } on InvalidGenerationSource { rethrow; } catch (e, st) { _log.warning( @@ -2623,7 +2648,7 @@ class SchemaAstAnalyzer { }) { final contextLibrary = contextElement.library2; if (contextLibrary == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not resolve libraries while qualifying transformed representation ' 'type "$representationType".', element: contextElement, @@ -2635,7 +2660,7 @@ class SchemaAstAnalyzer { } if (_containsUnsupportedRepresentationSyntax(representationType)) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Transformed representation type "$representationType" for ' '"${resolved.schemaName}" uses unsupported syntax for cross-file ' 'generation.', @@ -2680,7 +2705,7 @@ class SchemaAstAnalyzer { } if (token.contains('.')) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Transformed representation type "$fullRepresentationType" for ' '"${resolved.schemaName}" uses a qualified type that cannot be ' 'referenced across library boundaries.', @@ -2727,7 +2752,7 @@ class SchemaAstAnalyzer { } if (hasAmbiguousImportedTypes || unqualifiedContextType != null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Transformed representation type "$fullRepresentationType" for ' '"${resolved.schemaName}" is ambiguous in this library.', element: contextElement, @@ -2738,7 +2763,7 @@ class SchemaAstAnalyzer { } if (hasAmbiguousImportedTypes) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Transformed representation type "$fullRepresentationType" for ' '"${resolved.schemaName}" is ambiguous in this library.', element: contextElement, @@ -2751,7 +2776,7 @@ class SchemaAstAnalyzer { return token; } - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Transformed representation type "$fullRepresentationType" for ' '"${resolved.schemaName}" is not visible from this library.', element: contextElement, @@ -3048,7 +3073,7 @@ class SchemaAstAnalyzer { return typeName; } - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '$contextLabel uses .transform(...) without an explicit output type. ' '@AckType requires .transform(...) so the generated type can be inferred.', element: element, @@ -3068,7 +3093,7 @@ class SchemaAstAnalyzer { } if (schemaMethod == 'object') { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '$contextLabel transforms an Ack.object(...) schema. ' 'Transformed object schemas are not supported by @AckType.', element: element, @@ -3078,7 +3103,7 @@ class SchemaAstAnalyzer { } if (schemaMethod == 'discriminated') { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '$contextLabel transforms an Ack.discriminated(...) schema. ' 'Transformed discriminated schemas are not supported by @AckType.', element: element, @@ -3095,7 +3120,7 @@ class SchemaAstAnalyzer { }) { final modelInfo = resolved.modelInfo; if (modelInfo.isDiscriminatedBaseDefinition) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '$contextLabel transforms referenced discriminated schema ' '"${resolved.schemaName}". Transformed discriminated schemas are not supported by @AckType.', element: element, @@ -3105,7 +3130,7 @@ class SchemaAstAnalyzer { } if (modelInfo.representationType == kMapType) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '$contextLabel transforms referenced object schema ' '"${resolved.schemaName}". Transformed object schemas are not supported by @AckType.', element: element, @@ -3187,7 +3212,7 @@ class SchemaAstAnalyzer { final trimmed = customTypeName.trim(); if (trimmed.isEmpty) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Custom @AckType name cannot be empty', element: element, todo: 'Provide a non-empty type name in the @AckType annotation.', @@ -3196,7 +3221,7 @@ class SchemaAstAnalyzer { const identifierPattern = r'^[A-Za-z_][A-Za-z0-9_]*$'; if (!RegExp(identifierPattern).hasMatch(trimmed)) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Invalid custom @AckType name "$customTypeName". ' 'Type names must start with a letter or underscore and can only contain letters, numbers, and underscores.', element: element, @@ -3476,7 +3501,7 @@ class SchemaAstAnalyzer { // If we couldn't extract the enum type, throw an error if (enumTypeName == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not determine enum type for Ack.enumValues(). ' 'Use explicit type argument: Ack.enumValues([...]) ' 'or pass enum.values: Ack.enumValues(YourEnum.values)', @@ -3514,14 +3539,14 @@ class SchemaAstAnalyzer { /// Validates that a field name is a valid Dart identifier /// - /// Throws [InvalidGenerationSourceError] if the field name: + /// Throws [InvalidGenerationSource] if the field name: /// - Contains invalid characters (must match [a-zA-Z_$][a-zA-Z0-9_$]*) /// - Is a Dart reserved keyword void _validateFieldName(String fieldName, Element2 element) { // Check if key is a valid Dart identifier final identifierRegex = RegExp(r'^[a-zA-Z_$][a-zA-Z0-9_$]*$'); if (!identifierRegex.hasMatch(fieldName)) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'JSON key "$fieldName" is not a valid Dart identifier. ' 'Keys must start with a letter, underscore, or dollar sign, and can only ' 'contain letters, numbers, underscores, and dollar signs.', @@ -3536,7 +3561,7 @@ class SchemaAstAnalyzer { // as identifiers in many contexts (for example `of`, `augment`). final keyword = Keyword.keywords[fieldName]; if (keyword?.isReservedWord == true) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'JSON key "$fieldName" is a Dart reserved keyword and cannot be used as a field name.', element: element, todo: diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index b8cd19d9..2913eeba 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:ack_annotations/ack_annotations.dart'; import 'package:analyzer/dart/element/element2.dart'; import 'package:build/build.dart'; @@ -29,7 +27,7 @@ class AckSchemaGenerator extends Generator { for (final element in library.allElements) { if (element is ClassElement2 && _hasAckTypeAnnotation(element)) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '@AckType can only be applied to top-level schema variables or getters, not classes.', element: element, todo: @@ -43,7 +41,7 @@ class AckSchemaGenerator extends Generator { } else if (element is GetterElement && _hasAckTypeAnnotation(element)) { final isTopLevel = element.enclosingElement2 is LibraryElement2; if (!isTopLevel) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '@AckType can only be applied to top-level schema variables or getters.', element: element, todo: @@ -60,7 +58,7 @@ class AckSchemaGenerator extends Generator { for (final classElement in library.classes) { for (final getter in classElement.getters) { if (_hasAckTypeAnnotation(getter)) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( '@AckType can only be applied to top-level schema variables or getters.', element: getter, todo: @@ -92,7 +90,7 @@ class AckSchemaGenerator extends Generator { modelInfos.add(modelInfo); } } catch (e) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Failed to analyze schema variable "${variable.name3}": $e', element: variable, todo: @@ -111,7 +109,7 @@ class AckSchemaGenerator extends Generator { modelInfos.add(modelInfo); } } catch (e) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Failed to analyze schema getter "${getter.name3}": $e', element: getter, todo: @@ -160,18 +158,9 @@ class AckSchemaGenerator extends Generator { final validation = CodeValidator.validate(formattedCode); if (validation.isFailure) { - var debugInfo = ''; - final debugEnabled = - Platform.environment['ACK_GENERATOR_DEBUG']?.toLowerCase() == 'true'; - if (debugEnabled) { - final outputPath = buildStep.inputId.changeExtension('.g.dart.debug'); - final file = File(outputPath.path); - file.writeAsStringSync(formattedCode); - debugInfo = '\nDebug output written to: ${outputPath.path}'; - } - - throw InvalidGenerationSourceError( - 'Generated code validation failed: ${validation.errorMessage}$debugInfo', + throw InvalidGenerationSource( + 'Generated code validation failed: ${validation.errorMessage}\n' + 'Generated output:\n$formattedCode', todo: 'Fix the code generation logic to produce valid Dart syntax.', ); } @@ -194,7 +183,7 @@ class AckSchemaGenerator extends Generator { annotatedVariables, annotatedGetters, ) ?? - (throw InvalidGenerationSourceError( + (throw InvalidGenerationSource( 'Could not find schema declaration "${model.schemaClassName}"', todo: 'Ensure the schema variable or getter exists and is annotated with @AckType.', @@ -206,7 +195,7 @@ class AckSchemaGenerator extends Generator { sortedModels = typeBuilder.topologicalSort(models); } catch (e) { final element = typedElements.first; - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Extension type dependency resolution failed: $e', element: element, todo: @@ -223,7 +212,7 @@ class AckSchemaGenerator extends Generator { annotatedGetters, ); if (element == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not find schema declaration "${model.schemaClassName}"', todo: 'Ensure the schema variable or getter exists and is annotated with @AckType.', @@ -248,7 +237,7 @@ class AckSchemaGenerator extends Generator { final emittedSubtypeSchemaNames = {}; for (final subtypeSchemaName in subtypeNames.values) { if (!emittedSubtypeSchemaNames.add(subtypeSchemaName)) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Discriminated base "${model.schemaClassName}" maps multiple discriminator values to subtype "$subtypeSchemaName".', element: element, todo: @@ -258,7 +247,7 @@ class AckSchemaGenerator extends Generator { final subtypeModel = sortedModels.firstWhere( (candidate) => candidate.schemaClassName == subtypeSchemaName, - orElse: () => throw InvalidGenerationSourceError( + orElse: () => throw InvalidGenerationSource( 'Subtype "$subtypeSchemaName" was not found while generating "${model.schemaClassName}".', element: element, ), @@ -290,7 +279,7 @@ class AckSchemaGenerator extends Generator { generatedTypeModels.add(model); } } catch (e) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Extension type generation failed for ${element.name3}: $e', element: element, todo: @@ -331,7 +320,7 @@ class AckSchemaGenerator extends Generator { final branchIndex = modelIndexBySchemaClassName[branchSchemaClassName]; if (branchIndex == null) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Could not resolve discriminated branch "$branchSchemaClassName" for base "${baseModel.schemaClassName}".', todo: 'Ensure every Ack.discriminated(...) branch references an @AckType schema declared in the same library.', @@ -345,7 +334,7 @@ class AckSchemaGenerator extends Generator { branchOwnerByCanonicalIdentity[canonicalBranchIdentity]; if (existingOwner != null && existingOwner != baseModel.schemaClassName) { - throw InvalidGenerationSourceError( + throw InvalidGenerationSource( 'Branch schema "$branchSchemaClassName" is mapped to multiple discriminated bases: "$existingOwner" and "${baseModel.schemaClassName}".', todo: 'A branch schema can only belong to one Ack.discriminated(...) base.', diff --git a/packages/ack_generator/melos_ack_generator.iml b/packages/ack_generator/melos_ack_generator.iml deleted file mode 100644 index 389d07a1..00000000 --- a/packages/ack_generator/melos_ack_generator.iml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/ack_generator/test/block_emission_test.dart b/packages/ack_generator/test/block_emission_test.dart deleted file mode 100644 index 4b55d357..00000000 --- a/packages/ack_generator/test/block_emission_test.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'package:code_builder/code_builder.dart'; -import 'package:test/test.dart'; - -/// Test to understand how code_builder emits Block statements -void main() { - group('Block emission tests', () { - test('Block with statements using addAll', () { - final method = Method( - (m) => m - ..name = 'parse' - ..static = true - ..returns = refer('SimpleUserType') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Block( - (b) => b.statements.addAll([ - Code('final validated = simpleUserSchema.parse(data);'), - Code('return SimpleUserType(validated as Map);'), - ]), - ), - ); - - final emitter = DartEmitter( - allocator: Allocator.none, - orderDirectives: true, - useNullSafetySyntax: true, - ); - - final output = method.accept(emitter).toString(); - print('=== Block with addAll ==='); - print(output); - print('=== End ===\n'); - }); - - test('Block.of with Code statements', () { - final method = Method( - (m) => m - ..name = 'parse' - ..static = true - ..returns = refer('SimpleUserType') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Block.of([ - Code('final validated = simpleUserSchema.parse(data);'), - Code('return SimpleUserType(validated as Map);'), - ]), - ); - - final emitter = DartEmitter( - allocator: Allocator.none, - orderDirectives: true, - useNullSafetySyntax: true, - ); - - final output = method.accept(emitter).toString(); - print('=== Block.of ==='); - print(output); - print('=== End ===\n'); - }); - - test('Multi-line Code string', () { - final method = Method( - (m) => m - ..name = 'parse' - ..static = true - ..returns = refer('SimpleUserType') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -final validated = simpleUserSchema.parse(data); -return SimpleUserType(validated as Map); -'''), - ); - - final emitter = DartEmitter( - allocator: Allocator.none, - orderDirectives: true, - useNullSafetySyntax: true, - ); - - final output = method.accept(emitter).toString(); - print('=== Multi-line Code ==='); - print(output); - print('=== End ===\n'); - }); - - test('Code with explicit newlines', () { - final method = Method( - (m) => m - ..name = 'parse' - ..static = true - ..returns = refer('SimpleUserType') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code( - 'final validated = simpleUserSchema.parse(data);\nreturn SimpleUserType(validated as Map);', - ), - ); - - final emitter = DartEmitter( - allocator: Allocator.none, - orderDirectives: true, - useNullSafetySyntax: true, - ); - - final output = method.accept(emitter).toString(); - print('=== Code with \\n ==='); - print(output); - print('=== End ===\n'); - }); - }); -} diff --git a/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart b/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart index f61279dc..ed91a1db 100644 --- a/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart +++ b/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart @@ -225,7 +225,7 @@ final schemaBSchema = Ack.list(schemaASchema); expect( () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), + throwsA(isA()), ); }); }); @@ -256,7 +256,7 @@ final usersSchema = Ack.list(Ack.object({ expect( () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), + throwsA(isA()), ); }); }); @@ -289,7 +289,7 @@ final usersSchema = Ack.list(schemaFactory()); expect( () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), + throwsA(isA()), ); }); }, @@ -358,7 +358,7 @@ final schemaBSchema = schemaASchema; throwsA( predicate( (error) => - error is InvalidGenerationSourceError && + error is InvalidGenerationSource && error.toString().contains('Circular schema reference'), ), ), @@ -540,7 +540,7 @@ final deepSchema = Ack.object({ throwsA( predicate( (error) => - error is InvalidGenerationSourceError && + error is InvalidGenerationSource && error.toString().contains('exceeded max depth of 20'), ), ), @@ -704,7 +704,7 @@ final testSchema = Ack.object({ expect( () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), + throwsA(isA()), ); }); }, @@ -741,7 +741,7 @@ final testSchema = Ack.object({ expect( () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), + throwsA(isA()), ); }); }, @@ -776,7 +776,7 @@ final testSchema = Ack.object({ final analyzer = SchemaAstAnalyzer(); expect( () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), + throwsA(isA()), ); }); }); @@ -808,7 +808,7 @@ final testSchema = Ack.object({ final analyzer = SchemaAstAnalyzer(); expect( () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), + throwsA(isA()), ); }); }); @@ -985,7 +985,7 @@ final keywordSchema = Ack.object({ expect( () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), + throwsA(isA()), reason: 'Expected "$keyword" to be rejected as reserved keyword.', ); }); diff --git a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart index 25a84f8d..36d33c20 100644 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart @@ -3,32 +3,9 @@ import 'package:build/build.dart'; import 'package:build_test/build_test.dart'; import 'package:test/test.dart'; +import '../test_utils/generation_test_utils.dart'; import '../test_utils/test_assets.dart'; -Future _expectGenerationFailure({ - required Builder builder, - required Map assets, - required String expectedMessage, - Map? expectedOutputs, -}) async { - var sawExpectedError = false; - await testBuilder( - builder, - assets, - outputs: expectedOutputs ?? {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && log.message.contains(expectedMessage)) { - sawExpectedError = true; - } - }, - ); - expect( - sawExpectedError, - isTrue, - reason: 'Expected SEVERE log containing "$expectedMessage"', - ); -} - void main() { group('@AckType cross-file schema references', () { test( @@ -372,7 +349,7 @@ final themeSchema = Ack.object({ () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'is not visible from this library', expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, @@ -484,7 +461,7 @@ final themeSchema = Ack.object({ () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'is ambiguous in this library', expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, @@ -527,7 +504,7 @@ final themeSchema = Ack.object({ () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'is ambiguous in this library', expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, @@ -572,7 +549,7 @@ final themeSchema = Ack.object({ () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'is not visible from this library', expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, @@ -614,7 +591,7 @@ final themeSchema = Ack.object({ () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'uses a qualified type that cannot be referenced across library boundaries', diff --git a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart index 85107c2f..2b2e8acc 100644 --- a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart +++ b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart @@ -3,32 +3,9 @@ import 'package:build/build.dart'; import 'package:build_test/build_test.dart'; import 'package:test/test.dart'; +import '../test_utils/generation_test_utils.dart'; import '../test_utils/test_assets.dart'; -Future _expectGenerationFailure({ - required Builder builder, - required Map assets, - required String expectedMessage, - Map? expectedOutputs, -}) async { - var sawExpectedError = false; - await testBuilder( - builder, - assets, - outputs: expectedOutputs ?? {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && log.message.contains(expectedMessage)) { - sawExpectedError = true; - } - }, - ); - expect( - sawExpectedError, - isTrue, - reason: 'Expected SEVERE log containing "$expectedMessage"', - ); -} - void main() { group('@AckType discriminated schemas', () { test( @@ -180,7 +157,7 @@ final petSchema = Ack.discriminated( test('fails when branch discriminator property is broad string', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'could not be proven to accept "cat"', assets: { @@ -212,7 +189,7 @@ final petSchema = Ack.discriminated( () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'could not be proven to accept "cat"', assets: { @@ -245,7 +222,7 @@ final petSchema = Ack.discriminated( () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'could not be proven to accept "cat"', assets: { @@ -276,7 +253,7 @@ final petSchema = Ack.discriminated( test('fails when a branch is an inline expression', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'must reference a top-level schema variable/getter', assets: { @@ -303,7 +280,7 @@ final petSchema = Ack.discriminated( test('fails when a branch lacks @AckType', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'must be annotated with @AckType', assets: { @@ -332,7 +309,7 @@ final petSchema = Ack.discriminated( test('fails when a branch schema is not object-shaped', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'must be an object schema', assets: { @@ -359,7 +336,7 @@ final petSchema = Ack.discriminated( test('fails when a branch comes from another library', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'must be declared in the same library', expectedOutputs: {'test_pkg|lib/branches.g.dart': anything}, @@ -395,7 +372,7 @@ final petSchema = Ack.discriminated( test('fails when discriminated base is nullable', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'cannot be nullable when used with @AckType', assets: { @@ -425,7 +402,7 @@ final petSchema = Ack.discriminated( test('fails when schemas map is empty', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'must contain at least one branch', assets: { @@ -447,7 +424,7 @@ final petSchema = Ack.discriminated( test('fails when a branch schema is nullable', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'cannot be nullable', assets: { @@ -477,7 +454,7 @@ final petSchema = Ack.discriminated( test('fails when discriminator values are duplicated', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'duplicate discriminator value', assets: { @@ -516,7 +493,7 @@ final petSchema = Ack.discriminated( () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'but is mapped as', assets: { @@ -550,7 +527,7 @@ final petSchema = Ack.discriminated( () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'but is mapped as', assets: { @@ -581,7 +558,7 @@ final petSchema = Ack.discriminated( test('fails when a branch is reused across multiple bases', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'mapped to multiple discriminated bases', assets: { @@ -619,7 +596,7 @@ final anotherPetSchema = Ack.discriminated( test('fails when aliased branch is reused across multiple bases', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'mapped to multiple discriminated bases', assets: { @@ -663,7 +640,7 @@ final anotherPetSchema = Ack.discriminated( test('fails when a branch is itself a discriminated base', () async { final builder = ackGenerator(BuilderOptions.empty); - await _expectGenerationFailure( + await expectGenerationFailure( builder: builder, expectedMessage: 'Nested discriminated unions are not supported', assets: { diff --git a/packages/ack_generator/test/integration/ack_type_getter_test.dart b/packages/ack_generator/test/integration/ack_type_getter_test.dart index d7f120ed..febee9cf 100644 --- a/packages/ack_generator/test/integration/ack_type_getter_test.dart +++ b/packages/ack_generator/test/integration/ack_type_getter_test.dart @@ -3,6 +3,7 @@ import 'package:build/build.dart'; import 'package:build_test/build_test.dart'; import 'package:test/test.dart'; +import '../test_utils/generation_test_utils.dart'; import '../test_utils/test_assets.dart'; void main() { @@ -153,12 +154,12 @@ ObjectSchema get userSchema => Ack.object({ ); }); - test('documents nullable list element type inference loss', () async { + test('rejects nullable list element schemas', () async { final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { + await expectGenerationFailure( + builder: builder, + assets: { ...allAssets, 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; @@ -170,16 +171,32 @@ ObjectSchema get userSchema => Ack.object({ }); ''', }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - "List get tags => _\$ackListCast(_data['tags'])", - ), - isNot(contains('List get tags')), - ]), - ), + expectedMessage: + 'Ack.list(...) does not support nullable element schemas', + ); + }); + + test('rejects nullable list element schema references', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await expectGenerationFailure( + builder: builder, + assets: { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +final tagSchema = Ack.string().nullable(); + +@AckType() +ObjectSchema get userSchema => Ack.object({ + 'tags': Ack.list(tagSchema), +}); +''', }, + expectedMessage: + 'Ack.list(...) does not support nullable element schemas', ); }); }); 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 c00a726e..7bc7380a 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -4,8 +4,8 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; /// Integration test that verifies the example folder builds correctly -/// and has no analyze errors. This acts as a golden test to ensure -/// the generated code remains valid and analyzer-clean. +/// and has no analyze errors. The retained output set and structural checks +/// keep generated code stable and analyzer-clean. void main() { group('Example Folder Build Integration', () { const expectedGeneratedFiles = [ @@ -56,7 +56,6 @@ void main() { 'run', 'build_runner', 'build', - '--delete-conflicting-outputs', ], workingDirectory: exampleDir.path); expect( @@ -85,11 +84,6 @@ void main() { expectedGeneratedFiles, reason: 'The retained AckType example output set should stay stable', ); - - print('✅ Generated ${generatedFiles.length} files:'); - for (final file in generatedFiles) { - print(' - ${p.relative(file.path, from: exampleDir.path)}'); - } }, timeout: const Timeout(Duration(minutes: 2)), ); @@ -108,8 +102,6 @@ void main() { 'STDOUT: ${analyzeResult.stdout}\n' 'STDERR: ${analyzeResult.stderr}', ); - - print('✅ Example folder passed dart analyze with no issues'); }); test('generated files should match expected schema patterns', () async { @@ -170,8 +162,6 @@ void main() { contains("part '$fileName'"), reason: 'Main file $mainFileName should include part directive', ); - - print('✅ $fileName matches expected patterns'); } final discriminatedFile = File( @@ -262,8 +252,6 @@ void main() { 'STDOUT: ${pubGetResult.stdout}\n' 'STDERR: ${pubGetResult.stderr}', ); - - print('✅ Example folder dependencies resolved successfully'); }); }); } diff --git a/packages/ack_generator/test/run_tests.sh b/packages/ack_generator/test/run_tests.sh deleted file mode 100755 index 03050e73..00000000 --- a/packages/ack_generator/test/run_tests.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -# Test runner script for ack_generator - -set -e - -echo "🧪 Running ack_generator tests..." -echo "" - -# Ensure we're in the right directory -cd "$(dirname "$0")/.." - -# Get dependencies -echo "📦 Getting dependencies..." -dart pub get - -# Run tests -echo "" -echo "🔍 Running unit tests..." - -# Run analyzer tests -echo " - Analyzer tests..." -dart test test/src/analyzer --reporter compact || true - -# Run builder tests -echo " - Builder tests..." -dart test test/src/builders --reporter compact || true - -# Run generator tests -echo " - Generator tests..." -dart test test/src/generator_test.dart --reporter compact || true - -# Run integration tests -echo "" -echo "🔗 Running integration tests..." -dart test test/integration --reporter compact || true - -# Run golden tests -echo "" -echo "✨ Running golden tests..." -dart test test/golden_test.dart --reporter compact || true - -echo "" -echo "✅ Test run complete!" diff --git a/packages/ack_generator/test/test_utils/generation_test_utils.dart b/packages/ack_generator/test/test_utils/generation_test_utils.dart new file mode 100644 index 00000000..74c4b12d --- /dev/null +++ b/packages/ack_generator/test/test_utils/generation_test_utils.dart @@ -0,0 +1,27 @@ +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +Future expectGenerationFailure({ + required Builder builder, + required Map assets, + required String expectedMessage, + Map? expectedOutputs, +}) async { + var sawExpectedError = false; + await testBuilder( + builder, + assets, + outputs: expectedOutputs ?? const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && log.message.contains(expectedMessage)) { + sawExpectedError = true; + } + }, + ); + expect( + sawExpectedError, + isTrue, + reason: 'Expected SEVERE log containing "$expectedMessage"', + ); +} diff --git a/packages/ack_generator/tool/show_generator_output.dart b/packages/ack_generator/tool/show_generator_output.dart deleted file mode 100644 index 7cdeb935..00000000 --- a/packages/ack_generator/tool/show_generator_output.dart +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env dart - -import 'dart:io'; - -/// Simple tool to show the actual generator output by running tests -void main(List args) async { - final testName = args.isNotEmpty ? args[0] : 'user_schema'; - - print('🔍 Showing generator output for: $testName'); - print('=' * 60); - - if (testName == 'user_schema' || testName == 'user') { - await _showGeneratorOutput('user schema golden test'); - } else if (testName == 'order_schema' || testName == 'order') { - print( - 'ℹ️ Order schema test uses pattern matching, not golden file comparison', - ); - print( - ' Run: dart test test/golden_test.dart --plain-name "complex nested schema golden test"', - ); - } else { - print('❌ Unknown test: $testName'); - print('Available tests: user_schema, order_schema'); - exit(1); - } -} - -Future _showGeneratorOutput(String testName) async { - print('📝 Running test to extract generator output...'); - print('-' * 40); - - try { - // Run the specific golden test and capture its output - final result = await Process.run('dart', [ - 'test', - 'test/golden_test.dart', - '--plain-name', - testName, - '--reporter=expanded', - ], workingDirectory: Directory.current.path); - - final output = result.stdout.toString(); - - // Look for the actual generated content in the test output - // The test output shows the actual bytes, we need to extract the readable content - final lines = output.split('\n'); - bool foundActualContent = false; - final contentLines = []; - - for (final line in lines) { - if (line.contains('Which: has `utf-8 decoded bytes` with value')) { - foundActualContent = true; - continue; - } - - if (foundActualContent) { - if (line.trim().startsWith("'") && line.trim().endsWith("'")) { - // Extract content between quotes and convert escape sequences - var content = line.trim(); - content = content.substring(1, content.length - 1); // Remove quotes - content = content.replaceAll(r'\n', '\n'); - content = content.replaceAll(r"\'", "'"); - contentLines.add(content); - } else if (line.trim().isEmpty || line.contains('Unexpected content')) { - break; - } - } - } - - if (contentLines.isNotEmpty) { - print('✅ Generated Code:'); - print('=' * 60); - final generatedCode = contentLines.join(''); - print(generatedCode); - print('=' * 60); - } else { - print('❌ Could not extract generator output from test failure'); - print('Raw test output:'); - print(output); - } - } catch (e) { - print('❌ Error running test: $e'); - } -} diff --git a/packages/ack_generator/tool/update_goldens.dart b/packages/ack_generator/tool/update_goldens.dart deleted file mode 100644 index d36c1642..00000000 --- a/packages/ack_generator/tool/update_goldens.dart +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env dart - -import 'dart:io'; - -import 'package:path/path.dart' as p; - -/// Update golden test files by extracting actual generator output. -/// -/// **When to use this tool:** -/// - After changing generator code (new features, bug fixes) -/// - When golden tests are failing due to outdated reference files -/// - To see what the current generator produces -/// -/// **When NOT to use:** -/// - When tests are already passing (golden files are correct) -/// - When you haven't changed the generator -void main(List args) async { - final updateAll = args.contains('--all'); - final testNames = args.where((arg) => !arg.startsWith('--')).toList(); - - if (!updateAll && testNames.isEmpty) { - print('🔧 Golden File Updater for ACK Generator'); - print(''); - print( - 'Updates golden test files by extracting actual generator output from test failures.', - ); - print(''); - print('📋 When to use:'); - print(' ✅ After changing generator code'); - print(' ✅ When golden tests are failing'); - print(' ✅ To see current generator output'); - print(''); - print('❌ When NOT to use:'); - print(' - Tests are already passing'); - print(' - You haven\'t changed anything'); - print(''); - print('Usage:'); - print( - ' dart tool/update_goldens.dart --all # Update all golden files', - ); - print( - ' dart tool/update_goldens.dart user_schema # Update specific test', - ); - print(''); - print('Available tests:'); - print(' - user_schema (simple schema with basic types)'); - return; - } - - // Ensure golden directory exists - final goldenDir = Directory('test/golden'); - if (!await goldenDir.exists()) { - await goldenDir.create(recursive: true); - print('📁 Created test/golden directory'); - } - - print('🚀 Auto-updating golden files from test output...'); - print(''); - - try { - if (updateAll || testNames.contains('user_schema')) { - await _autoUpdateUserSchemaGolden(); - } - - print(''); - print('✅ Golden files auto-updated successfully!'); - print(''); - print('Next steps:'); - print(' 1. Run tests: dart test test/golden_test.dart'); - print(' 2. Commit the updated golden files if tests pass'); - } catch (e, stackTrace) { - print('❌ Error auto-updating golden files: $e'); - print('Stack trace: $stackTrace'); - exit(1); - } -} - -Future _autoUpdateUserSchemaGolden() async { - print('🔄 Auto-updating user_schema.dart.golden...'); - - try { - // Run the test and capture the detailed output - final result = await Process.run('dart', [ - 'test', - 'test/golden_test.dart', - '--plain-name', - 'user schema golden test', - '--reporter=expanded', - ], workingDirectory: Directory.current.path); - - final output = result.stdout.toString(); - - // Extract the generated content from the test output - final content = _extractContentFromTestOutput(output); - - if (content != null && content.isNotEmpty) { - final goldenFile = File( - p.join('test', 'golden', 'user_schema.dart.golden'), - ); - await goldenFile.writeAsString(content); - print(' ✅ Updated ${goldenFile.path}'); - print(' 📝 Content length: ${content.length} characters'); - } else { - print(' ❌ FAILED to extract generator output from test'); - print(' 💡 Possible causes:'); - print(' - Test is already passing (golden file is correct)'); - print(' - Test output format has changed'); - print(' - Generator is not producing expected output'); - print( - ' 🔧 Try running: dart test test/golden_test.dart --plain-name "user schema golden test"', - ); - throw Exception( - 'Failed to extract generator output for user_schema.dart.golden', - ); - } - } catch (e) { - print(' ❌ Error running test: $e'); - rethrow; // Re-throw to fail the script - } -} - -String? _extractContentFromTestOutput(String output) { - // Look for the actual generated content in the test output - // The test failure shows the actual content in a specific format - - // Method 1: Look for the content between specific markers - final lines = output.split('\n'); - final contentLines = []; - - for (int i = 0; i < lines.length; i++) { - final line = lines[i]; - - // Look for the line that shows the actual content - if (line.contains('Which: has `utf-8 decoded bytes` with value')) { - // The content is in the following lines, quoted - for (int j = i + 1; j < lines.length; j++) { - final contentLine = lines[j].trim(); - - if (contentLine.startsWith("'") && contentLine.endsWith("'")) { - // Extract content between quotes - var content = contentLine.substring(1, contentLine.length - 1); - // Convert escape sequences - content = content.replaceAll(r'\n', '\n'); - content = content.replaceAll(r"\'", "'"); - content = content.replaceAll(r'\"', '"'); - contentLines.add(content); - } else if (contentLine.isEmpty || - contentLine.contains('Unexpected content') || - contentLine.contains('package:')) { - break; - } - } - break; - } - } - - if (contentLines.isNotEmpty) { - var result = contentLines.join(''); - // Clean up leading newlines but preserve structure - result = result.replaceAll(RegExp(r'^\n+'), ''); - return result; - } - - // Method 2: Try regex extraction as fallback - final regexMatch = RegExp( - r"'([^']*class UserSchema[^']*)'", - multiLine: true, - dotAll: true, - ).firstMatch(output); - - if (regexMatch != null) { - var content = regexMatch.group(1) ?? ''; - content = content.replaceAll(r'\n', '\n'); - content = content.replaceAll(r"\'", "'"); - return content.trim(); - } - - return null; -} diff --git a/packages/ack_json_schema_builder/melos_ack_json_schema_builder.iml b/packages/ack_json_schema_builder/melos_ack_json_schema_builder.iml deleted file mode 100644 index 389d07a1..00000000 --- a/packages/ack_json_schema_builder/melos_ack_json_schema_builder.iml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 70218fc1..c1b6c13a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ workspace: - packages/ack_firebase_ai - packages/ack_json_schema_builder - example - + dependencies: meta: ^1.15.0 @@ -25,6 +25,9 @@ dev_dependencies: dart_code_metrics_presets: ^2.22.0 melos: + ide: + intellij: false + bootstrap: dependencies: meta: ^1.15.0 @@ -50,7 +53,7 @@ melos: scripts: analyze: - run: melos exec -- "dart analyze . --fatal-infos" + run: dart run melos exec -- "dart analyze . --fatal-infos" description: Run analyzer on all packages packageFilters: scope: @@ -62,47 +65,46 @@ melos: - ack_annotations fix: - run: melos exec -- "dart fix --apply" + run: dart run melos exec -- "dart fix --apply" description: Apply fixes to all packages test: - run: melos run analyze --no-select && melos run test:dart --no-select && melos run test:flutter --no-select - description: Run strict analysis (fatal infos) and all Dart & Flutter tests + run: >- + dart run melos run analyze --no-select && + dart test test && + dart run melos run test:dart --no-select && + dart run melos run test:flutter --no-select + description: Run strict analysis and all root, Dart, and Flutter tests test:dart: - run: melos exec -c 1 --fail-fast -- "dart test" + run: dart run melos exec -c 1 --fail-fast -- "dart test" description: Run Dart tests for pure Dart packages packageFilters: flutter: false dirExists: test test:flutter: - run: melos exec -c 1 --fail-fast -- "flutter test" + run: dart run melos exec -c 1 --fail-fast -- "flutter test" description: Run Flutter tests for Flutter packages packageFilters: flutter: true dirExists: test format: - run: melos exec -- "dart format ." + run: dart run melos exec -- "dart format ." description: Format code for all packages deps-outdated: - run: melos exec -- "dart pub outdated" + run: dart run melos exec -- "dart pub outdated" description: Check for outdated dependencies in all packages - # Create proper analysis_options.yaml files - ensure_analysis_options: - run: .scripts/ensure_analysis_options.sh - description: Ensures each package has DCM configuration in analysis_options.yaml - clean: - run: melos clean + run: dart run melos clean description: Clean all build outputs. # Build scripts for code generation build: - run: melos exec -- "dart run build_runner build --delete-conflicting-outputs" + run: dart run melos exec -- "dart run build_runner build" description: Run build_runner for all packages packageFilters: dependsOn: build_runner @@ -110,72 +112,50 @@ melos: # Version and publish scripts # Publishing helpers publish: - run: melos publish --no-dry-run --yes + run: dart run melos publish --no-dry-run --yes description: Publish packages to pub.dev - + release: run: | - melos version --yes - melos publish --no-dry-run --yes + dart run melos version --yes + dart run melos publish --no-dry-run --yes description: One-step version and publish # ACK Generator specific test commands test:gen: - run: melos exec -c 1 -- dart test + run: dart run melos exec -c 1 -- dart test description: Run generator tests packageFilters: scope: ack_generator - test:gen:watch: - run: melos exec -c 1 -- dart test --watch - description: Run generator tests in watch mode - packageFilters: - scope: ack_generator - - update-golden: - run: cd packages/ack_generator && dart tool/update_goldens.dart - description: Update golden test files (interactive - specify test names) - - update-golden:all: - run: cd packages/ack_generator && dart tool/update_goldens.dart --all - description: Update all golden test files - 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 - test:golden: - run: melos exec -c 1 -- dart test test/golden_test.dart - description: Run only golden tests - packageFilters: - dirExists: test/golden - # JSON Schema Draft-7 validation validate-jsonschema: run: | echo "🔧 Installing Node.js dependencies..." - (cd tools && npm install) + (cd tools && npm ci) echo "🧪 Running JSON Schema Draft-7 validation tests..." (cd packages/ack && dart test test/json_schema_conformance_test.dart) description: Validate JSON Schema Draft-7 specifications validate-jsonschema:batch: run: | - cd tools && npm install + cd tools && npm ci node jsonschema-validator.js validate-batch --input test-fixtures/reference-config.json description: Run batch JSON Schema Draft-7 validation validate-jsonschema:setup: - run: cd tools && npm install + run: cd tools && npm ci description: Install Node.js dependencies for JSON Schema validation # API compatibility checking with dart_apitool api-check: run: dart scripts/api_check.dart - description: "Check API compatibility for packages against specified version (usage: melos api-check v0.2.0)" + description: "Check API compatibility against a published version" ci: - run: melos run test --no-select + run: dart run melos run test --no-select description: Run strict analysis (fatal infos) and all Dart & Flutter tests - - sdkPath: .fvm/flutter_sdk diff --git a/scripts/api_check.dart b/scripts/api_check.dart index ccea4ab0..1d5f22ea 100755 --- a/scripts/api_check.dart +++ b/scripts/api_check.dart @@ -3,7 +3,14 @@ import 'dart:convert'; import 'dart:io'; -const ackPackages = ['ack', 'ack_generator']; +const ackPackages = [ + 'ack', + 'ack_annotations', + 'ack_generator', + 'ack_firebase_ai', + 'ack_json_schema_builder', +]; +const dartApiToolVersion = '0.23.0'; Future main(List args) async { // Parse arguments @@ -48,23 +55,42 @@ Future main(List args) async { print('🚀 API Compatibility Check vs $version'); // Activate dart_apitool - await runCommand('dart', ['pub', 'global', 'activate', 'dart_apitool']); + final activated = await runCommand('dart', [ + 'pub', + 'global', + 'activate', + 'dart_apitool', + dartApiToolVersion, + ]); + if (!activated) { + stderr.writeln('❌ Unable to activate dart_apitool.'); + exitCode = 1; + return; + } // Check packages final packagesToCheck = packageName != null ? [packageName] : ackPackages; final reports = []; + var hasFailures = false; for (final pkg in packagesToCheck) { - await checkPackage(pkg, cleanVersion, version, reports); + if (!await checkPackage(pkg, cleanVersion, version, reports)) { + hasFailures = true; + } } // Print summary print(''); - print('🎯 API compatibility check completed!'); + print( + hasFailures + ? '❌ API compatibility check found changes or errors.' + : '🎯 API compatibility check completed!', + ); print('📂 Reports saved in project root:'); for (final report in reports) { print(' • $report'); } + if (hasFailures) exitCode = 1; } Future getLatestVersion(String packageName) async { @@ -75,19 +101,26 @@ Future getLatestVersion(String packageName) async { ]); if (result.exitCode == 0) { - final json = jsonDecode(result.stdout); - return json['latest']['version']; + final decoded = jsonDecode(result.stdout); + if (decoded is Map) { + final latest = decoded['latest']; + if (latest is Map) { + final latestVersion = latest['version']; + if (latestVersion is String) return latestVersion; + } + } } } catch (e) { - print( - '⚠️ Could not fetch latest version for $packageName, please specify version manually', - ); + stderr.writeln('Could not fetch the latest $packageName version: $e'); } + stderr.writeln( + 'Could not fetch the latest version for $packageName; specify one explicitly.', + ); exit(1); } -Future checkPackage( +Future checkPackage( String packageName, String cleanVersion, String displayVersion, @@ -96,36 +129,69 @@ Future checkPackage( print('📦 Checking $packageName package...'); final reportFile = 'api-compat-$packageName-vs-$displayVersion.md'; - reports.add(reportFile); - - final result = await Process.run('dart-apitool', [ - 'diff', - '--old', - 'pub://$packageName/$cleanVersion', - '--new', - './packages/$packageName', - '--report-format', - 'markdown', - '--report-file-path', - reportFile, - '--ignore-prerelease', - ]); + final report = File(reportFile); + try { + if (report.existsSync()) report.deleteSync(); + } on FileSystemException catch (error) { + stderr.writeln('❌ $packageName: Could not replace $reportFile: $error'); + return false; + } + + final ProcessResult result; + try { + result = await Process.run('dart', [ + 'pub', + 'global', + 'run', + 'dart_apitool:main', + 'diff', + '--old', + 'pub://$packageName/$cleanVersion', + '--new', + './packages/$packageName', + '--report-format', + 'markdown', + '--report-file-path', + reportFile, + '--ignore-prerelease', + ]); + } on ProcessException catch (error) { + stderr.writeln('❌ $packageName: Could not run dart_apitool: $error'); + return false; + } if (result.exitCode == 0) { print('✅ $packageName: API check completed'); } else { - print('⚠️ $packageName: API changes detected'); + stderr.writeln('❌ $packageName: API changes detected or check failed'); + if ((result.stderr as String).isNotEmpty) { + stderr.writeln(result.stderr); + } } - print('📄 Report saved: $reportFile'); + final reportExists = report.existsSync(); + if (reportExists) { + reports.add(reportFile); + print('📄 Report saved: $reportFile'); + } else if (result.exitCode == 0) { + stderr.writeln('❌ $packageName: dart_apitool did not create $reportFile'); + } + return result.exitCode == 0 && reportExists; } -Future runCommand(String command, List args) async { - final result = await Process.run(command, args); - if (result.exitCode != 0) { - print('Error running $command ${args.join(' ')}'); - print(result.stderr); +Future runCommand(String command, List args) async { + try { + final result = await Process.run(command, args); + if (result.exitCode == 0) return true; + + stderr.writeln('Error running $command ${args.join(' ')}'); + if ((result.stderr as String).isNotEmpty) { + stderr.writeln(result.stderr); + } + } on ProcessException catch (error) { + stderr.writeln('Error running $command ${args.join(' ')}: $error'); } + return false; } void printUsage() { @@ -152,6 +218,6 @@ void printUsage() { ); print(''); print('Melos usage:'); - print(' melos api-check ack v0.2.0'); - print(' melos api-check v0.2.0'); + print(' dart run melos run api-check -- ack v0.2.0'); + print(' dart run melos run api-check -- v0.2.0'); } diff --git a/scripts/setup.sh b/scripts/setup.sh deleted file mode 100644 index b4e45138..00000000 --- a/scripts/setup.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -set -e - -echo "🔧 Setting up Dart ACK workspace environment..." - -# Install Dart SDK with proper GPG key handling -echo "📦 Installing Dart SDK..." -sudo apt-get update -sudo apt-get install -y apt-transport-https wget gnupg - -# Add Dart GPG key and repository -wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/dart.gpg -echo 'deb [signed-by=/usr/share/keyrings/dart.gpg arch=amd64] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main' | sudo tee /etc/apt/sources.list.d/dart_stable.list - -# Update package list and install Dart -sudo apt-get update -sudo apt-get install -y dart - -# Install Node.js for JSON Schema validation tools -echo "📦 Installing Node.js..." -curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - -sudo apt-get install -y nodejs - -# Set up PATH for current session and future sessions -export PATH="$PATH:/usr/lib/dart/bin" -export PATH="$PATH:$HOME/.pub-cache/bin" -echo 'export PATH="$PATH:/usr/lib/dart/bin"' >> $HOME/.profile -echo 'export PATH="$PATH:$HOME/.pub-cache/bin"' >> $HOME/.profile - -# Verify installations -echo "✅ Verifying installations..." -dart --version -node --version -npm --version - -# Install Melos globally -echo "📦 Installing Melos..." -dart pub global activate melos - -# Refresh PATH to include newly installed global packages -export PATH="$PATH:$HOME/.pub-cache/bin" - -# Bootstrap the workspace (install dependencies for all packages) -echo "🔄 Bootstrapping Melos workspace..." -melos bootstrap - -# Install Node.js dependencies for JSON Schema validation tools -echo "📦 Installing Node.js dependencies for validation tools..." -cd tools -npm install -cd .. - -echo "✅ Setup complete!" \ No newline at end of file diff --git a/scripts/src/release_changelog.dart b/scripts/src/release_changelog.dart new file mode 100644 index 00000000..3f075fb3 --- /dev/null +++ b/scripts/src/release_changelog.dart @@ -0,0 +1,58 @@ +typedef ChangelogUpdate = ({String content, bool found, bool changed}); + +/// Replaces the exact [version] section with a release-notes link and removes +/// duplicate headings for that same version. +/// +/// Prerelease headings such as `1.0.0-beta.1` do not match version `1.0.0`. +ChangelogUpdate updateReleaseChangelog( + String content, { + required String version, + required String releaseUrl, +}) { + final lines = content.split('\n'); + final headingIndex = lines.indexWhere( + (line) => _isVersionHeading(line, version), + ); + if (headingIndex == -1) { + return (content: content, found: false, changed: false); + } + + final bracketed = lines[headingIndex].trimLeft().startsWith('## ['); + lines[headingIndex] = bracketed ? '## [$version]' : '## $version'; + + var sectionEnd = _nextHeadingIndex(lines, headingIndex + 1); + lines.replaceRange(headingIndex + 1, sectionEnd, [ + '', + '* See [release notes]($releaseUrl) for details.', + '', + ]); + + var duplicateIndex = lines.indexWhere( + (line) => _isVersionHeading(line, version), + headingIndex + 1, + ); + while (duplicateIndex != -1) { + sectionEnd = _nextHeadingIndex(lines, duplicateIndex + 1); + lines.removeRange(duplicateIndex, sectionEnd); + duplicateIndex = lines.indexWhere( + (line) => _isVersionHeading(line, version), + headingIndex + 1, + ); + } + + final updated = lines.join('\n'); + return (content: updated, found: true, changed: updated != content); +} + +bool _isVersionHeading(String line, String version) { + final escapedVersion = RegExp.escape(version); + final pattern = RegExp( + '^##\\s+(?:\\[$escapedVersion\\]|$escapedVersion)(?:\\s|\$)', + ); + return pattern.hasMatch(line); +} + +int _nextHeadingIndex(List lines, int start) { + final index = lines.indexWhere((line) => line.startsWith('## '), start); + return index == -1 ? lines.length : index; +} diff --git a/scripts/update_release_changelog.dart b/scripts/update_release_changelog.dart index 19157807..f6a4b39f 100644 --- a/scripts/update_release_changelog.dart +++ b/scripts/update_release_changelog.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'src/release_changelog.dart'; + /// Updates package changelog entries for the latest release so they contain /// only a link to the GitHub release notes. /// @@ -37,66 +39,43 @@ void main(List args) { 'packages/ack_firebase_ai/CHANGELOG.md', 'packages/ack_json_schema_builder/CHANGELOG.md', ]; + var hasErrors = false; + final updates = <({File file, String path, ChangelogUpdate update})>[]; for (final path in changelogPaths) { final file = File(path); if (!file.existsSync()) { - stderr.writeln('Skipping $path (file not found)'); + stderr.writeln('Could not update $path (file not found)'); + hasErrors = true; continue; } - final lines = file.readAsLinesSync(); - final headingIndex = lines.indexWhere( - (line) => line.startsWith('## ') && line.contains(version), + final original = file.readAsStringSync(); + final update = updateReleaseChangelog( + original, + version: version, + releaseUrl: releaseUrl, ); - if (headingIndex == -1) { + if (!update.found) { stderr.writeln('Warning: Could not find version $version in $path'); + hasErrors = true; continue; } - final currentHeading = lines[headingIndex].trim(); - lines[headingIndex] = currentHeading.startsWith('## [') - ? '## [$version]' - : '## $version'; - - var sectionEnd = headingIndex + 1; - while (sectionEnd < lines.length && !lines[sectionEnd].startsWith('## ')) { - sectionEnd++; - } - - final newSection = [ - '', - '* See [release notes]($releaseUrl) for details.', - '', - ]; + updates.add((file: file, path: path, update: update)); + } + if (hasErrors) { + exitCode = 1; + return; + } - final existingSection = lines.sublist(headingIndex + 1, sectionEnd); - if (!_sectionMatches(existingSection, newSection)) { - lines.replaceRange(headingIndex + 1, sectionEnd, newSection); - stdout.writeln('Updated changelog entry in $path'); + for (final entry in updates) { + if (entry.update.changed) { + entry.file.writeAsStringSync(entry.update.content); + stdout.writeln('Updated changelog entry in ${entry.path}'); } else { - stdout.writeln('No changes required for $path'); + stdout.writeln('No changes required for ${entry.path}'); } - - // Remove any duplicate headings for the same version further down the file. - var duplicateIndex = lines.indexWhere( - (line) => line.startsWith('## ') && line.contains(version), - headingIndex + 1, - ); - while (duplicateIndex != -1) { - var duplicateEnd = duplicateIndex + 1; - while (duplicateEnd < lines.length && - !lines[duplicateEnd].startsWith('## ')) { - duplicateEnd++; - } - lines.removeRange(duplicateIndex, duplicateEnd); - duplicateIndex = lines.indexWhere( - (line) => line.startsWith('## ') && line.contains(version), - headingIndex + 1, - ); - } - - file.writeAsStringSync(lines.join('\n')); } } @@ -114,22 +93,3 @@ String? _readVersionFromPubspec(String path) { } return null; } - -bool _sectionMatches(List existing, List expected) { - List clean(List input) => input - .map((line) => line.trimRight()) - .skipWhile((line) => line.isEmpty) - .toList(); - - final cleanedExisting = clean(existing); - final cleanedExpected = clean(expected); - if (cleanedExisting.length != cleanedExpected.length) { - return false; - } - for (var i = 0; i < cleanedExisting.length; i++) { - if (cleanedExisting[i] != cleanedExpected[i]) { - return false; - } - } - return true; -} diff --git a/setup.sh b/setup.sh index 22c5be4e..3519bacd 100755 --- a/setup.sh +++ b/setup.sh @@ -1,225 +1,39 @@ -#!/bin/bash -set -e - -echo "🔧 Setting up Dart ACK workspace environment..." - -# Colors for output -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# Function to check if command exists -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -# Function to add PATH to shell profile -add_to_path() { - local path_addition="$1" - local profile_file="$HOME/.bashrc" - - if [[ -f "$HOME/.zshrc" ]]; then - profile_file="$HOME/.zshrc" - fi - - if ! grep -q "$path_addition" "$profile_file" 2>/dev/null; then - echo "export PATH=\"\$PATH:$path_addition\"" >> "$profile_file" - echo -e "${GREEN}✓${NC} Added $path_addition to PATH in $profile_file" - fi -} - -# Check and install Dart SDK -install_dart() { - if command_exists dart; then - echo -e "${GREEN}✓${NC} Dart SDK already installed: $(dart --version 2>&1 | head -1)" - return 0 - fi - - echo "📦 Installing Dart SDK..." - - # Method 1: Try APT package manager (recommended for Debian/Ubuntu) - if command_exists apt-get; then - echo " → Attempting installation via APT package manager..." - - # Check if we can reach the internet - if wget -q --spider --timeout=5 https://dl-ssl.google.com 2>/dev/null; then - sudo apt-get update || true - sudo apt-get install -y apt-transport-https wget gnupg || true - - # Add Dart GPG key and repository - wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/dart.gpg 2>/dev/null || true - echo 'deb [signed-by=/usr/share/keyrings/dart.gpg arch=amd64] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main' | sudo tee /etc/apt/sources.list.d/dart_stable.list - - # Update package list and install Dart - sudo apt-get update || true - if sudo apt-get install -y dart; then - export PATH="$PATH:/usr/lib/dart/bin" - add_to_path "/usr/lib/dart/bin" - echo -e "${GREEN}✓${NC} Dart SDK installed via APT" - return 0 - fi - else - echo -e "${YELLOW}⚠${NC} No internet connection for APT installation" - fi - fi - - # Method 2: Download and extract Dart SDK manually - echo " → Attempting direct download and installation..." - - local dart_install_dir="/usr/local/dart-sdk" - local temp_dir="/tmp/dart-install-$$" - - mkdir -p "$temp_dir" - cd "$temp_dir" - - if wget --timeout=30 https://storage.googleapis.com/dart-archive/channels/stable/release/latest/sdk/dartsdk-linux-x64-release.zip -O dart-sdk.zip 2>/dev/null; then - unzip -q dart-sdk.zip - sudo rm -rf "$dart_install_dir" - sudo mv dart-sdk "$dart_install_dir" - - export PATH="$PATH:$dart_install_dir/bin" - add_to_path "$dart_install_dir/bin" - - cd - > /dev/null - rm -rf "$temp_dir" - - echo -e "${GREEN}✓${NC} Dart SDK installed to $dart_install_dir" - return 0 - else - echo -e "${RED}✗${NC} Failed to download Dart SDK" - cd - > /dev/null - rm -rf "$temp_dir" - return 1 - fi -} - -# Check and install Node.js -install_nodejs() { - if command_exists node; then - echo -e "${GREEN}✓${NC} Node.js already installed: $(node --version)" - return 0 - fi - - echo "📦 Installing Node.js..." - - if command_exists apt-get && wget -q --spider --timeout=5 https://deb.nodesource.com 2>/dev/null; then - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt-get install -y nodejs - echo -e "${GREEN}✓${NC} Node.js installed" - else - echo -e "${YELLOW}⚠${NC} Could not install Node.js automatically" - echo " Please install Node.js manually from https://nodejs.org/" - fi -} - -# Main installation flow -echo "" -echo "=== Installing Dependencies ===" -echo "" - -# Install Dart SDK -if ! install_dart; then - echo -e "${RED}✗${NC} Failed to install Dart SDK" - echo " Please install manually: https://dart.dev/get-dart" - exit 1 -fi - -# Install Node.js (optional, for validation tools) -install_nodejs - -# Verify Dart installation -echo "" -echo "=== Verifying Installations ===" -echo "" - -if command_exists dart; then - dart --version +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$repo_root" + +if [[ -x .fvm/flutter_sdk/bin/flutter ]]; then + export PATH="$repo_root/.fvm/flutter_sdk/bin:$PATH" +elif ! command -v flutter >/dev/null 2>&1; then + echo "Flutter is required but was not found." >&2 + echo "Install Flutter or run 'fvm install' when using FVM, then retry." >&2 + exit 1 else - echo -e "${RED}✗${NC} Dart not found in PATH" - echo " Please restart your shell or run: source ~/.bashrc" - exit 1 + flutter_bin="$(dirname "$(command -v flutter)")" + export PATH="$flutter_bin:$PATH" fi -if command_exists node; then - echo "Node: $(node --version)" - echo "NPM: $(npm --version)" -fi - -# Install Melos globally -echo "" -echo "=== Installing Melos ===" -echo "" - -if command_exists melos; then - echo -e "${GREEN}✓${NC} Melos already installed: $(melos --version 2>&1 | head -1)" -else - echo "📦 Installing Melos globally..." - dart pub global activate melos - - # Add pub-cache to PATH - export PATH="$PATH:$HOME/.pub-cache/bin" - add_to_path "$HOME/.pub-cache/bin" - - if command_exists melos; then - echo -e "${GREEN}✓${NC} Melos installed successfully" - else - echo -e "${YELLOW}⚠${NC} Melos installed but not in PATH" - echo " Please restart your shell or run: source ~/.bashrc" - fi +if ! command -v dart >/dev/null 2>&1; then + echo "Dart was not found next to the selected Flutter SDK." >&2 + exit 1 fi -# Bootstrap the workspace -echo "" -echo "=== Bootstrapping Melos Workspace ===" -echo "" - -cd "$(dirname "$0")" - -if command_exists melos; then - melos bootstrap - echo -e "${GREEN}✓${NC} Workspace bootstrapped" -else - echo -e "${YELLOW}⚠${NC} Melos not available, skipping bootstrap" - echo " Please restart your shell and run: melos bootstrap" -fi +flutter --version +dart --version -# Install Node.js dependencies for validation tools -if [ -d "tools" ] && [ -f "tools/package.json" ]; then - echo "" - echo "=== Installing Validation Tools ===" - echo "" +echo "Resolving workspace dependencies..." +dart pub get +dart run melos bootstrap - if command_exists npm; then - cd tools - npm install - cd .. - echo -e "${GREEN}✓${NC} Validation tools installed" - else - echo -e "${YELLOW}⚠${NC} NPM not available, skipping validation tools" - fi +if [[ -f tools/npm-shrinkwrap.json ]]; then + if command -v npm >/dev/null 2>&1; then + echo "Installing JSON Schema validation tools..." + npm ci --prefix tools + else + echo "Node.js/npm not found; skipping optional JSON Schema tooling." >&2 + fi fi -# Final summary -echo "" -echo "=========================================" -echo -e "${GREEN}✅ Setup Complete!${NC}" -echo "=========================================" -echo "" -echo "Installed components:" -if command_exists dart; then - echo " ✓ Dart SDK: $(dart --version 2>&1 | head -1)" -fi -if command_exists node; then - echo " ✓ Node.js: $(node --version)" -fi -if command_exists melos; then - echo " ✓ Melos: $(melos --version 2>&1 | head -1)" -fi -echo "" -echo "Next steps:" -echo " 1. Restart your terminal or run: source ~/.bashrc" -echo " 2. Verify installation: dart --version && melos --version" -echo " 3. Run tests: melos test" -echo " 4. See all available commands: melos list-scripts" -echo "" +echo "Setup complete. Run 'dart run melos run --list' to see workspace scripts." diff --git a/test/scripts/api_check_test.dart b/test/scripts/api_check_test.dart new file mode 100644 index 00000000..75cebb65 --- /dev/null +++ b/test/scripts/api_check_test.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + test( + 'activation failures stop API checks with a non-zero exit code', + () async { + final fakeBin = Directory.systemTemp.createTempSync('ack-api-check-'); + addTearDown(() => fakeBin.deleteSync(recursive: true)); + + _writeExecutable(fakeBin, 'dart', '#!/bin/sh\nexit 23\n'); + + final result = await Process.run( + Platform.resolvedExecutable, + ['scripts/api_check.dart', 'ack', '1.0.0'], + environment: { + ...Platform.environment, + 'PATH': '${fakeBin.path}:/usr/bin:/bin', + }, + ); + + expect(result.exitCode, 1); + expect(result.stderr, contains('Unable to activate dart_apitool')); + expect(result.stdout, isNot(contains('Checking ack package'))); + }, + skip: Platform.isWindows ? 'Uses POSIX test executables.' : false, + ); + + test( + 'stale reports do not satisfy the current API check', + () async { + final fakeBin = Directory.systemTemp.createTempSync('ack-api-check-'); + final workingDirectory = Directory.systemTemp.createTempSync( + 'ack-api-report-', + ); + addTearDown(() => fakeBin.deleteSync(recursive: true)); + addTearDown(() => workingDirectory.deleteSync(recursive: true)); + + _writeExecutable(fakeBin, 'dart', '#!/bin/sh\nexit 0\n'); + final staleReport = File( + '${workingDirectory.path}/api-compat-ack-vs-1.0.0.md', + )..writeAsStringSync('stale report'); + final scriptPath = File('scripts/api_check.dart').absolute.path; + + final result = await Process.run( + Platform.resolvedExecutable, + [scriptPath, 'ack', '1.0.0'], + workingDirectory: workingDirectory.path, + environment: { + ...Platform.environment, + 'PATH': '${fakeBin.path}:/usr/bin:/bin', + }, + ); + + expect(result.exitCode, 1); + expect(result.stderr, contains('did not create')); + expect(staleReport.existsSync(), isFalse); + }, + skip: Platform.isWindows ? 'Uses POSIX test executables.' : false, + ); +} + +void _writeExecutable(Directory directory, String name, String contents) { + final file = File('${directory.path}/$name')..writeAsStringSync(contents); + final chmod = Process.runSync('chmod', ['+x', file.path]); + expect(chmod.exitCode, 0); +} diff --git a/test/scripts/release_changelog_test.dart b/test/scripts/release_changelog_test.dart new file mode 100644 index 00000000..e05066f5 --- /dev/null +++ b/test/scripts/release_changelog_test.dart @@ -0,0 +1,141 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../../scripts/src/release_changelog.dart'; + +void main() { + group('updateReleaseChangelog', () { + test('matches stable headings exactly and preserves prereleases', () { + const input = ''' +## 1.0.0 + +Old stable notes. + +## 1.0.0-beta.2 + +Keep beta two. + +## 1.0.0-beta.1 + +Keep beta one. + +## 1.0.0 + +Duplicate stable notes. +'''; + + final result = updateReleaseChangelog( + input, + version: '1.0.0', + releaseUrl: 'https://example.test/v1.0.0', + ); + + expect(result.found, isTrue); + expect(result.changed, isTrue); + expect(result.content, contains('## 1.0.0-beta.2')); + expect(result.content, contains('Keep beta two.')); + expect(result.content, contains('## 1.0.0-beta.1')); + expect(result.content, contains('Keep beta one.')); + expect( + RegExp(r'^## 1\.0\.0$', multiLine: true).allMatches(result.content), + hasLength(1), + ); + expect( + result.content, + contains( + '* See [release notes](https://example.test/v1.0.0) for details.', + ), + ); + }); + + test('preserves bracket heading style', () { + const input = '## [2.0.0] - 2026-01-01\n\nNotes.\n'; + + final result = updateReleaseChangelog( + input, + version: '2.0.0', + releaseUrl: 'https://example.test/v2.0.0', + ); + + expect(result.content, startsWith('## [2.0.0]\n')); + }); + + test('reports a missing exact version without modifying content', () { + const input = '## 1.0.0-beta.1\n\nNotes.\n'; + + final result = updateReleaseChangelog( + input, + version: '1.0.0', + releaseUrl: 'https://example.test/v1.0.0', + ); + + expect(result.found, isFalse); + expect(result.changed, isFalse); + expect(result.content, input); + }); + + test('is idempotent after the release link is installed', () { + const input = '## 3.0.0\n\nOriginal notes.\n'; + const releaseUrl = 'https://example.test/v3.0.0'; + + final first = updateReleaseChangelog( + input, + version: '3.0.0', + releaseUrl: releaseUrl, + ); + final second = updateReleaseChangelog( + first.content, + version: '3.0.0', + releaseUrl: releaseUrl, + ); + + expect(first.changed, isTrue); + expect(second.found, isTrue); + expect(second.changed, isFalse); + expect(second.content, first.content); + }); + }); + + test('failed batches leave every changelog unchanged', () async { + final scriptPath = File( + 'scripts/update_release_changelog.dart', + ).absolute.path; + final workingDirectory = Directory.systemTemp.createTempSync( + 'ack-release-changelog-', + ); + addTearDown(() => workingDirectory.deleteSync(recursive: true)); + + const paths = [ + 'packages/ack/CHANGELOG.md', + 'packages/ack_annotations/CHANGELOG.md', + 'packages/ack_generator/CHANGELOG.md', + 'packages/ack_firebase_ai/CHANGELOG.md', + 'packages/ack_json_schema_builder/CHANGELOG.md', + ]; + final originals = {}; + for (final path in paths) { + final content = path.contains('ack_annotations') + ? '## 0.9.0\n\nOlder notes.\n' + : '## 1.0.0\n\nRelease notes.\n'; + originals[path] = content; + File('${workingDirectory.path}/$path') + ..createSync(recursive: true) + ..writeAsStringSync(content); + } + + final result = await Process.run(Platform.resolvedExecutable, [ + scriptPath, + '1.0.0', + ], workingDirectory: workingDirectory.path); + + expect(result.exitCode, 1); + for (final path in paths) { + expect( + File('${workingDirectory.path}/$path').readAsStringSync(), + originals[path], + reason: '$path should not change when batch validation fails', + ); + } + }); +} diff --git a/tools/README.md b/tools/README.md index 5bec8329..467f0a94 100644 --- a/tools/README.md +++ b/tools/README.md @@ -18,7 +18,7 @@ Install Node.js dependencies: ```bash cd tools -npm install +npm ci ``` ## JSON Schema Validator @@ -125,4 +125,4 @@ The validator outputs structured JSON results: The JSON Schema validation tool is integrated into the Melos workflow with `validate-jsonschema` scripts. -See the main project's `melos.yaml` for all available commands. +Run `dart run melos run --list` from the workspace root for all available commands. diff --git a/tools/npm-shrinkwrap.json b/tools/npm-shrinkwrap.json index 0098d8db..ef38d294 100644 --- a/tools/npm-shrinkwrap.json +++ b/tools/npm-shrinkwrap.json @@ -23,9 +23,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -71,9 +71,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "funding": [ { "type": "github", @@ -102,9 +102,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" From 110a1673d8ea1de2deca9930248a03e36f812aac Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 12 Jul 2026 10:47:34 -0400 Subject: [PATCH 2/4] refactor: consolidate duplicated guards, error construction, and package list Behavior-preserving simplifications on top of the validation/schema/tooling fixes. Each item removes a single source of duplication or redundant state: - ack.dart: extract _requireNonEmpty/_requireUniqueBy for the enumValues, enumString, and anyOf argument guards - schema.dart: extract _failFromThrown for the three thrown-error catch blocks - codec_schema.dart: drop redundant _encoderIdentity (provably identical to _encoder in every path; _decoderIdentity retained as it fixes a real bug) - lazy_schema.dart: drop write-only _LazyRecursionContext.owner (duplicated the inherited SchemaContext.schema) - ack_schema_model_builder.dart: hoist a single const _deepEquality - schema_ast_analyzer.dart: extract _rejectIfReferencesNullableSchema (2 sites) - api_check.dart: extract _writeProcessStderr - update_release_changelog.dart: drop redundant record `path` field - scripts: share publishableAckPackages across api_check and update_release_changelog (new scripts/src/workspace_packages.dart) - enum_schema/any_of_schema: point docs to the canonical AckSchema policy dart analyze clean; ack (955) + ack_generator (127) + scripts tests pass. --- packages/ack/lib/src/ack.dart | 39 ++++++++------ .../ack_schema_model_builder.dart | 8 +-- .../ack/lib/src/schemas/any_of_schema.dart | 2 +- .../ack/lib/src/schemas/codec_schema.dart | 12 ++--- packages/ack/lib/src/schemas/enum_schema.dart | 5 +- packages/ack/lib/src/schemas/lazy_schema.dart | 7 ++- packages/ack/lib/src/schemas/schema.dart | 52 +++++++++++-------- .../lib/src/analyzer/schema_ast_analyzer.dart | 26 +++++----- scripts/api_check.dart | 24 ++++----- scripts/src/workspace_packages.dart | 8 +++ scripts/update_release_changelog.dart | 19 +++---- 11 files changed, 108 insertions(+), 94 deletions(-) create mode 100644 scripts/src/workspace_packages.dart diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 57df7d43..feece745 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -52,13 +52,8 @@ final class Ack { /// Creates an enum schema for validating enum values. static EnumSchema enumValues(List values) { - if (values.isEmpty) { - throw ArgumentError.value(values, 'values', 'Must not be empty.'); - } - final names = values.map((value) => value.name).toSet(); - if (names.length != values.length) { - throw ArgumentError.value(values, 'values', 'Must be unique.'); - } + _requireNonEmpty(values, 'values'); + _requireUniqueBy(values, 'values', (value) => value.name); return EnumSchema(values: List.unmodifiable(values)); } @@ -79,12 +74,8 @@ final class Ack { /// Creates a string schema that only accepts one of the given [values]. static StringSchema enumString(List values) { - if (values.isEmpty) { - throw ArgumentError.value(values, 'values', 'Must not be empty.'); - } - if (values.toSet().length != values.length) { - throw ArgumentError.value(values, 'values', 'Must be unique.'); - } + _requireNonEmpty(values, 'values'); + _requireUniqueBy(values, 'values', (value) => value); return string().withConstraint( PatternConstraint.enumString(List.unmodifiable(values)), ); @@ -92,9 +83,7 @@ final class Ack { /// Creates a schema that can be one of many types. static AnyOfSchema anyOf(List schemas) { - if (schemas.isEmpty) { - throw ArgumentError.value(schemas, 'schemas', 'Must not be empty.'); - } + _requireNonEmpty(schemas, 'schemas'); return AnyOfSchema(List.unmodifiable(schemas)); } @@ -269,3 +258,21 @@ String _encodeIsoDate(DateTime value) { String _encodeIsoDateTime(DateTime value) { return value.toIso8601String(); } + +List _requireNonEmpty(List values, String name) { + if (values.isEmpty) { + throw ArgumentError.value(values, name, 'Must not be empty.'); + } + return values; +} + +List _requireUniqueBy( + List values, + String name, + K Function(T) keyOf, +) { + if (values.map(keyOf).toSet().length != values.length) { + throw ArgumentError.value(values, name, 'Must be unique.'); + } + return values; +} diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index a9cd35fe..d408c0c5 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -10,6 +10,8 @@ import '../schemas/schema.dart'; import 'ack_schema_model.dart'; import 'ack_schema_model_warning.dart'; +const _deepEquality = DeepCollectionEquality(); + extension AckSchemaModelExtension< Boundary extends Object, Runtime extends Object @@ -47,10 +49,9 @@ final class _SchemaModelBuilder { merged[key] = entry.value; } - const equality = DeepCollectionEquality(); for (final entry in lazyDefinitions.entries) { if (merged.containsKey(entry.key)) { - if (!equality.equals(merged[entry.key], entry.value)) { + if (!_deepEquality.equals(merged[entry.key], entry.value)) { throw ArgumentError( 'Ack.lazy definition "${entry.key}" collides with an existing root ' 'JSON Schema definition. Use a unique lazy name or rename the ' @@ -353,7 +354,6 @@ AckSchemaModel _applyConstraints( AckSchema schema, ) { var next = model; - const deepEquality = DeepCollectionEquality(); final appliedKeywordValues = { for (final entry in _renderedKeywords(model).entries) entry.key: [entry.value], @@ -374,7 +374,7 @@ AckSchemaModel _applyConstraints( appliedKeywordValues[entry.key] = [entry.value]; newKeywords[entry.key] = entry.value; } else if (!seenValues.any( - (value) => deepEquality.equals(value, entry.value), + (value) => _deepEquality.equals(value, entry.value), )) { seenValues.add(entry.value); conflicts[entry.key] = entry.value; diff --git a/packages/ack/lib/src/schemas/any_of_schema.dart b/packages/ack/lib/src/schemas/any_of_schema.dart index 1c73d578..5ce93455 100644 --- a/packages/ack/lib/src/schemas/any_of_schema.dart +++ b/packages/ack/lib/src/schemas/any_of_schema.dart @@ -14,7 +14,7 @@ final class AnyOfSchema extends AckSchema /// Creates a low-level union from an immutable, non-empty list of [schemas]. /// - /// Prefer `Ack.anyOf`, which validates and snapshots caller-owned lists. + /// See [AckSchema] for the low-level-constructor policy; prefer [Ack.anyOf]. /// Direct callers must not mutate [schemas] after construction. const AnyOfSchema( this.schemas, { diff --git a/packages/ack/lib/src/schemas/codec_schema.dart b/packages/ack/lib/src/schemas/codec_schema.dart index 9ac8b54e..46cfb33b 100644 --- a/packages/ack/lib/src/schemas/codec_schema.dart +++ b/packages/ack/lib/src/schemas/codec_schema.dart @@ -25,7 +25,6 @@ final class CodecSchema final Runtime Function(Object value) _decoder; final Object Function(Runtime value)? _encoder; final Object _decoderIdentity; - final Object? _encoderIdentity; CodecSchema._({ required this.inputSchema, @@ -33,7 +32,6 @@ final class CodecSchema required Runtime Function(Object value) decoder, required Object Function(Runtime value)? encoder, required Object decoderIdentity, - required Object? encoderIdentity, super.isNullable, super.isOptional, super.description, @@ -41,8 +39,7 @@ final class CodecSchema super.refinements, }) : _decoder = decoder, _encoder = encoder, - _decoderIdentity = decoderIdentity, - _encoderIdentity = encoderIdentity; + _decoderIdentity = decoderIdentity; /// Creates a codec while preserving the input schema's runtime type. static CodecSchema create< @@ -66,7 +63,6 @@ final class CodecSchema decoder: (value) => decoder(value as InputRuntime), encoder: encoder, decoderIdentity: decoder, - encoderIdentity: encoder, isNullable: isNullable, isOptional: isOptional, description: description, @@ -169,7 +165,6 @@ final class CodecSchema decoder: _decoder, encoder: _encoder, decoderIdentity: _decoderIdentity, - encoderIdentity: _encoderIdentity, isNullable: isNullable, isOptional: isOptional, description: description, @@ -192,7 +187,6 @@ final class CodecSchema decoder: _decoder, encoder: _encoder, decoderIdentity: _decoderIdentity, - encoderIdentity: _encoderIdentity, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, @@ -210,7 +204,7 @@ final class CodecSchema inputSchema == other.inputSchema && outputSchema == other.outputSchema && _decoderIdentity == other._decoderIdentity && - _encoderIdentity == other._encoderIdentity; + _encoder == other._encoder; } @override @@ -225,6 +219,6 @@ final class CodecSchema inputSchema, outputSchema, _decoderIdentity, - _encoderIdentity, + _encoder, ); } diff --git a/packages/ack/lib/src/schemas/enum_schema.dart b/packages/ack/lib/src/schemas/enum_schema.dart index 35af5e63..8e5da20c 100644 --- a/packages/ack/lib/src/schemas/enum_schema.dart +++ b/packages/ack/lib/src/schemas/enum_schema.dart @@ -10,8 +10,9 @@ final class EnumSchema extends AckSchema /// Creates a low-level enum schema from an immutable, non-empty set of /// uniquely named [values]. /// - /// Prefer `Ack.enumValues`, which validates and snapshots caller-owned lists. - /// Direct callers must not mutate [values] after construction. + /// See [AckSchema] for the low-level-constructor policy; prefer + /// [Ack.enumValues]. Direct callers must not mutate [values] after + /// construction. const EnumSchema({ required this.values, super.isNullable, diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index da87c880..a79e61a2 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -132,7 +132,7 @@ final class LazySchema SchemaContext _enterRecursion(Object? value, SchemaContext context) { return _LazyRecursionContext( name: name, - owner: this as AnyAckSchema, + schema: this as AnyAckSchema, recursionToken: _recursionToken, value: value, parent: context, @@ -222,20 +222,19 @@ final class LazySchema final class _LazyRecursionContext extends SchemaContext { _LazyRecursionContext({ required String name, - required this.owner, + required AnyAckSchema schema, required this.recursionToken, required Object? value, required SchemaContext parent, }) : super( name: name, - schema: owner, + schema: schema, value: value, parent: parent, pathSegment: '', operation: parent.operation, ); - final AnyAckSchema owner; final Object recursionToken; } diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 148cd75d..8ad0147b 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -150,13 +150,11 @@ abstract class AckSchema { final violation = constraint.validate(value); if (violation != null) constraintViolations.add(violation); } catch (error, stackTrace) { - return SchemaResult.fail( - SchemaValidationError( - message: 'Constraint "${constraint.constraintKey}" threw: $error', - context: context, - cause: error, - stackTrace: stackTrace, - ), + return _failFromThrown( + 'Constraint "${constraint.constraintKey}" threw: $error', + context, + error, + stackTrace, ); } } @@ -177,13 +175,11 @@ abstract class AckSchema { try { isValid = refinement.validate(value); } catch (error, stackTrace) { - return SchemaResult.fail( - SchemaValidationError( - message: 'Refinement threw: $error', - context: context, - cause: error, - stackTrace: stackTrace, - ), + return _failFromThrown( + 'Refinement threw: $error', + context, + error, + stackTrace, ); } if (!isValid) { @@ -196,6 +192,22 @@ abstract class AckSchema { return SchemaResult.ok(value); } + SchemaResult _failFromThrown( + String message, + SchemaContext context, + Object error, + StackTrace stackTrace, + ) { + return SchemaResult.fail( + SchemaValidationError( + message: message, + context: context, + cause: error, + stackTrace: stackTrace, + ), + ); + } + /// Helper for schemas whose boundary == runtime: validates the runtime /// value and, if it passes, returns it as the boundary value unchanged. /// Only safe to call when `Boundary` and `Runtime` are the same type. @@ -361,13 +373,11 @@ abstract class AckSchema { try { return parseWithContext(value, context); } catch (error, stackTrace) { - return SchemaResult.fail( - SchemaValidationError( - message: 'Validation threw: $error', - context: context, - cause: error, - stackTrace: stackTrace, - ), + return _failFromThrown( + 'Validation threw: $error', + context, + error, + stackTrace, ); } } diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart index 03f4656c..cc88f728 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart @@ -2098,14 +2098,7 @@ class SchemaAstAnalyzer { } if (chain.schemaReference != null) { - final resolved = _resolveSchemaReference( - chain.schemaReference!, - element, - ); - _rejectNullableListElement( - resolved?.modelInfo.isNullableSchema ?? false, - element, - ); + _rejectIfReferencesNullableSchema(chain.schemaReference!, element); final mapping = _resolveSchemaVariableType( chain.schemaReference!, element, @@ -2168,11 +2161,7 @@ class SchemaAstAnalyzer { } if (ref.schemaRef != null) { - final resolved = _resolveSchemaReference(ref.schemaRef!, element); - _rejectNullableListElement( - resolved?.modelInfo.isNullableSchema ?? false, - element, - ); + _rejectIfReferencesNullableSchema(ref.schemaRef!, element); final mapping = _resolveSchemaVariableType( ref.schemaRef!, element, @@ -2207,6 +2196,17 @@ class SchemaAstAnalyzer { ); } + void _rejectIfReferencesNullableSchema( + _SchemaReference reference, + Element2 element, + ) { + final resolved = _resolveSchemaReference(reference, element); + _rejectNullableListElement( + resolved?.modelInfo.isNullableSchema ?? false, + element, + ); + } + _SchemaTypeMapping _wrapListElementMapping( _SchemaTypeMapping elementMapping, TypeProvider typeProvider, diff --git a/scripts/api_check.dart b/scripts/api_check.dart index 1d5f22ea..6bbfdbe9 100755 --- a/scripts/api_check.dart +++ b/scripts/api_check.dart @@ -3,13 +3,9 @@ import 'dart:convert'; import 'dart:io'; -const ackPackages = [ - 'ack', - 'ack_annotations', - 'ack_generator', - 'ack_firebase_ai', - 'ack_json_schema_builder', -]; +import 'src/workspace_packages.dart'; + +final ackPackages = publishableAckPackages; const dartApiToolVersion = '0.23.0'; Future main(List args) async { @@ -164,9 +160,7 @@ Future checkPackage( print('✅ $packageName: API check completed'); } else { stderr.writeln('❌ $packageName: API changes detected or check failed'); - if ((result.stderr as String).isNotEmpty) { - stderr.writeln(result.stderr); - } + _writeProcessStderr(result); } final reportExists = report.existsSync(); @@ -185,15 +179,19 @@ Future runCommand(String command, List args) async { if (result.exitCode == 0) return true; stderr.writeln('Error running $command ${args.join(' ')}'); - if ((result.stderr as String).isNotEmpty) { - stderr.writeln(result.stderr); - } + _writeProcessStderr(result); } on ProcessException catch (error) { stderr.writeln('Error running $command ${args.join(' ')}: $error'); } return false; } +void _writeProcessStderr(ProcessResult result) { + if ((result.stderr as String).isNotEmpty) { + stderr.writeln(result.stderr); + } +} + void printUsage() { print(''); print('Usage: dart scripts/api_check.dart [PACKAGE] [VERSION]'); diff --git a/scripts/src/workspace_packages.dart b/scripts/src/workspace_packages.dart new file mode 100644 index 00000000..908a3138 --- /dev/null +++ b/scripts/src/workspace_packages.dart @@ -0,0 +1,8 @@ +/// The publishable ack packages in the melos workspace, in publish order. +const publishableAckPackages = [ + 'ack', + 'ack_annotations', + 'ack_generator', + 'ack_firebase_ai', + 'ack_json_schema_builder', +]; diff --git a/scripts/update_release_changelog.dart b/scripts/update_release_changelog.dart index f6a4b39f..6dac3920 100644 --- a/scripts/update_release_changelog.dart +++ b/scripts/update_release_changelog.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'src/release_changelog.dart'; +import 'src/workspace_packages.dart'; /// Updates package changelog entries for the latest release so they contain /// only a link to the GitHub release notes. @@ -32,15 +33,11 @@ void main(List args) { : 'v$version'; final releaseUrl = 'https://github.com/btwld/ack/releases/tag/$tag'; - final changelogPaths = [ - 'packages/ack/CHANGELOG.md', - 'packages/ack_annotations/CHANGELOG.md', - 'packages/ack_generator/CHANGELOG.md', - 'packages/ack_firebase_ai/CHANGELOG.md', - 'packages/ack_json_schema_builder/CHANGELOG.md', - ]; + final changelogPaths = publishableAckPackages + .map((p) => 'packages/$p/CHANGELOG.md') + .toList(); var hasErrors = false; - final updates = <({File file, String path, ChangelogUpdate update})>[]; + final updates = <({File file, ChangelogUpdate update})>[]; for (final path in changelogPaths) { final file = File(path); @@ -62,7 +59,7 @@ void main(List args) { continue; } - updates.add((file: file, path: path, update: update)); + updates.add((file: file, update: update)); } if (hasErrors) { exitCode = 1; @@ -72,9 +69,9 @@ void main(List args) { for (final entry in updates) { if (entry.update.changed) { entry.file.writeAsStringSync(entry.update.content); - stdout.writeln('Updated changelog entry in ${entry.path}'); + stdout.writeln('Updated changelog entry in ${entry.file.path}'); } else { - stdout.writeln('No changes required for ${entry.path}'); + stdout.writeln('No changes required for ${entry.file.path}'); } } } From 684ae13b7f0a55fa0d421ac2334f74791f53e20a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 12 Jul 2026 11:50:42 -0400 Subject: [PATCH 3/4] docs: clarify multipleOf tolerance and label behavior changes Split runtime behavior changes out of the Fixed/Changed changelog sections into dedicated "Behavior changes" sections with migration notes, noting no public API changed (verified against 1.0.1 with dart_apitool). Document that multipleOf checks integers exactly and doubles with a scale-relative (~4 ULP) tolerance, and record the rationale for the relative tolerance as a code comment so it is not reverted to a fixed epsilon. --- CHANGELOG.md | 4 ++ docs/core-concepts/validation.mdx | 2 +- packages/ack/CHANGELOG.md | 37 +++++++++++++------ .../constraints/comparison_constraint.dart | 5 +++ .../extensions/numeric_extensions.dart | 10 +++++ packages/ack_generator/CHANGELOG.md | 9 ++++- 6 files changed, 53 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edf597d..6558f5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline * Bound lazy recursion through wrappers and fluent copies, align RFC date-time and IPv6 behavior, run root tooling tests in CI, prevent stale API reports, and avoid partial changelog updates when validation fails. +* **Behavior changes** (no API break): fail-fast schema construction, stricter + `multipleOf`/IPv6/date-time validation, `toJsonSchema()` keyword merging, and + generator rejection of nullable list elements. See the `ack` and + `ack_generator` changelogs for per-package details and migration notes. ## 1.0.0 - 2026-06-26 diff --git a/docs/core-concepts/validation.mdx b/docs/core-concepts/validation.mdx index 33943181..5d81f584 100644 --- a/docs/core-concepts/validation.mdx +++ b/docs/core-concepts/validation.mdx @@ -222,7 +222,7 @@ Ack.number().lessThan(100) // < 100 ``` ### `multipleOf(num factor)` -Requires a value that is a multiple of `factor`. +Requires a value that is a multiple of `factor`. Integer checks are exact; doubles use a small scale-relative tolerance, so round accumulated floating-point sums (or validate integer units such as cents) before parsing. ```dart Ack.integer().multipleOf(5) // Must be divisible by 5 Ack.double().multipleOf(0.5) // Use factors that avoid floating point rounding issues diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 0027a387..ba32d7cb 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -2,18 +2,33 @@ ### Fixed -* Keep `safeParse` non-throwing when refinements fail with exceptions. -* Validate numeric `multipleOf`, IPv6, and RFC 3339 date-time edge cases - strictly; preserve announced leap seconds in string schemas while rejecting - them in `Ack.datetime()`, where Dart cannot represent them; and reject invalid - constraint configuration at schema construction. -* Snapshot factory collections; reject empty unions and empty or duplicate - enum inputs. -* Preserve intersecting constraints in JSON Schema output and correct - behavior-based schema/deep-collection equality. -* Reject union schemas that can produce nullable list items. +* Keep `safeParse` non-throwing when refinements or constraints throw exceptions. * Bound direct, indirect, wrapper-mediated, and fluent-copy lazy-schema alias - recursion. + recursion (previously unbounded and prone to stack overflow). +* Snapshot factory collections so a caller mutating the passed list or map can no + longer corrupt a constructed schema. +* Correct behavior-based schema and deep-collection equality. + +### Behavior changes + +No public API changed (verified with `dart_apitool` against 1.0.1); the following +now reject inputs that previously passed or misbehaved silently. + +* Validate numeric `multipleOf`, IPv6, and RFC 3339 date-time values strictly. + Announced leap seconds are preserved by `Ack.string().datetime()` but rejected + by `Ack.datetime()`, where Dart cannot represent them. *(migration: some + previously-accepted strings and numbers now fail validation.)* +* Reject invalid constraint configuration at construction — negative + lengths/item counts, non-finite numeric bounds, `min > max` ranges, + `multipleOf <= 0`, empty unions, empty or duplicate enum inputs, and unions + that can yield nullable list items — all throw `ArgumentError`. *(migration: + fix the schema definition; put nullability on the list via + `Ack.list(item).nullable()`.)* +* `toJsonSchema()` merges conflicting duplicate keywords into `allOf` and emits + `min/maxItems` and `min/maxProperties` for exact counts. *(migration: refresh + snapshot or golden tests of exported schemas.)* +* `parse()` throws `AckException` — instead of the raw callback error — when a + constraint or refinement throws. ## 1.0.1 diff --git a/packages/ack/lib/src/constraints/comparison_constraint.dart b/packages/ack/lib/src/constraints/comparison_constraint.dart index 8c6eb279..0aa1041e 100644 --- a/packages/ack/lib/src/constraints/comparison_constraint.dart +++ b/packages/ack/lib/src/constraints/comparison_constraint.dart @@ -288,6 +288,11 @@ class ComparisonConstraint extends Constraint return extracted.remainder(multipleValue!) == 0; } + // Deliberate: scale the tolerance to the reconstructed value rather + // than using a fixed absolute epsilon. A constant epsilon (the former + // 1e-10) accepts every value once the divisor drops below it and is + // meaningless at large magnitudes; a relative tolerance stays correct + // across scales. Do not revert to a fixed epsilon. final quotient = extracted / multipleValue!; if (!quotient.isFinite) return false; diff --git a/packages/ack/lib/src/schemas/extensions/numeric_extensions.dart b/packages/ack/lib/src/schemas/extensions/numeric_extensions.dart index 5efcb701..c2e4ebe9 100644 --- a/packages/ack/lib/src/schemas/extensions/numeric_extensions.dart +++ b/packages/ack/lib/src/schemas/extensions/numeric_extensions.dart @@ -36,6 +36,8 @@ extension IntegerSchemaExtensions on IntegerSchema { } /// Adds a constraint that the integer must be a multiple of [n]. + /// + /// Integer multiples are checked exactly. IntegerSchema multipleOf(int n) { return withConstraint(ComparisonConstraint.numberMultipleOf(n)); } @@ -80,6 +82,10 @@ extension DoubleSchemaExtensions on DoubleSchema { } /// Adds a constraint that the double must be a multiple of [n]. + /// + /// Doubles are compared with a small scale-relative tolerance (~4 ULP), so + /// round accumulated floating-point sums — or validate integer units such as + /// cents — before parsing. DoubleSchema multipleOf(double n) { return withConstraint(ComparisonConstraint.numberMultipleOf(n)); } @@ -123,6 +129,10 @@ extension NumberSchemaExtensions on NumberSchema { } /// Adds a constraint that the number must be a multiple of [n]. + /// + /// Integer values and factors are checked exactly; doubles use a small + /// scale-relative tolerance (~4 ULP), so round accumulated floating-point sums + /// before parsing. NumberSchema multipleOf(num n) { return withConstraint(ComparisonConstraint.numberMultipleOf(n)); } diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 203651ce..b9b2bb1e 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -4,10 +4,15 @@ * Use the current source_gen generation error type and keep diagnostic output in the build pipeline instead of writing undeclared debug files. -* Reject direct and referenced nullable list element schemas during generation, - matching the runtime schema contract. * Remove obsolete exploratory and golden-test utilities. +### Behavior changes + +* Reject direct and referenced nullable list element schemas during generation, + matching the runtime schema contract. *(migration: a previously-succeeding + `Ack.list(x.nullable())` build now fails; put nullability on the list with + `Ack.list(x).nullable()`.)* + ## 1.0.1 * See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.1) for details. From 1ade7d16e37f2596adbeb241bab61702ba8ea2dd Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 12 Jul 2026 12:26:19 -0400 Subject: [PATCH 4/4] fix: address remaining validation review findings --- packages/ack/CHANGELOG.md | 7 +- packages/ack/lib/src/ack.dart | 11 ++- packages/ack/lib/src/schemas/schema.dart | 8 +++ .../ack/test/reference_semantics_test.dart | 32 ++++++++- .../schemas/datetime_validation_test.dart | 12 ++++ packages/ack/test/schemas/refine_test.dart | 13 +++- .../integration/ack_type_golden_test.dart | 70 +++++++++++++++++++ 7 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 packages/ack_generator/test/integration/ack_type_golden_test.dart diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index ba32d7cb..153c9e91 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -2,7 +2,12 @@ ### Fixed -* Keep `safeParse` non-throwing when refinements or constraints throw exceptions. +* Keep `safeParse` non-throwing when refinements or constraints throw + recoverable `Exception`s, while preserving `Error` values such as + `StateError` so programming defects are not masked as validation failures. +* Decode RFC 3339 lowercase `t`/`z` separators in `Ack.datetime()`, which its + own string validation already accepts (previously they failed at the decode + step with a misleading "Codec decode failed" message). * Bound direct, indirect, wrapper-mediated, and fluent-copy lazy-schema alias recursion (previously unbounded and prone to stack overflow). * Snapshot factory collections so a caller mutating the passed list or map can no diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index feece745..9dec6078 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -182,7 +182,7 @@ final class Ack { _isUtcDateTime, message: 'Expected a UTC DateTime.', ), - decoder: DateTime.parse, + decoder: _decodeIso8601DateTime, encoder: _encodeIsoDateTime, ); } @@ -259,6 +259,15 @@ String _encodeIsoDateTime(DateTime value) { return value.toIso8601String(); } +DateTime _decodeIso8601DateTime(String value) { + // The input schema accepts RFC 3339's lowercase `t`/`z` separators, but + // `DateTime.parse` only accepts the uppercase forms. Normalize so the codec + // decodes every string its own format validation admitted. The value is + // already known to match the ISO 8601 pattern, whose only letters are these + // separators, so upper-casing cannot corrupt any other field. + return DateTime.parse(value.toUpperCase()); +} + List _requireNonEmpty(List values, String name) { if (values.isEmpty) { throw ArgumentError.value(values, name, 'Must not be empty.'); diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 8ad0147b..7e6e3f63 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -198,6 +198,14 @@ abstract class AckSchema { Object error, StackTrace stackTrace, ) { + // Callback failures that are not [Error] values become validation failures. + // An [Error] (for example, `StateError`, `TypeError`, or a VM error) signals + // a programmer or runtime defect rather than invalid input. Preserve its + // identity and original stack trace instead of disguising it as a failed + // validation result. + if (error is Error) { + Error.throwWithStackTrace(error, stackTrace); + } return SchemaResult.fail( SchemaValidationError( message: message, diff --git a/packages/ack/test/reference_semantics_test.dart b/packages/ack/test/reference_semantics_test.dart index e5130bfa..acb35fe4 100644 --- a/packages/ack/test/reference_semantics_test.dart +++ b/packages/ack/test/reference_semantics_test.dart @@ -115,8 +115,8 @@ void main() { }); }); - group('total validation', () { - test('preserves nested context when a constraint throws', () { + group('callback failure handling', () { + test('preserves nested context when a constraint throws an Exception', () { final schema = Ack.object({ 'name': Ack.string().constrain(const _ThrowingConstraint()), }); @@ -128,7 +128,18 @@ void main() { final error = nested.errors.single; expect(error, isA()); expect(error.path, '#/name'); - expect(error.cause, isA()); + expect(error.cause, isA()); + }); + + test('rethrows Error instances from constraints', () { + final schema = Ack.object({ + 'name': Ack.string().constrain(const _ThrowingErrorConstraint()), + }); + + expect( + () => schema.safeParse({'name': 'Ada'}), + throwsA(isA()), + ); }); }); @@ -175,6 +186,21 @@ final class _ThrowingConstraint extends Constraint description: 'Throws for test coverage.', ); + @override + bool isValid(String value) => throw FormatException('constraint exploded'); + + @override + String buildMessage(String value) => 'unreachable'; +} + +final class _ThrowingErrorConstraint extends Constraint + with Validator { + const _ThrowingErrorConstraint() + : super( + constraintKey: 'throwing_error_test_constraint', + description: 'Throws an Error for test coverage.', + ); + @override bool isValid(String value) => throw StateError('constraint exploded'); diff --git a/packages/ack/test/schemas/datetime_validation_test.dart b/packages/ack/test/schemas/datetime_validation_test.dart index 5ffdbec8..b08b405c 100644 --- a/packages/ack/test/schemas/datetime_validation_test.dart +++ b/packages/ack/test/schemas/datetime_validation_test.dart @@ -115,6 +115,18 @@ void main() { expect(schema.safeParse('2025-06-15T10:30:00.123456Z').isOk, isTrue); }); + test('decodes RFC 3339 lowercase t/z separators', () { + final schema = Ack.datetime(); + + final zulu = schema.safeParse('2025-06-15t10:30:00z'); + final offset = schema.safeParse('2025-06-15t10:30:00+05:30'); + + expect(zulu.isOk, isTrue); + expect(zulu.getOrThrow(), DateTime.utc(2025, 6, 15, 10, 30)); + expect(offset.isOk, isTrue); + expect(offset.getOrThrow(), DateTime.utc(2025, 6, 15, 5, 0)); + }); + test('string validation accepts announced leap seconds', () { final schema = Ack.string().datetime(); diff --git a/packages/ack/test/schemas/refine_test.dart b/packages/ack/test/schemas/refine_test.dart index 9dce726f..7bcac74a 100644 --- a/packages/ack/test/schemas/refine_test.dart +++ b/packages/ack/test/schemas/refine_test.dart @@ -98,9 +98,9 @@ void main() { ); }); - test('safeParse converts refinement exceptions to failures', () { + test('safeParse converts refinement Exceptions to failures', () { final schema = Ack.string().refine( - (_) => throw StateError('refinement exploded'), + (_) => throw FormatException('refinement exploded'), ); final result = schema.safeParse('value', debugName: 'explosiveField'); @@ -111,8 +111,15 @@ void main() { expect(error.message, contains('Refinement threw')); expect(error.name, 'explosiveField'); expect(error.value, 'value'); - expect(error.cause, isA()); + expect(error.cause, isA()); expect(error.stackTrace, isNotNull); }); + + test('safeParse rethrows Error instances from refinements', () { + final thrown = StateError('refinement exploded'); + final schema = Ack.string().refine((_) => throw thrown); + + expect(() => schema.safeParse('value'), throwsA(same(thrown))); + }); }); } diff --git a/packages/ack_generator/test/integration/ack_type_golden_test.dart b/packages/ack_generator/test/integration/ack_type_golden_test.dart new file mode 100644 index 00000000..87f4e8c8 --- /dev/null +++ b/packages/ack_generator/test/integration/ack_type_golden_test.dart @@ -0,0 +1,70 @@ +import 'package:ack_generator/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +import '../test_utils/test_assets.dart'; + +/// Full-output snapshot for one canonical `@AckType` object schema. +/// +/// The scattered `decodedMatches(contains(...))` assertions in the sibling +/// integration tests catch missing pieces, but not formatting drift, member +/// reordering, or unexpected additions. This single exact-match golden guards +/// the overall shape of generated code for a representative schema; update the +/// expected string deliberately when the emitter output is meant to change. +void main() { + test('emits stable extension-type output for an object schema', () async { + final builder = ackGenerator(BuilderOptions.empty); + + const expected = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'schema.dart'; + +/// Extension type for User +extension type UserType(Map _data) + implements Map { + static UserType parse(Object? data) { + return userSchema.parseAs( + data, + (validated) => UserType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return userSchema.safeParseAs( + data, + (validated) => UserType(validated as Map), + ); + } + + String get name => _data['name'] as String; + + int get age => _data['age'] as int; +} +'''; + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +@AckType() +final userSchema = Ack.object({ + 'name': Ack.string(), + 'age': Ack.integer(), +}); +''', + }, + outputs: {'test_pkg|lib/schema.g.dart': decodedMatches(expected)}, + ); + }); +}