@venn-lang/assert 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vinicius Borges
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # @venn-lang/assert
2
+
3
+ > The words that follow `expect`: `equals`, `contains`, `oneOf` and `closeTo`.
4
+
5
+ `expect` belongs to the kernel; the vocabulary does not. This plugin registers four matchers, each
6
+ carrying its own one-line failure message and the two values it compared. A red assertion therefore
7
+ prints a sentence a person can read plus a structured diff, never `[object Object]`. The plugin
8
+ contributes no verbs, declares no types and needs no host capability.
9
+
10
+ ## Install
11
+
12
+ `@venn-lang/assert` is part of the stdlib the `venn` CLI and the language server load, so there is
13
+ nothing to install. A file that asserts brings the namespace in:
14
+
15
+ ```ruby
16
+ use "venn/assert"
17
+ ```
18
+
19
+ A matcher used without that line is `VN2007`; a word no plugin registered is `VN2004`. Both are
20
+ reported by `venn check`, before anything runs.
21
+
22
+ ## Usage
23
+
24
+ ```ruby
25
+ module demo.matchers
26
+
27
+ use "venn/assert"
28
+
29
+ flow "Bareword matchers" {
30
+ step "checks" {
31
+ let plan = "pro"
32
+ let total = 99.005
33
+ expect plan oneOf ["free", "pro"]
34
+ expect "Total: $99.00" contains "$99.00"
35
+ expect total closeTo 99.0 { within: 0.01 }
36
+ expect plan equals "pro"
37
+ expect not plan oneOf ["a", "b"]
38
+ }
39
+ }
40
+ ```
41
+
42
+ Matchers are barewords: they resolve by name alone, not through the `assert.` prefix. The namespace
43
+ is what `use` brings into the file.
44
+
45
+ ## Matchers
46
+
47
+ | Matcher | Written as | Passes when |
48
+ | --- | --- | --- |
49
+ | `equals` | `expect res.status equals 200` | The two values are structurally the same. No coercion: `"200"` never equals `200`. |
50
+ | `contains` | `expect body contains "$99.00"` | The subject is a string holding that substring, or a list holding that item. Anything else fails. |
51
+ | `oneOf` | `expect plan oneOf ["free", "pro"]` | The subject is one of the listed values. |
52
+ | `closeTo` | `expect total closeTo 99.0 { within: 0.01 }` | The two numbers differ by no more than the tolerance. `within` defaults to `0.01`. |
53
+
54
+ `not` negates any of them: `expect not plan oneOf ["a", "b"]`.
55
+
56
+ ### How `equals` compares
57
+
58
+ Maps and lists compare by value, not by reference: a body built twice the same way is the same
59
+ thing, and comparing it by identity would fail an assertion that reads as true on the page.
60
+
61
+ - A field set to nothing is not a field. `{ id: 1, ref: absent }` equals `{ id: 1 }`, because both
62
+ print and travel over the wire identically. A field holding `null` is still a field, so
63
+ `{ id: 1, ref: null }` does not.
64
+ - A value that contains itself is handled rather than overflowing the stack: two containers already
65
+ open on the way down are taken as equal, the way one cycle matches another.
66
+ - Anything that is neither a map nor a list (dates, plugin objects, closures) compares by identity.
67
+
68
+ `contains` compares list items the same way, so `expect rows contains { id: 1 }` works.
69
+
70
+ ## What a failure looks like
71
+
72
+ The title is one line, in the values' own terms:
73
+
74
+ ```
75
+ expected {"status":"pending"} to equal {"status":"paid"}
76
+ expected 500 to be one of [200,204]
77
+ expected 99.5 to be within 0.01 of 99
78
+ ```
79
+
80
+ Both sides are rendered to the same level of detail. Past a line's worth, both are summarised by
81
+ shape (`expected a map with 3 fields to equal a map with 2 fields`) rather than one being spelled
82
+ out beside one that is not. Strings are quoted, so `"200"` never reads as `200`.
83
+
84
+ The full values are not lost: each matcher hands back the two sides, and the kernel turns them into
85
+ the diff carried by the `VN6001` problem. Membership matchers (`contains`, `oneOf`) mark their sides
86
+ unaligned, because the needle was held against every item and never stood opposite item 0. A
87
+ negated `expect` gets no diff on purpose: under `not` the two sides matched, and "expected 200,
88
+ actual 200" explains nothing.
89
+
90
+ ## API
91
+
92
+ | Export | What it is |
93
+ | --- | --- |
94
+ | `assertPlugin` (also the default export) | The `PluginDefinition`: namespace `assert`, matchers only, no actions, no `typeDefs`, no required capability. |
95
+ | `assertMatchers` | The four `MatcherDefinition`s, in order: `equals`, `contains`, `oneOf`, `closeTo`. |
96
+
97
+ A matcher is a plain object, so it can be exercised directly:
98
+
99
+ ```ts
100
+ import { assertMatchers } from "@venn-lang/assert";
101
+
102
+ const equals = assertMatchers.find((matcher) => matcher.name === "equals");
103
+
104
+ equals?.test({ subject: { id: 1 }, args: [{ id: 1 }], params: {} });
105
+ // true
106
+ equals?.message({ subject: "200", args: [200], params: {} });
107
+ // 'expected "200" to equal 200'
108
+ equals?.detail?.({ subject: { status: "pending" }, args: [{ status: "paid" }], params: {} });
109
+ // { expected: { status: "paid" }, actual: { status: "pending" } }
110
+ ```
111
+
112
+ ## See also
113
+
114
+ - [`@venn-lang/sdk`](../sdk) for `defineMatcher` and the definition types used here.
115
+ - [`@venn-lang/runtime`](../runtime) for the registry that resolves a bareword and emits `VN6001`.
116
+ - [`@venn-lang/fmt`](../std-fmt) for turning a value into text you can assert against.
@@ -0,0 +1,17 @@
1
+ import { MatcherDefinition, PluginDefinition } from "@venn-lang/sdk";
2
+ //#region src/matchers/index.d.ts
3
+ /** The words `expect` gains from this plugin: `equals`, `contains`, `oneOf`, `closeTo`. */
4
+ declare const assertMatchers: MatcherDefinition[];
5
+ //#endregion
6
+ //#region src/plugin.d.ts
7
+ /**
8
+ * The `assert` plugin: the vocabulary behind `expect`.
9
+ *
10
+ * `expect` is kernel syntax; the words that follow it are not. This plugin
11
+ * contributes those words and nothing else, so it declares no verbs, no
12
+ * resources, no types and no host capabilities.
13
+ */
14
+ declare const assertPlugin: PluginDefinition;
15
+ //#endregion
16
+ export { assertMatchers, assertPlugin, assertPlugin as default };
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/matchers/index.ts","../src/plugin.ts"],"mappings":";;;cAOa,gBAAgB;;;;;;;;;;cCGhB,cAAc"}
package/dist/index.js ADDED
@@ -0,0 +1,220 @@
1
+ import { arg, defineMatcher, definePlugin, z } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ //#region src/matchers/failure-line.ts
4
+ /** A failure title is one line. Past this, a value is summarised instead. */
5
+ const LIMIT = 44;
6
+ /**
7
+ * Builds the line a failure leads with: `expected <subject> <relation> <other>`.
8
+ *
9
+ * Both sides render to the same level of detail: one spelled out next to one
10
+ * summarised reads as a reporter glitch rather than a comparison. Nothing is
11
+ * lost, the full values travel in the problem's diff.
12
+ */
13
+ function failureLine(args) {
14
+ const [left, right] = pair(args.subject, args.other);
15
+ return `expected ${left} ${args.relation} ${right}`;
16
+ }
17
+ function pair(subject, other) {
18
+ const left = render(subject);
19
+ const right = render(other);
20
+ if (left.length <= LIMIT && right.length <= LIMIT) return [left, right];
21
+ return [summarize(subject), summarize(other)];
22
+ }
23
+ /** Strings quoted, maps and lists as compact JSON. Never `[object Object]`. */
24
+ function render(value) {
25
+ if (value === void 0) return "absent";
26
+ if (value === null) return "null";
27
+ if (typeof value === "function") return "fn";
28
+ if (typeof value === "string") return JSON.stringify(value);
29
+ if (typeof value !== "object") return String(value);
30
+ return json(value);
31
+ }
32
+ function summarize(value) {
33
+ if (typeof value === "string") return `${JSON.stringify(value.slice(0, LIMIT))}…`;
34
+ if (typeof value === "object" && value !== null) return shapeOf(value);
35
+ return render(value);
36
+ }
37
+ function shapeOf(value) {
38
+ if (Array.isArray(value)) return `a list of ${value.length} ${plural("item", value.length)}`;
39
+ const count = Object.keys(value).length;
40
+ return `a map with ${count} ${plural("field", count)}`;
41
+ }
42
+ function json(value) {
43
+ try {
44
+ return JSON.stringify(value) ?? shapeOf(value);
45
+ } catch {
46
+ return shapeOf(value);
47
+ }
48
+ }
49
+ function plural(noun, count) {
50
+ return count === 1 ? noun : `${noun}s`;
51
+ }
52
+ //#endregion
53
+ //#region src/matchers/close-to.ts
54
+ const DEFAULT_WITHIN = .01;
55
+ /** `expect x closeTo 99.0 { within: 0.01 }`: numeric equality within a tolerance. */
56
+ const closeTo = defineMatcher({
57
+ name: "closeTo",
58
+ args: [arg("value", t.number, "The number to come near. `within` in the options sets how near.")],
59
+ params: z.object({ within: z.number().default(DEFAULT_WITHIN) }),
60
+ test: ({ subject, args, params }) => Math.abs(Number(subject) - Number(args[0])) <= tolerance(params),
61
+ message: ({ subject, args, params }) => failureLine({
62
+ subject,
63
+ relation: `to be within ${tolerance(params)} of`,
64
+ other: args[0]
65
+ }),
66
+ detail: ({ subject, args }) => ({
67
+ expected: args[0],
68
+ actual: subject
69
+ })
70
+ });
71
+ function tolerance(params) {
72
+ return params.within ?? DEFAULT_WITHIN;
73
+ }
74
+ //#endregion
75
+ //#region src/matchers/deep-equals.ts
76
+ /**
77
+ * Structural equality over the language's values.
78
+ *
79
+ * A body, a row or a list of ids is a value, not a handle, so two built the
80
+ * same way are equal. Anything that is not a plain map or a list (dates, plugin
81
+ * objects, closures) still compares by identity: guessing at their innards
82
+ * would be worse than being strict.
83
+ */
84
+ function deepEquals(a, b) {
85
+ return equal(a, b, []);
86
+ }
87
+ /**
88
+ * `open` holds the containers already entered on the way down, so a value that
89
+ * contains itself cannot recurse until the stack gives out. Two containers
90
+ * already open count as equal, the way one cycle matches another.
91
+ */
92
+ function equal(a, b, open) {
93
+ if (a === b) return true;
94
+ const openA = open.includes(a);
95
+ const openB = open.includes(b);
96
+ if (openA || openB) return openA && openB;
97
+ if (Array.isArray(a) || Array.isArray(b)) return sameList(a, b, open);
98
+ if (!isMap(a) || !isMap(b)) return false;
99
+ return sameMap(a, b, open);
100
+ }
101
+ function sameList(a, b, open) {
102
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
103
+ const next = [
104
+ ...open,
105
+ a,
106
+ b
107
+ ];
108
+ return a.every((item, index) => equal(item, b[index], next));
109
+ }
110
+ /**
111
+ * A field set to nothing is not a field. `{ id: 1, ref: absent }` prints, and
112
+ * travels over the wire, exactly like `{ id: 1 }`, so the two compare equal.
113
+ */
114
+ function sameMap(a, b, open) {
115
+ const keys = presentKeys(a);
116
+ if (keys.length !== presentKeys(b).length) return false;
117
+ const next = [
118
+ ...open,
119
+ a,
120
+ b
121
+ ];
122
+ return keys.every((key) => equal(a[key], b[key], next));
123
+ }
124
+ function presentKeys(value) {
125
+ return Object.keys(value).filter((key) => value[key] !== void 0);
126
+ }
127
+ function isMap(value) {
128
+ if (typeof value !== "object" || value === null) return false;
129
+ const proto = Object.getPrototypeOf(value);
130
+ return proto === Object.prototype || proto === null;
131
+ }
132
+ //#endregion
133
+ //#region src/matchers/contains.ts
134
+ /** `expect x contains y`: substring for strings, membership for lists. */
135
+ const contains = defineMatcher({
136
+ name: "contains",
137
+ args: [arg("value", t.dynamic, "What to look for: a substring, or an item of the list.")],
138
+ test: ({ subject, args }) => includes(subject, args[0]),
139
+ message: ({ subject, args }) => failureLine({
140
+ subject,
141
+ relation: "to contain",
142
+ other: args[0]
143
+ }),
144
+ detail: ({ subject, args }) => ({
145
+ expected: args[0],
146
+ actual: subject,
147
+ aligned: false
148
+ })
149
+ });
150
+ function includes(subject, value) {
151
+ if (typeof subject === "string") return subject.includes(String(value));
152
+ if (Array.isArray(subject)) return subject.some((item) => deepEquals(item, value));
153
+ return false;
154
+ }
155
+ //#endregion
156
+ //#region src/matchers/equals.ts
157
+ /** `expect x equals y`: the same value, no coercion, maps and lists included. */
158
+ const equals = defineMatcher({
159
+ name: "equals",
160
+ args: [arg("value", t.dynamic, "What the subject should be, compared field by field.")],
161
+ test: ({ subject, args }) => deepEquals(subject, args[0]),
162
+ message: ({ subject, args }) => failureLine({
163
+ subject,
164
+ relation: "to equal",
165
+ other: args[0]
166
+ }),
167
+ detail: ({ subject, args }) => ({
168
+ expected: args[0],
169
+ actual: subject
170
+ })
171
+ });
172
+ //#endregion
173
+ //#region src/matchers/one-of.ts
174
+ /** `expect x oneOf [a, b]`: passes if the subject is one of the options. */
175
+ const oneOf = defineMatcher({
176
+ name: "oneOf",
177
+ args: [arg("values", t.list(t.dynamic), "The accepted values. The subject must be one of them.")],
178
+ test: ({ subject, args }) => options(args[0]).some((option) => deepEquals(option, subject)),
179
+ message: ({ subject, args }) => failureLine({
180
+ subject,
181
+ relation: "to be one of",
182
+ other: args[0]
183
+ }),
184
+ detail: ({ subject, args }) => ({
185
+ expected: args[0],
186
+ actual: subject,
187
+ aligned: false
188
+ })
189
+ });
190
+ function options(value) {
191
+ return Array.isArray(value) ? value : [];
192
+ }
193
+ //#endregion
194
+ //#region src/matchers/index.ts
195
+ /** The words `expect` gains from this plugin: `equals`, `contains`, `oneOf`, `closeTo`. */
196
+ const assertMatchers = [
197
+ equals,
198
+ contains,
199
+ oneOf,
200
+ closeTo
201
+ ];
202
+ //#endregion
203
+ //#region src/plugin.ts
204
+ /**
205
+ * The `assert` plugin: the vocabulary behind `expect`.
206
+ *
207
+ * `expect` is kernel syntax; the words that follow it are not. This plugin
208
+ * contributes those words and nothing else, so it declares no verbs, no
209
+ * resources, no types and no host capabilities.
210
+ */
211
+ const assertPlugin = definePlugin({
212
+ name: "venn/assert",
213
+ version: "0.0.0",
214
+ namespace: "assert",
215
+ matchers: assertMatchers
216
+ });
217
+ //#endregion
218
+ export { assertMatchers, assertPlugin, assertPlugin as default };
219
+
220
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/matchers/failure-line.ts","../src/matchers/close-to.ts","../src/matchers/deep-equals.ts","../src/matchers/contains.ts","../src/matchers/equals.ts","../src/matchers/one-of.ts","../src/matchers/index.ts","../src/plugin.ts"],"sourcesContent":["/** A failure title is one line. Past this, a value is summarised instead. */\nconst LIMIT = 44;\n\n/**\n * Builds the line a failure leads with: `expected <subject> <relation> <other>`.\n *\n * Both sides render to the same level of detail: one spelled out next to one\n * summarised reads as a reporter glitch rather than a comparison. Nothing is\n * lost, the full values travel in the problem's diff.\n */\nexport function failureLine(args: { subject: unknown; relation: string; other: unknown }): string {\n const [left, right] = pair(args.subject, args.other);\n return `expected ${left} ${args.relation} ${right}`;\n}\n\nfunction pair(subject: unknown, other: unknown): [string, string] {\n const left = render(subject);\n const right = render(other);\n if (left.length <= LIMIT && right.length <= LIMIT) return [left, right];\n return [summarize(subject), summarize(other)];\n}\n\n/** Strings quoted, maps and lists as compact JSON. Never `[object Object]`. */\nfunction render(value: unknown): string {\n if (value === undefined) return \"absent\";\n if (value === null) return \"null\";\n if (typeof value === \"function\") return \"fn\";\n if (typeof value === \"string\") return JSON.stringify(value);\n if (typeof value !== \"object\") return String(value);\n return json(value);\n}\n\nfunction summarize(value: unknown): string {\n if (typeof value === \"string\") return `${JSON.stringify(value.slice(0, LIMIT))}…`;\n if (typeof value === \"object\" && value !== null) return shapeOf(value);\n return render(value);\n}\n\nfunction shapeOf(value: object): string {\n if (Array.isArray(value)) return `a list of ${value.length} ${plural(\"item\", value.length)}`;\n const count = Object.keys(value).length;\n return `a map with ${count} ${plural(\"field\", count)}`;\n}\n\nfunction json(value: object): string {\n try {\n return JSON.stringify(value) ?? shapeOf(value);\n } catch {\n return shapeOf(value);\n }\n}\n\nfunction plural(noun: string, count: number): string {\n return count === 1 ? noun : `${noun}s`;\n}\n","import { arg, defineMatcher, type MatcherDefinition, z } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { failureLine } from \"./failure-line.js\";\n\nconst DEFAULT_WITHIN = 0.01;\n\n/** `expect x closeTo 99.0 { within: 0.01 }`: numeric equality within a tolerance. */\nexport const closeTo: MatcherDefinition = defineMatcher({\n name: \"closeTo\",\n args: [arg(\"value\", t.number, \"The number to come near. `within` in the options sets how near.\")],\n params: z.object({ within: z.number().default(DEFAULT_WITHIN) }),\n test: ({ subject, args, params }) =>\n Math.abs(Number(subject) - Number(args[0])) <= tolerance(params),\n // The tolerance belongs in the title: without it a failure cannot be judged.\n message: ({ subject, args, params }) =>\n failureLine({ subject, relation: `to be within ${tolerance(params)} of`, other: args[0] }),\n detail: ({ subject, args }) => ({ expected: args[0], actual: subject }),\n});\n\nfunction tolerance(params: unknown): number {\n return (params as { within?: number }).within ?? DEFAULT_WITHIN;\n}\n","/**\n * Structural equality over the language's values.\n *\n * A body, a row or a list of ids is a value, not a handle, so two built the\n * same way are equal. Anything that is not a plain map or a list (dates, plugin\n * objects, closures) still compares by identity: guessing at their innards\n * would be worse than being strict.\n */\nexport function deepEquals(a: unknown, b: unknown): boolean {\n return equal(a, b, []);\n}\n\n/**\n * `open` holds the containers already entered on the way down, so a value that\n * contains itself cannot recurse until the stack gives out. Two containers\n * already open count as equal, the way one cycle matches another.\n */\nfunction equal(a: unknown, b: unknown, open: readonly unknown[]): boolean {\n if (a === b) return true;\n const openA = open.includes(a);\n const openB = open.includes(b);\n if (openA || openB) return openA && openB;\n if (Array.isArray(a) || Array.isArray(b)) return sameList(a, b, open);\n if (!isMap(a) || !isMap(b)) return false;\n return sameMap(a, b, open);\n}\n\nfunction sameList(a: unknown, b: unknown, open: readonly unknown[]): boolean {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n const next = [...open, a, b];\n return a.every((item, index) => equal(item, b[index], next));\n}\n\n/**\n * A field set to nothing is not a field. `{ id: 1, ref: absent }` prints, and\n * travels over the wire, exactly like `{ id: 1 }`, so the two compare equal.\n */\nfunction sameMap(\n a: Record<string, unknown>,\n b: Record<string, unknown>,\n open: readonly unknown[],\n): boolean {\n const keys = presentKeys(a);\n if (keys.length !== presentKeys(b).length) return false;\n const next = [...open, a, b];\n return keys.every((key) => equal(a[key], b[key], next));\n}\n\nfunction presentKeys(value: Record<string, unknown>): string[] {\n return Object.keys(value).filter((key) => value[key] !== undefined);\n}\n\nfunction isMap(value: unknown): value is Record<string, unknown> {\n if (typeof value !== \"object\" || value === null) return false;\n const proto = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}\n","import { arg, defineMatcher, type MatcherDefinition } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { deepEquals } from \"./deep-equals.js\";\nimport { failureLine } from \"./failure-line.js\";\n\n/** `expect x contains y`: substring for strings, membership for lists. */\nexport const contains: MatcherDefinition = defineMatcher({\n name: \"contains\",\n args: [arg(\"value\", t.dynamic, \"What to look for: a substring, or an item of the list.\")],\n test: ({ subject, args }) => includes(subject, args[0]),\n message: ({ subject, args }) => failureLine({ subject, relation: \"to contain\", other: args[0] }),\n // `aligned: false`: the needle is held against every item, never against item\n // 0, so the two sides do not correspond by position.\n detail: ({ subject, args }) => ({ expected: args[0], actual: subject, aligned: false }),\n});\n\nfunction includes(subject: unknown, value: unknown): boolean {\n if (typeof subject === \"string\") return subject.includes(String(value));\n if (Array.isArray(subject)) return subject.some((item) => deepEquals(item, value));\n return false;\n}\n","import { arg, defineMatcher, type MatcherDefinition } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { deepEquals } from \"./deep-equals.js\";\nimport { failureLine } from \"./failure-line.js\";\n\n/** `expect x equals y`: the same value, no coercion, maps and lists included. */\nexport const equals: MatcherDefinition = defineMatcher({\n name: \"equals\",\n args: [arg(\"value\", t.dynamic, \"What the subject should be, compared field by field.\")],\n test: ({ subject, args }) => deepEquals(subject, args[0]),\n message: ({ subject, args }) => failureLine({ subject, relation: \"to equal\", other: args[0] }),\n detail: ({ subject, args }) => ({ expected: args[0], actual: subject }),\n});\n","import { arg, defineMatcher, type MatcherDefinition } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { deepEquals } from \"./deep-equals.js\";\nimport { failureLine } from \"./failure-line.js\";\n\n/** `expect x oneOf [a, b]`: passes if the subject is one of the options. */\nexport const oneOf: MatcherDefinition = defineMatcher({\n name: \"oneOf\",\n args: [arg(\"values\", t.list(t.dynamic), \"The accepted values. The subject must be one of them.\")],\n test: ({ subject, args }) => options(args[0]).some((option) => deepEquals(option, subject)),\n message: ({ subject, args }) =>\n failureLine({ subject, relation: \"to be one of\", other: args[0] }),\n // The subject is held against every option, never against one by position.\n detail: ({ subject, args }) => ({ expected: args[0], actual: subject, aligned: false }),\n});\n\nfunction options(value: unknown): readonly unknown[] {\n return Array.isArray(value) ? value : [];\n}\n","import type { MatcherDefinition } from \"@venn-lang/sdk\";\nimport { closeTo } from \"./close-to.js\";\nimport { contains } from \"./contains.js\";\nimport { equals } from \"./equals.js\";\nimport { oneOf } from \"./one-of.js\";\n\n/** The words `expect` gains from this plugin: `equals`, `contains`, `oneOf`, `closeTo`. */\nexport const assertMatchers: MatcherDefinition[] = [equals, contains, oneOf, closeTo];\n","import { definePlugin, type PluginDefinition } from \"@venn-lang/sdk\";\nimport { assertMatchers } from \"./matchers/index.js\";\n\n/**\n * The `assert` plugin: the vocabulary behind `expect`.\n *\n * `expect` is kernel syntax; the words that follow it are not. This plugin\n * contributes those words and nothing else, so it declares no verbs, no\n * resources, no types and no host capabilities.\n */\nexport const assertPlugin: PluginDefinition = definePlugin({\n name: \"venn/assert\",\n version: \"0.0.0\",\n namespace: \"assert\",\n matchers: assertMatchers,\n});\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@venn-lang/assert",
3
+ "version": "0.1.0",
4
+ "description": "The words that follow expect: equals, contains, oneOf and closeTo.",
5
+ "keywords": [
6
+ "venn",
7
+ "testing",
8
+ "e2e",
9
+ "assert"
10
+ ],
11
+ "homepage": "https://github.com/venn-lang/venn/tree/main/packages/std-assert#readme",
12
+ "bugs": "https://github.com/venn-lang/venn/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/venn-lang/venn.git",
16
+ "directory": "packages/std-assert"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Vinicius Borges",
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": {
24
+ "development": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "!src/**/*.test.ts",
34
+ "!src/**/*.suite.ts"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@venn-lang/sdk": "0.1.0",
41
+ "@venn-lang/types": "0.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "tsdown": "^0.22.14",
45
+ "typescript": "^7.0.2",
46
+ "vitest": "^4.1.10"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "test": "vitest run",
51
+ "typecheck": "tsc --noEmit"
52
+ }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { assertMatchers } from "./matchers/index.js";
2
+ export { assertPlugin, assertPlugin as default } from "./plugin.js";
@@ -0,0 +1,22 @@
1
+ import { arg, defineMatcher, type MatcherDefinition, z } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { failureLine } from "./failure-line.js";
4
+
5
+ const DEFAULT_WITHIN = 0.01;
6
+
7
+ /** `expect x closeTo 99.0 { within: 0.01 }`: numeric equality within a tolerance. */
8
+ export const closeTo: MatcherDefinition = defineMatcher({
9
+ name: "closeTo",
10
+ args: [arg("value", t.number, "The number to come near. `within` in the options sets how near.")],
11
+ params: z.object({ within: z.number().default(DEFAULT_WITHIN) }),
12
+ test: ({ subject, args, params }) =>
13
+ Math.abs(Number(subject) - Number(args[0])) <= tolerance(params),
14
+ // The tolerance belongs in the title: without it a failure cannot be judged.
15
+ message: ({ subject, args, params }) =>
16
+ failureLine({ subject, relation: `to be within ${tolerance(params)} of`, other: args[0] }),
17
+ detail: ({ subject, args }) => ({ expected: args[0], actual: subject }),
18
+ });
19
+
20
+ function tolerance(params: unknown): number {
21
+ return (params as { within?: number }).within ?? DEFAULT_WITHIN;
22
+ }
@@ -0,0 +1,21 @@
1
+ import { arg, defineMatcher, type MatcherDefinition } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { deepEquals } from "./deep-equals.js";
4
+ import { failureLine } from "./failure-line.js";
5
+
6
+ /** `expect x contains y`: substring for strings, membership for lists. */
7
+ export const contains: MatcherDefinition = defineMatcher({
8
+ name: "contains",
9
+ args: [arg("value", t.dynamic, "What to look for: a substring, or an item of the list.")],
10
+ test: ({ subject, args }) => includes(subject, args[0]),
11
+ message: ({ subject, args }) => failureLine({ subject, relation: "to contain", other: args[0] }),
12
+ // `aligned: false`: the needle is held against every item, never against item
13
+ // 0, so the two sides do not correspond by position.
14
+ detail: ({ subject, args }) => ({ expected: args[0], actual: subject, aligned: false }),
15
+ });
16
+
17
+ function includes(subject: unknown, value: unknown): boolean {
18
+ if (typeof subject === "string") return subject.includes(String(value));
19
+ if (Array.isArray(subject)) return subject.some((item) => deepEquals(item, value));
20
+ return false;
21
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Structural equality over the language's values.
3
+ *
4
+ * A body, a row or a list of ids is a value, not a handle, so two built the
5
+ * same way are equal. Anything that is not a plain map or a list (dates, plugin
6
+ * objects, closures) still compares by identity: guessing at their innards
7
+ * would be worse than being strict.
8
+ */
9
+ export function deepEquals(a: unknown, b: unknown): boolean {
10
+ return equal(a, b, []);
11
+ }
12
+
13
+ /**
14
+ * `open` holds the containers already entered on the way down, so a value that
15
+ * contains itself cannot recurse until the stack gives out. Two containers
16
+ * already open count as equal, the way one cycle matches another.
17
+ */
18
+ function equal(a: unknown, b: unknown, open: readonly unknown[]): boolean {
19
+ if (a === b) return true;
20
+ const openA = open.includes(a);
21
+ const openB = open.includes(b);
22
+ if (openA || openB) return openA && openB;
23
+ if (Array.isArray(a) || Array.isArray(b)) return sameList(a, b, open);
24
+ if (!isMap(a) || !isMap(b)) return false;
25
+ return sameMap(a, b, open);
26
+ }
27
+
28
+ function sameList(a: unknown, b: unknown, open: readonly unknown[]): boolean {
29
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
30
+ const next = [...open, a, b];
31
+ return a.every((item, index) => equal(item, b[index], next));
32
+ }
33
+
34
+ /**
35
+ * A field set to nothing is not a field. `{ id: 1, ref: absent }` prints, and
36
+ * travels over the wire, exactly like `{ id: 1 }`, so the two compare equal.
37
+ */
38
+ function sameMap(
39
+ a: Record<string, unknown>,
40
+ b: Record<string, unknown>,
41
+ open: readonly unknown[],
42
+ ): boolean {
43
+ const keys = presentKeys(a);
44
+ if (keys.length !== presentKeys(b).length) return false;
45
+ const next = [...open, a, b];
46
+ return keys.every((key) => equal(a[key], b[key], next));
47
+ }
48
+
49
+ function presentKeys(value: Record<string, unknown>): string[] {
50
+ return Object.keys(value).filter((key) => value[key] !== undefined);
51
+ }
52
+
53
+ function isMap(value: unknown): value is Record<string, unknown> {
54
+ if (typeof value !== "object" || value === null) return false;
55
+ const proto = Object.getPrototypeOf(value);
56
+ return proto === Object.prototype || proto === null;
57
+ }
@@ -0,0 +1,13 @@
1
+ import { arg, defineMatcher, type MatcherDefinition } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { deepEquals } from "./deep-equals.js";
4
+ import { failureLine } from "./failure-line.js";
5
+
6
+ /** `expect x equals y`: the same value, no coercion, maps and lists included. */
7
+ export const equals: MatcherDefinition = defineMatcher({
8
+ name: "equals",
9
+ args: [arg("value", t.dynamic, "What the subject should be, compared field by field.")],
10
+ test: ({ subject, args }) => deepEquals(subject, args[0]),
11
+ message: ({ subject, args }) => failureLine({ subject, relation: "to equal", other: args[0] }),
12
+ detail: ({ subject, args }) => ({ expected: args[0], actual: subject }),
13
+ });
@@ -0,0 +1,55 @@
1
+ /** A failure title is one line. Past this, a value is summarised instead. */
2
+ const LIMIT = 44;
3
+
4
+ /**
5
+ * Builds the line a failure leads with: `expected <subject> <relation> <other>`.
6
+ *
7
+ * Both sides render to the same level of detail: one spelled out next to one
8
+ * summarised reads as a reporter glitch rather than a comparison. Nothing is
9
+ * lost, the full values travel in the problem's diff.
10
+ */
11
+ export function failureLine(args: { subject: unknown; relation: string; other: unknown }): string {
12
+ const [left, right] = pair(args.subject, args.other);
13
+ return `expected ${left} ${args.relation} ${right}`;
14
+ }
15
+
16
+ function pair(subject: unknown, other: unknown): [string, string] {
17
+ const left = render(subject);
18
+ const right = render(other);
19
+ if (left.length <= LIMIT && right.length <= LIMIT) return [left, right];
20
+ return [summarize(subject), summarize(other)];
21
+ }
22
+
23
+ /** Strings quoted, maps and lists as compact JSON. Never `[object Object]`. */
24
+ function render(value: unknown): string {
25
+ if (value === undefined) return "absent";
26
+ if (value === null) return "null";
27
+ if (typeof value === "function") return "fn";
28
+ if (typeof value === "string") return JSON.stringify(value);
29
+ if (typeof value !== "object") return String(value);
30
+ return json(value);
31
+ }
32
+
33
+ function summarize(value: unknown): string {
34
+ if (typeof value === "string") return `${JSON.stringify(value.slice(0, LIMIT))}…`;
35
+ if (typeof value === "object" && value !== null) return shapeOf(value);
36
+ return render(value);
37
+ }
38
+
39
+ function shapeOf(value: object): string {
40
+ if (Array.isArray(value)) return `a list of ${value.length} ${plural("item", value.length)}`;
41
+ const count = Object.keys(value).length;
42
+ return `a map with ${count} ${plural("field", count)}`;
43
+ }
44
+
45
+ function json(value: object): string {
46
+ try {
47
+ return JSON.stringify(value) ?? shapeOf(value);
48
+ } catch {
49
+ return shapeOf(value);
50
+ }
51
+ }
52
+
53
+ function plural(noun: string, count: number): string {
54
+ return count === 1 ? noun : `${noun}s`;
55
+ }
@@ -0,0 +1,8 @@
1
+ import type { MatcherDefinition } from "@venn-lang/sdk";
2
+ import { closeTo } from "./close-to.js";
3
+ import { contains } from "./contains.js";
4
+ import { equals } from "./equals.js";
5
+ import { oneOf } from "./one-of.js";
6
+
7
+ /** The words `expect` gains from this plugin: `equals`, `contains`, `oneOf`, `closeTo`. */
8
+ export const assertMatchers: MatcherDefinition[] = [equals, contains, oneOf, closeTo];
@@ -0,0 +1,19 @@
1
+ import { arg, defineMatcher, type MatcherDefinition } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { deepEquals } from "./deep-equals.js";
4
+ import { failureLine } from "./failure-line.js";
5
+
6
+ /** `expect x oneOf [a, b]`: passes if the subject is one of the options. */
7
+ export const oneOf: MatcherDefinition = defineMatcher({
8
+ name: "oneOf",
9
+ args: [arg("values", t.list(t.dynamic), "The accepted values. The subject must be one of them.")],
10
+ test: ({ subject, args }) => options(args[0]).some((option) => deepEquals(option, subject)),
11
+ message: ({ subject, args }) =>
12
+ failureLine({ subject, relation: "to be one of", other: args[0] }),
13
+ // The subject is held against every option, never against one by position.
14
+ detail: ({ subject, args }) => ({ expected: args[0], actual: subject, aligned: false }),
15
+ });
16
+
17
+ function options(value: unknown): readonly unknown[] {
18
+ return Array.isArray(value) ? value : [];
19
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { definePlugin, type PluginDefinition } from "@venn-lang/sdk";
2
+ import { assertMatchers } from "./matchers/index.js";
3
+
4
+ /**
5
+ * The `assert` plugin: the vocabulary behind `expect`.
6
+ *
7
+ * `expect` is kernel syntax; the words that follow it are not. This plugin
8
+ * contributes those words and nothing else, so it declares no verbs, no
9
+ * resources, no types and no host capabilities.
10
+ */
11
+ export const assertPlugin: PluginDefinition = definePlugin({
12
+ name: "venn/assert",
13
+ version: "0.0.0",
14
+ namespace: "assert",
15
+ matchers: assertMatchers,
16
+ });