anyvali 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +370 -0
  3. package/VERSION +1 -0
  4. package/dist/errors.d.ts +6 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/errors.js +12 -0
  7. package/dist/errors.js.map +1 -0
  8. package/dist/format/validators.d.ts +2 -0
  9. package/dist/format/validators.d.ts.map +1 -0
  10. package/dist/format/validators.js +57 -0
  11. package/dist/format/validators.js.map +1 -0
  12. package/dist/forms/index.d.ts +57 -0
  13. package/dist/forms/index.d.ts.map +1 -0
  14. package/dist/forms/index.js +586 -0
  15. package/dist/forms/index.js.map +1 -0
  16. package/dist/index.d.ts +93 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +156 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/infer.d.ts +8 -0
  21. package/dist/infer.d.ts.map +1 -0
  22. package/dist/infer.js +2 -0
  23. package/dist/infer.js.map +1 -0
  24. package/dist/interchange/document.d.ts +5 -0
  25. package/dist/interchange/document.d.ts.map +1 -0
  26. package/dist/interchange/document.js +12 -0
  27. package/dist/interchange/document.js.map +1 -0
  28. package/dist/interchange/exporter.d.ts +7 -0
  29. package/dist/interchange/exporter.d.ts.map +1 -0
  30. package/dist/interchange/exporter.js +7 -0
  31. package/dist/interchange/exporter.js.map +1 -0
  32. package/dist/interchange/importer.d.ts +4 -0
  33. package/dist/interchange/importer.d.ts.map +1 -0
  34. package/dist/interchange/importer.js +229 -0
  35. package/dist/interchange/importer.js.map +1 -0
  36. package/dist/issue-codes.d.ts +19 -0
  37. package/dist/issue-codes.d.ts.map +1 -0
  38. package/dist/issue-codes.js +18 -0
  39. package/dist/issue-codes.js.map +1 -0
  40. package/dist/parse/coerce.d.ts +16 -0
  41. package/dist/parse/coerce.d.ts.map +1 -0
  42. package/dist/parse/coerce.js +115 -0
  43. package/dist/parse/coerce.js.map +1 -0
  44. package/dist/parse/defaults.d.ts +7 -0
  45. package/dist/parse/defaults.d.ts.map +1 -0
  46. package/dist/parse/defaults.js +12 -0
  47. package/dist/parse/defaults.js.map +1 -0
  48. package/dist/parse/parser.d.ts +11 -0
  49. package/dist/parse/parser.d.ts.map +1 -0
  50. package/dist/parse/parser.js +13 -0
  51. package/dist/parse/parser.js.map +1 -0
  52. package/dist/schemas/any.d.ts +7 -0
  53. package/dist/schemas/any.d.ts.map +1 -0
  54. package/dist/schemas/any.js +12 -0
  55. package/dist/schemas/any.js.map +1 -0
  56. package/dist/schemas/array.d.ts +13 -0
  57. package/dist/schemas/array.d.ts.map +1 -0
  58. package/dist/schemas/array.js +73 -0
  59. package/dist/schemas/array.js.map +1 -0
  60. package/dist/schemas/base.d.ts +37 -0
  61. package/dist/schemas/base.d.ts.map +1 -0
  62. package/dist/schemas/base.js +285 -0
  63. package/dist/schemas/base.js.map +1 -0
  64. package/dist/schemas/bool.d.ts +8 -0
  65. package/dist/schemas/bool.d.ts.map +1 -0
  66. package/dist/schemas/bool.js +27 -0
  67. package/dist/schemas/bool.js.map +1 -0
  68. package/dist/schemas/enum.d.ts +9 -0
  69. package/dist/schemas/enum.d.ts.map +1 -0
  70. package/dist/schemas/enum.js +31 -0
  71. package/dist/schemas/enum.js.map +1 -0
  72. package/dist/schemas/index.d.ts +21 -0
  73. package/dist/schemas/index.d.ts.map +1 -0
  74. package/dist/schemas/index.js +21 -0
  75. package/dist/schemas/index.js.map +1 -0
  76. package/dist/schemas/int.d.ts +32 -0
  77. package/dist/schemas/int.d.ts.map +1 -0
  78. package/dist/schemas/int.js +108 -0
  79. package/dist/schemas/int.js.map +1 -0
  80. package/dist/schemas/intersection.d.ts +16 -0
  81. package/dist/schemas/intersection.d.ts.map +1 -0
  82. package/dist/schemas/intersection.js +58 -0
  83. package/dist/schemas/intersection.js.map +1 -0
  84. package/dist/schemas/literal.d.ts +11 -0
  85. package/dist/schemas/literal.d.ts.map +1 -0
  86. package/dist/schemas/literal.js +28 -0
  87. package/dist/schemas/literal.js.map +1 -0
  88. package/dist/schemas/never.d.ts +7 -0
  89. package/dist/schemas/never.d.ts.map +1 -0
  90. package/dist/schemas/never.js +19 -0
  91. package/dist/schemas/never.js.map +1 -0
  92. package/dist/schemas/null.d.ts +7 -0
  93. package/dist/schemas/null.d.ts.map +1 -0
  94. package/dist/schemas/null.js +24 -0
  95. package/dist/schemas/null.js.map +1 -0
  96. package/dist/schemas/nullable.d.ts +10 -0
  97. package/dist/schemas/nullable.d.ts.map +1 -0
  98. package/dist/schemas/nullable.js +29 -0
  99. package/dist/schemas/nullable.js.map +1 -0
  100. package/dist/schemas/number.d.ts +27 -0
  101. package/dist/schemas/number.d.ts.map +1 -0
  102. package/dist/schemas/number.js +134 -0
  103. package/dist/schemas/number.js.map +1 -0
  104. package/dist/schemas/object.d.ts +28 -0
  105. package/dist/schemas/object.d.ts.map +1 -0
  106. package/dist/schemas/object.js +153 -0
  107. package/dist/schemas/object.js.map +1 -0
  108. package/dist/schemas/optional.d.ts +11 -0
  109. package/dist/schemas/optional.d.ts.map +1 -0
  110. package/dist/schemas/optional.js +39 -0
  111. package/dist/schemas/optional.js.map +1 -0
  112. package/dist/schemas/record.d.ts +9 -0
  113. package/dist/schemas/record.d.ts.map +1 -0
  114. package/dist/schemas/record.js +45 -0
  115. package/dist/schemas/record.js.map +1 -0
  116. package/dist/schemas/ref.d.ts +10 -0
  117. package/dist/schemas/ref.d.ts.map +1 -0
  118. package/dist/schemas/ref.js +30 -0
  119. package/dist/schemas/ref.js.map +1 -0
  120. package/dist/schemas/string.d.ts +29 -0
  121. package/dist/schemas/string.d.ts.map +1 -0
  122. package/dist/schemas/string.js +181 -0
  123. package/dist/schemas/string.js.map +1 -0
  124. package/dist/schemas/tuple.d.ts +14 -0
  125. package/dist/schemas/tuple.d.ts.map +1 -0
  126. package/dist/schemas/tuple.js +59 -0
  127. package/dist/schemas/tuple.js.map +1 -0
  128. package/dist/schemas/union.d.ts +9 -0
  129. package/dist/schemas/union.d.ts.map +1 -0
  130. package/dist/schemas/union.js +45 -0
  131. package/dist/schemas/union.js.map +1 -0
  132. package/dist/schemas/unknown.d.ts +7 -0
  133. package/dist/schemas/unknown.d.ts.map +1 -0
  134. package/dist/schemas/unknown.js +12 -0
  135. package/dist/schemas/unknown.js.map +1 -0
  136. package/dist/types.d.ts +132 -0
  137. package/dist/types.d.ts.map +1 -0
  138. package/dist/types.js +3 -0
  139. package/dist/types.js.map +1 -0
  140. package/dist/util.d.ts +6 -0
  141. package/dist/util.d.ts.map +1 -0
  142. package/dist/util.js +12 -0
  143. package/dist/util.js.map +1 -0
  144. package/package.json +41 -0
  145. package/sdk/js/CHANGELOG.md +13 -0
  146. package/src/errors.ts +17 -0
  147. package/src/format/validators.ts +71 -0
  148. package/src/forms/index.ts +789 -0
  149. package/src/index.ts +285 -0
  150. package/src/infer.ts +12 -0
  151. package/src/interchange/document.ts +18 -0
  152. package/src/interchange/exporter.ts +12 -0
  153. package/src/interchange/importer.ts +285 -0
  154. package/src/issue-codes.ts +19 -0
  155. package/src/parse/coerce.ts +133 -0
  156. package/src/parse/defaults.ts +15 -0
  157. package/src/parse/parser.ts +19 -0
  158. package/src/schemas/any.ts +14 -0
  159. package/src/schemas/array.ts +83 -0
  160. package/src/schemas/base.ts +322 -0
  161. package/src/schemas/bool.ts +30 -0
  162. package/src/schemas/enum.ts +37 -0
  163. package/src/schemas/index.ts +30 -0
  164. package/src/schemas/int.ts +129 -0
  165. package/src/schemas/intersection.ts +81 -0
  166. package/src/schemas/literal.ts +34 -0
  167. package/src/schemas/never.ts +21 -0
  168. package/src/schemas/null.ts +26 -0
  169. package/src/schemas/nullable.ts +36 -0
  170. package/src/schemas/number.ts +151 -0
  171. package/src/schemas/object.ts +203 -0
  172. package/src/schemas/optional.ts +49 -0
  173. package/src/schemas/record.ts +55 -0
  174. package/src/schemas/ref.ts +35 -0
  175. package/src/schemas/string.ts +192 -0
  176. package/src/schemas/tuple.ts +74 -0
  177. package/src/schemas/union.ts +53 -0
  178. package/src/schemas/unknown.ts +14 -0
  179. package/src/types.ts +239 -0
  180. package/src/util.ts +9 -0
  181. package/tests/conformance/runner.test.ts +28 -0
  182. package/tests/conformance/runner.ts +137 -0
  183. package/tests/forms.test.ts +146 -0
  184. package/tests/unit/coerce.test.ts +136 -0
  185. package/tests/unit/collections.test.ts +99 -0
  186. package/tests/unit/composition.test.ts +80 -0
  187. package/tests/unit/date-format.test.ts +18 -0
  188. package/tests/unit/default-mutation.test.ts +32 -0
  189. package/tests/unit/defaults.test.ts +49 -0
  190. package/tests/unit/errors.test.ts +53 -0
  191. package/tests/unit/export.test.ts +270 -0
  192. package/tests/unit/inference.test.ts +306 -0
  193. package/tests/unit/interchange.test.ts +191 -0
  194. package/tests/unit/number.test.ts +195 -0
  195. package/tests/unit/object.test.ts +208 -0
  196. package/tests/unit/parser.test.ts +151 -0
  197. package/tests/unit/primitives.test.ts +111 -0
  198. package/tests/unit/security-recursion.test.ts +105 -0
  199. package/tests/unit/security.test.ts +945 -0
  200. package/tests/unit/shared-ref-falsepos.test.ts +33 -0
  201. package/tests/unit/string-pattern-redos.test.ts +46 -0
  202. package/tests/unit/string.test.ts +147 -0
  203. package/tsconfig.json +21 -0
  204. package/vitest.config.ts +7 -0
package/src/util.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Describe the type of a value for error messages, matching the corpus expectations.
3
+ * null -> "null", array -> "array", otherwise typeof.
4
+ */
5
+ export function describeType(value: unknown): string {
6
+ if (value === null) return "null";
7
+ if (Array.isArray(value)) return "array";
8
+ return typeof value;
9
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as path from "node:path";
3
+ import { loadCorpus, runTestCase } from "./runner.js";
4
+
5
+ const corpusDir = path.resolve(__dirname, "../../../../spec/corpus");
6
+
7
+ const suites = loadCorpus(corpusDir);
8
+
9
+ if (suites.length === 0) {
10
+ describe("Conformance corpus", () => {
11
+ it("no corpus files found (skipping)", () => {
12
+ expect(true).toBe(true);
13
+ });
14
+ });
15
+ } else {
16
+ for (const suite of suites) {
17
+ describe(`Conformance: ${suite.suite}`, () => {
18
+ for (const tc of suite.cases) {
19
+ it(tc.description, () => {
20
+ const result = runTestCase(tc);
21
+ if (!result.passed) {
22
+ throw new Error(result.error);
23
+ }
24
+ });
25
+ }
26
+ });
27
+ }
28
+ }
@@ -0,0 +1,137 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { importSchema } from "../../src/interchange/importer.js";
4
+ import type { AnyValiDocument } from "../../src/types.js";
5
+
6
+ export interface CorpusFile {
7
+ suite: string;
8
+ cases: CorpusTestCase[];
9
+ }
10
+
11
+ export interface CorpusTestCase {
12
+ description: string;
13
+ schema: AnyValiDocument;
14
+ input: unknown;
15
+ valid: boolean;
16
+ output: unknown;
17
+ issues: Array<{
18
+ code: string;
19
+ path: (string | number)[];
20
+ expected?: string;
21
+ received?: string;
22
+ }>;
23
+ }
24
+
25
+ export interface CorpusTestResult {
26
+ description: string;
27
+ passed: boolean;
28
+ error?: string;
29
+ }
30
+
31
+ /**
32
+ * Load all corpus test files from the given directory (recursively).
33
+ * Returns an array of { suite, cases } objects.
34
+ */
35
+ export function loadCorpus(corpusDir: string): CorpusFile[] {
36
+ const suites: CorpusFile[] = [];
37
+
38
+ if (!fs.existsSync(corpusDir)) {
39
+ return suites;
40
+ }
41
+
42
+ function walk(dir: string): void {
43
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ const fullPath = path.join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ walk(fullPath);
48
+ } else if (entry.name.endsWith(".json")) {
49
+ const content = fs.readFileSync(fullPath, "utf-8");
50
+ const parsed = JSON.parse(content) as CorpusFile;
51
+ suites.push(parsed);
52
+ }
53
+ }
54
+ }
55
+
56
+ walk(corpusDir);
57
+ return suites;
58
+ }
59
+
60
+ /**
61
+ * Run a single corpus test case.
62
+ */
63
+ export function runTestCase(tc: CorpusTestCase): CorpusTestResult {
64
+ try {
65
+ const schema = importSchema(tc.schema);
66
+ const result = schema.safeParse(tc.input);
67
+
68
+ if (tc.valid) {
69
+ // Expect success
70
+ if (!result.success) {
71
+ return {
72
+ description: tc.description,
73
+ passed: false,
74
+ error: `Expected success but got failure: ${JSON.stringify(result.issues)}`,
75
+ };
76
+ }
77
+ // Compare output
78
+ const expectedJson = JSON.stringify(tc.output);
79
+ const actualJson = JSON.stringify(result.data);
80
+ if (expectedJson !== actualJson) {
81
+ return {
82
+ description: tc.description,
83
+ passed: false,
84
+ error: `Output mismatch: expected ${expectedJson}, got ${actualJson}`,
85
+ };
86
+ }
87
+ return { description: tc.description, passed: true };
88
+ } else {
89
+ // Expect failure
90
+ if (result.success) {
91
+ return {
92
+ description: tc.description,
93
+ passed: false,
94
+ error: `Expected failure but got success: ${JSON.stringify(result.data)}`,
95
+ };
96
+ }
97
+ // Check expected issue codes and paths
98
+ for (const expectedIssue of tc.issues) {
99
+ const found = result.issues.some((actual) => {
100
+ if (actual.code !== expectedIssue.code) return false;
101
+ if (
102
+ JSON.stringify(actual.path) !== JSON.stringify(expectedIssue.path)
103
+ ) {
104
+ return false;
105
+ }
106
+ if (
107
+ expectedIssue.expected !== undefined &&
108
+ actual.expected !== expectedIssue.expected
109
+ ) {
110
+ return false;
111
+ }
112
+ if (
113
+ expectedIssue.received !== undefined &&
114
+ actual.received !== expectedIssue.received
115
+ ) {
116
+ return false;
117
+ }
118
+ return true;
119
+ });
120
+ if (!found) {
121
+ return {
122
+ description: tc.description,
123
+ passed: false,
124
+ error: `Expected issue ${JSON.stringify(expectedIssue)} not found in actual issues: ${JSON.stringify(result.issues)}`,
125
+ };
126
+ }
127
+ }
128
+ return { description: tc.description, passed: true };
129
+ }
130
+ } catch (err: any) {
131
+ return {
132
+ description: tc.description,
133
+ passed: false,
134
+ error: `Exception: ${err.message}`,
135
+ };
136
+ }
137
+ }
@@ -0,0 +1,146 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { object, string, int, array, exportSchema } from "../src/index.js";
5
+ import { createFormBindings, initForm } from "../src/forms/index.js";
6
+
7
+ describe("forms bindings", () => {
8
+ it("derives native field attributes from a schema", () => {
9
+ const schema = object({
10
+ email: string().format("email").minLength(5),
11
+ age: int().min(18),
12
+ tags: array(string()).minItems(1),
13
+ });
14
+
15
+ const bindings = createFormBindings({ schema });
16
+
17
+ expect(bindings.field("email")).toMatchObject({
18
+ name: "email",
19
+ type: "email",
20
+ required: true,
21
+ minLength: 5,
22
+ });
23
+
24
+ expect(bindings.field("age")).toMatchObject({
25
+ name: "age",
26
+ type: "number",
27
+ required: true,
28
+ min: 18,
29
+ step: 1,
30
+ });
31
+
32
+ expect(bindings.field("tags")).toMatchObject({
33
+ name: "tags",
34
+ required: true,
35
+ });
36
+ });
37
+ });
38
+
39
+ describe("forms init", () => {
40
+ it("enhances an existing form and blocks invalid submit", () => {
41
+ document.body.innerHTML = `
42
+ <form id="signup">
43
+ <input name="email" />
44
+ <input name="age" />
45
+ <div data-anyvali-error-for="email"></div>
46
+ </form>
47
+ `;
48
+
49
+ const schema = exportSchema(
50
+ object({
51
+ email: string().format("email"),
52
+ age: int().min(18),
53
+ })
54
+ );
55
+
56
+ const controller = initForm("#signup", {
57
+ schema,
58
+ validateOn: ["blur", "submit"],
59
+ });
60
+
61
+ const form = document.querySelector("#signup") as HTMLFormElement;
62
+ const email = form.querySelector('[name="email"]') as HTMLInputElement;
63
+ const age = form.querySelector('[name="age"]') as HTMLInputElement;
64
+
65
+ expect(email.type).toBe("email");
66
+ expect(age.type).toBe("number");
67
+ expect(age.getAttribute("min")).toBe("18");
68
+
69
+ email.value = "not-an-email";
70
+ age.value = "21";
71
+
72
+ const submit = new Event("submit", { cancelable: true });
73
+ const accepted = form.dispatchEvent(submit);
74
+
75
+ expect(accepted).toBe(false);
76
+ expect(email.validationMessage.length).toBeGreaterThan(0);
77
+ expect(
78
+ form.querySelector('[data-anyvali-error-for="email"]')?.textContent
79
+ ).not.toBe("");
80
+
81
+ controller.destroy();
82
+ });
83
+
84
+ it("hooks into htmx validation events for custom validity", () => {
85
+ document.body.innerHTML = `
86
+ <form id="profile">
87
+ <input name="email" />
88
+ </form>
89
+ `;
90
+
91
+ const controller = initForm("#profile", {
92
+ schema: exportSchema(object({ email: string().format("email") })),
93
+ htmx: true,
94
+ });
95
+
96
+ const input = document.querySelector('[name="email"]') as HTMLInputElement;
97
+ input.value = "broken";
98
+ input.dispatchEvent(new CustomEvent("htmx:validation:validate", { bubbles: true }));
99
+
100
+ expect(input.validationMessage.length).toBeGreaterThan(0);
101
+
102
+ controller.destroy();
103
+ });
104
+
105
+ it("reads nested fields and multi-select values using schema paths", () => {
106
+ document.body.innerHTML = `
107
+ <form id="profile">
108
+ <input name="user.email" />
109
+ <select name="tags" multiple>
110
+ <option value="alpha">Alpha</option>
111
+ <option value="beta">Beta</option>
112
+ <option value="gamma">Gamma</option>
113
+ </select>
114
+ <div data-anyvali-error-for="user.email"></div>
115
+ </form>
116
+ `;
117
+
118
+ const controller = initForm("#profile", {
119
+ schema: exportSchema(
120
+ object({
121
+ user: object({
122
+ email: string().format("email"),
123
+ }),
124
+ tags: array(string()).minItems(1),
125
+ })
126
+ ),
127
+ validateOn: ["submit"],
128
+ });
129
+
130
+ const form = document.querySelector("#profile") as HTMLFormElement;
131
+ const email = form.querySelector('[name="user.email"]') as HTMLInputElement;
132
+ const tags = form.querySelector('[name="tags"]') as HTMLSelectElement;
133
+
134
+ email.value = "team@anyvali.dev";
135
+ tags.options[0].selected = true;
136
+ tags.options[2].selected = true;
137
+
138
+ expect(controller.getValues()).toEqual({
139
+ user: { email: "team@anyvali.dev" },
140
+ tags: ["alpha", "gamma"],
141
+ });
142
+ expect(controller.validate()).toBe(true);
143
+
144
+ controller.destroy();
145
+ });
146
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ applyCoercion,
4
+ normalizeCoercionConfig,
5
+ } from "../../src/parse/coerce.js";
6
+
7
+ describe("normalizeCoercionConfig", () => {
8
+ it("passes through object config as-is", () => {
9
+ const config = { from: "string", trim: true };
10
+ expect(normalizeCoercionConfig(config)).toBe(config);
11
+ });
12
+
13
+ it("converts single string 'trim' to config", () => {
14
+ expect(normalizeCoercionConfig("trim")).toEqual({ trim: true });
15
+ });
16
+
17
+ it("converts single string 'lower' to config", () => {
18
+ expect(normalizeCoercionConfig("lower")).toEqual({ lower: true });
19
+ });
20
+
21
+ it("converts single string 'upper' to config", () => {
22
+ expect(normalizeCoercionConfig("upper")).toEqual({ upper: true });
23
+ });
24
+
25
+ it("converts 'string->int' to from config", () => {
26
+ expect(normalizeCoercionConfig("string->int")).toEqual({ from: "string" });
27
+ });
28
+
29
+ it("converts 'string->number' to from config", () => {
30
+ expect(normalizeCoercionConfig("string->number")).toEqual({
31
+ from: "string",
32
+ });
33
+ });
34
+
35
+ it("converts 'string->bool' to from config", () => {
36
+ expect(normalizeCoercionConfig("string->bool")).toEqual({
37
+ from: "string",
38
+ });
39
+ });
40
+
41
+ it("converts array of strings", () => {
42
+ expect(normalizeCoercionConfig(["trim", "lower"])).toEqual({
43
+ trim: true,
44
+ lower: true,
45
+ });
46
+ });
47
+ });
48
+
49
+ describe("applyCoercion edge cases", () => {
50
+ it("applies trim to string input", () => {
51
+ const result = applyCoercion(" hello ", { trim: true }, "string");
52
+ expect(result).toEqual({ success: true, value: "hello" });
53
+ });
54
+
55
+ it("applies lower to string input", () => {
56
+ const result = applyCoercion("HELLO", { lower: true }, "string");
57
+ expect(result).toEqual({ success: true, value: "hello" });
58
+ });
59
+
60
+ it("applies upper to string input", () => {
61
+ const result = applyCoercion("hello", { upper: true }, "string");
62
+ expect(result).toEqual({ success: true, value: "HELLO" });
63
+ });
64
+
65
+ it("applies trim + lower together", () => {
66
+ const result = applyCoercion(" HELLO ", { trim: true, lower: true }, "string");
67
+ expect(result).toEqual({ success: true, value: "hello" });
68
+ });
69
+
70
+ it("fails coercing non-integer string to int", () => {
71
+ const result = applyCoercion("3.5", { from: "string" }, "int");
72
+ expect(result.success).toBe(false);
73
+ });
74
+
75
+ it("fails coercing non-finite string to int", () => {
76
+ // A very large number that becomes Infinity
77
+ const huge = "9".repeat(400);
78
+ const result = applyCoercion(huge, { from: "string" }, "int");
79
+ expect(result.success).toBe(false);
80
+ });
81
+
82
+ it("fails coercing empty string to float", () => {
83
+ const result = applyCoercion("", { from: "string" }, "number");
84
+ expect(result.success).toBe(false);
85
+ if (!result.success) {
86
+ expect(result.message).toContain("empty string");
87
+ }
88
+ });
89
+
90
+ it("fails coercing empty string to float32", () => {
91
+ const result = applyCoercion("", { from: "string" }, "float32");
92
+ expect(result.success).toBe(false);
93
+ });
94
+
95
+ it("fails coercing empty string to float64", () => {
96
+ const result = applyCoercion("", { from: "string" }, "float64");
97
+ expect(result.success).toBe(false);
98
+ });
99
+
100
+ it("fails coercing non-numeric string to number", () => {
101
+ const result = applyCoercion("abc", { from: "string" }, "number");
102
+ expect(result.success).toBe(false);
103
+ });
104
+
105
+ it("successfully coerces valid float string", () => {
106
+ const result = applyCoercion("3.14", { from: "string" }, "number");
107
+ expect(result).toEqual({ success: true, value: 3.14 });
108
+ });
109
+
110
+ it("coerces string to int subtypes", () => {
111
+ for (const kind of [
112
+ "int8", "int16", "int32", "int64",
113
+ "uint8", "uint16", "uint32", "uint64",
114
+ ]) {
115
+ const result = applyCoercion("42", { from: "string" }, kind);
116
+ expect(result).toEqual({ success: true, value: 42 });
117
+ }
118
+ });
119
+
120
+ it("coerces string to float32/float64", () => {
121
+ for (const kind of ["float32", "float64"]) {
122
+ const result = applyCoercion("1.5", { from: "string" }, kind);
123
+ expect(result).toEqual({ success: true, value: 1.5 });
124
+ }
125
+ });
126
+
127
+ it("does not coerce when input is not a string", () => {
128
+ const result = applyCoercion(42, { from: "string" }, "int");
129
+ expect(result).toEqual({ success: true, value: 42 });
130
+ });
131
+
132
+ it("does not apply trim/lower/upper when input is not a string", () => {
133
+ const result = applyCoercion(42, { trim: true, lower: true, upper: true }, "number");
134
+ expect(result).toEqual({ success: true, value: 42 });
135
+ });
136
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { array, tuple, record, string, int, bool, any } from "../../src/index.js";
3
+
4
+ describe("ArraySchema", () => {
5
+ it("accepts valid arrays", () => {
6
+ const s = array(int());
7
+ expect(s.parse([1, 2, 3])).toEqual([1, 2, 3]);
8
+ });
9
+
10
+ it("rejects non-arrays", () => {
11
+ const s = array(int());
12
+ expect(s.safeParse("not an array").success).toBe(false);
13
+ });
14
+
15
+ it("validates array items", () => {
16
+ const s = array(int());
17
+ const result = s.safeParse([1, "two", 3]);
18
+ expect(result.success).toBe(false);
19
+ if (!result.success) {
20
+ expect(result.issues[0].path).toEqual([1]);
21
+ }
22
+ });
23
+
24
+ it("validates minItems", () => {
25
+ const s = array(int()).minItems(2);
26
+ expect(s.parse([1, 2])).toEqual([1, 2]);
27
+ expect(s.safeParse([1]).success).toBe(false);
28
+ });
29
+
30
+ it("validates maxItems", () => {
31
+ const s = array(int()).maxItems(2);
32
+ expect(s.parse([1, 2])).toEqual([1, 2]);
33
+ expect(s.safeParse([1, 2, 3]).success).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("TupleSchema", () => {
38
+ it("accepts valid tuples", () => {
39
+ const s = tuple([string(), int(), bool()]);
40
+ expect(s.parse(["hello", 42, true])).toEqual(["hello", 42, true]);
41
+ });
42
+
43
+ it("rejects wrong length", () => {
44
+ const s = tuple([string(), int()]);
45
+ expect(s.safeParse(["hello"]).success).toBe(false);
46
+ expect(s.safeParse(["hello", 42, true]).success).toBe(false);
47
+ });
48
+
49
+ it("validates element types", () => {
50
+ const s = tuple([string(), int()]);
51
+ const result = s.safeParse([42, "hello"]);
52
+ expect(result.success).toBe(false);
53
+ if (!result.success) {
54
+ expect(result.issues[0].path).toEqual([0]);
55
+ }
56
+ });
57
+ });
58
+
59
+ describe("RecordSchema", () => {
60
+ it("accepts valid records", () => {
61
+ const s = record(int());
62
+ expect(s.parse({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 });
63
+ });
64
+
65
+ it("rejects non-objects", () => {
66
+ const s = record(int());
67
+ expect(s.safeParse("not object").success).toBe(false);
68
+ expect(s.safeParse(null).success).toBe(false);
69
+ expect(s.safeParse([]).success).toBe(false);
70
+ });
71
+
72
+ it("validates record values", () => {
73
+ const s = record(int());
74
+ const result = s.safeParse({ a: 1, b: "two" });
75
+ expect(result.success).toBe(false);
76
+ if (!result.success) {
77
+ expect(result.issues[0].path).toEqual(["b"]);
78
+ }
79
+ });
80
+
81
+ it("preserves __proto__ as data in records", () => {
82
+ const s = record(any());
83
+ const input = JSON.parse(
84
+ '{"safe":1,"__proto__":{"polluted":"yes"}}'
85
+ ) as Record<
86
+ string,
87
+ unknown
88
+ >;
89
+
90
+ const result = s.parse(input);
91
+
92
+ expect(result.safe).toBe(1);
93
+ expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
94
+ expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(true);
95
+ expect(
96
+ Object.getOwnPropertyDescriptor(result, "__proto__")?.value
97
+ ).toEqual({ polluted: "yes" });
98
+ });
99
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ union,
4
+ intersection,
5
+ optional,
6
+ nullable,
7
+ string,
8
+ int,
9
+ object,
10
+ literal,
11
+ } from "../../src/index.js";
12
+
13
+ describe("UnionSchema", () => {
14
+ it("accepts values matching any variant", () => {
15
+ const s = union([string(), int()]);
16
+ expect(s.parse("hello")).toBe("hello");
17
+ expect(s.parse(42)).toBe(42);
18
+ });
19
+
20
+ it("rejects values matching no variant", () => {
21
+ const s = union([string(), int()]);
22
+ const result = s.safeParse(true);
23
+ expect(result.success).toBe(false);
24
+ if (!result.success) {
25
+ expect(result.issues[0].code).toBe("invalid_union");
26
+ }
27
+ });
28
+
29
+ it("returns first matching variant result", () => {
30
+ const s = union([literal("a"), string()]);
31
+ expect(s.parse("a")).toBe("a");
32
+ expect(s.parse("b")).toBe("b");
33
+ });
34
+ });
35
+
36
+ describe("IntersectionSchema", () => {
37
+ it("validates against all schemas", () => {
38
+ const s = intersection([
39
+ object({ name: string() }).unknownKeys("strip"),
40
+ object({ age: int() }).unknownKeys("strip"),
41
+ ]);
42
+ const result = s.parse({ name: "Alice", age: 30 });
43
+ expect(result).toEqual({ name: "Alice", age: 30 });
44
+ });
45
+
46
+ it("fails if any schema fails", () => {
47
+ const s = intersection([
48
+ object({ name: string() }).unknownKeys("strip"),
49
+ object({ age: int() }).unknownKeys("strip"),
50
+ ]);
51
+ const result = s.safeParse({ name: "Alice" });
52
+ expect(result.success).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("OptionalSchema", () => {
57
+ it("accepts undefined", () => {
58
+ const s = optional(string());
59
+ expect(s.parse(undefined)).toBe(undefined);
60
+ });
61
+
62
+ it("validates when present", () => {
63
+ const s = optional(string());
64
+ expect(s.parse("hello")).toBe("hello");
65
+ expect(s.safeParse(42).success).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe("NullableSchema", () => {
70
+ it("accepts null", () => {
71
+ const s = nullable(string());
72
+ expect(s.parse(null)).toBe(null);
73
+ });
74
+
75
+ it("validates when not null", () => {
76
+ const s = nullable(string());
77
+ expect(s.parse("hello")).toBe("hello");
78
+ expect(s.safeParse(42).success).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { string } from "../../src/index.js";
3
+
4
+ const date = () => string().format("date");
5
+
6
+ describe("date format validation", () => {
7
+ it("accepts valid ISO dates including early years (0001-0099)", () => {
8
+ for (const d of ["0001-01-01", "0050-01-01", "0099-12-31", "2021-06-15", "9999-12-31"]) {
9
+ expect(date().safeParse(d).success).toBe(true);
10
+ }
11
+ });
12
+
13
+ it("rejects impossible calendar dates", () => {
14
+ for (const d of ["2021-02-30", "2021-04-31", "2021-13-01", "2021-00-10"]) {
15
+ expect(date().safeParse(d).success).toBe(false);
16
+ }
17
+ });
18
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { any, unknown, object } from "../../src/index.js";
3
+
4
+ // Repro for AVV-007: mutable default values shared between validations.
5
+ // Pass-through schemas (any/unknown) return the default value by reference,
6
+ // so mutating one parse result corrupts the default for the next parse.
7
+ describe("Mutable default isolation (AVV-007)", () => {
8
+ it("any() array default is not shared between parses", () => {
9
+ const s = any().default([] as unknown[]);
10
+ const a = s.parse(undefined) as unknown[];
11
+ a.push("mutated");
12
+ const b = s.parse(undefined) as unknown[];
13
+ expect(b).toEqual([]);
14
+ });
15
+
16
+ it("unknown() object default is not shared between parses", () => {
17
+ const s = unknown().default({ tags: [] as string[] });
18
+ const a = s.parse(undefined) as { tags: string[] };
19
+ a.tags.push("x");
20
+ const b = s.parse(undefined) as { tags: string[] };
21
+ expect(b.tags).toEqual([]);
22
+ });
23
+
24
+ it("object field with any() default is not shared", () => {
25
+ const s = object({ meta: any().default({ count: 0, items: [] as number[] }) });
26
+ const a = s.parse({}) as { meta: { count: number; items: number[] } };
27
+ a.meta.items.push(1);
28
+ a.meta.count = 99;
29
+ const b = s.parse({}) as { meta: { count: number; items: number[] } };
30
+ expect(b.meta).toEqual({ count: 0, items: [] });
31
+ });
32
+ });