@systemfsoftware/effect-schema-vite 1.2.0 → 1.4.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.
@@ -0,0 +1,53 @@
1
+ import { Plugin as Plugin_2 } from 'vite';
2
+
3
+ /**
4
+ * Build the law-suite body injected into a consumer's `schema-laws.test.ts`.
5
+ *
6
+ * Import specifiers are relative to `lawFilePath`, never to the Vite root: the
7
+ * file being rewritten lives in `src/`, so a root-relative specifier resolves
8
+ * to `src/src/…` and yields a suite that silently imports nothing.
9
+ *
10
+ * @since 1.4.0
11
+ */
12
+ export declare const generateSchemaLaws: (lawFilePath: string, srcDir: string) => string;
13
+
14
+ /**
15
+ * Vite plugin that walks the consumer's `src/` directory, finds every
16
+ * exported Effect `Schema`, and auto-injects `ruleOfSchemas` round-trip
17
+ * property tests for each one.
18
+ *
19
+ * The laws are injected by rewriting the consumer's own
20
+ * `src/schema-laws.test.ts` — the one test filename the placement taxonomy
21
+ * whitelists by name. It is deliberately NOT a virtual module: the generated
22
+ * body carries real `import` edges to each schema file, so vitest's
23
+ * related-file walk reaches them. A virtual module breaks that walk (its id
24
+ * has no path on disk), which silently drops every generated law from
25
+ * `stryker --related` runs and reports the survivors as coverage gaps.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * // vitest.config.ts
30
+ * import { inlineSchemaTests } from '@systemfsoftware/effect-schema-vite'
31
+ *
32
+ * export default defineConfig({
33
+ * plugins: [inlineSchemaTests()],
34
+ * })
35
+ * ```
36
+ */
37
+ export declare const inlineSchemaTests: (options?: InlineSchemaTestsOptions) => Plugin_2;
38
+
39
+ /** @since 0.1.0 */
40
+ export declare interface InlineSchemaTestsOptions {
41
+ /** Directory to scan for schema files, relative to Vite root. Default: `"src"`. */
42
+ dir?: string;
43
+ }
44
+
45
+ /**
46
+ * The one test filename the placement taxonomy whitelists by name. The plugin
47
+ * rewrites this file in the consumer's `src/`; nothing else is touched.
48
+ *
49
+ * @since 1.4.0
50
+ */
51
+ export declare const LAW_FILE_BASENAME: 'schema-laws.test.ts';
52
+
53
+ export { }
package/dist/index.d.ts CHANGED
@@ -5,11 +5,36 @@ interface InlineSchemaTestsOptions {
5
5
  /** Directory to scan for schema files, relative to Vite root. Default: `"src"`. */
6
6
  dir?: string;
7
7
  }
8
+ /**
9
+ * The one test filename the placement taxonomy whitelists by name. The plugin
10
+ * rewrites this file in the consumer's `src/`; nothing else is touched.
11
+ *
12
+ * @since 1.4.0
13
+ */
14
+ declare const LAW_FILE_BASENAME: 'schema-laws.test.ts';
15
+ /**
16
+ * Build the law-suite body injected into a consumer's `schema-laws.test.ts`.
17
+ *
18
+ * Import specifiers are relative to `lawFilePath`, never to the Vite root: the
19
+ * file being rewritten lives in `src/`, so a root-relative specifier resolves
20
+ * to `src/src/…` and yields a suite that silently imports nothing.
21
+ *
22
+ * @since 1.4.0
23
+ */
24
+ declare const generateSchemaLaws: (lawFilePath: string, srcDir: string) => string;
8
25
  /**
9
26
  * Vite plugin that walks the consumer's `src/` directory, finds every
10
27
  * exported Effect `Schema`, and auto-injects `ruleOfSchemas` round-trip
11
28
  * property tests for each one.
12
29
  *
30
+ * The laws are injected by rewriting the consumer's own
31
+ * `src/schema-laws.test.ts` — the one test filename the placement taxonomy
32
+ * whitelists by name. It is deliberately NOT a virtual module: the generated
33
+ * body carries real `import` edges to each schema file, so vitest's
34
+ * related-file walk reaches them. A virtual module breaks that walk (its id
35
+ * has no path on disk), which silently drops every generated law from
36
+ * `stryker --related` runs and reports the survivors as coverage gaps.
37
+ *
13
38
  * @example
14
39
  * ```ts
15
40
  * // vitest.config.ts
@@ -22,4 +47,4 @@ interface InlineSchemaTestsOptions {
22
47
  */
23
48
  declare const inlineSchemaTests: (options?: InlineSchemaTestsOptions) => Plugin;
24
49
  //#endregion
25
- export { type InlineSchemaTestsOptions, inlineSchemaTests };
50
+ export { type InlineSchemaTestsOptions, LAW_FILE_BASENAME, generateSchemaLaws, inlineSchemaTests };
package/dist/index.mjs CHANGED
@@ -1,12 +1,19 @@
1
1
  import { readFileSync, readdirSync, statSync } from "node:fs";
2
- import { extname, join, relative, resolve } from "node:path";
2
+ import { dirname, extname, join, relative, resolve } from "node:path";
3
3
  import { parseSync } from "oxc-parser";
4
4
  //#region src/vite-inline-schema-tests.ts
5
5
  /**
6
+ * The one test filename the placement taxonomy whitelists by name. The plugin
7
+ * rewrites this file in the consumer's `src/`; nothing else is touched.
8
+ *
9
+ * @since 1.4.0
10
+ */
11
+ const LAW_FILE_BASENAME = "schema-laws.test.ts";
12
+ /**
6
13
  * Walk a directory and return every exported const whose type annotation
7
14
  * or initialiser references Effect Schema APIs.
8
15
  */
9
- function findExportedSchemas(root, dir) {
16
+ function findExportedSchemas(dir) {
10
17
  const schemas = [];
11
18
  function walk(current) {
12
19
  let entries;
@@ -30,7 +37,7 @@ function findExportedSchemas(root, dir) {
30
37
  const names = findExportedSchemaNames(readFileSync(full, "utf-8"));
31
38
  for (const name of names) schemas.push({
32
39
  name,
33
- importPath: `./${relative(root, full).replace(/\.ts$/, "")}`
40
+ filePath: full
34
41
  });
35
42
  }
36
43
  }
@@ -45,7 +52,13 @@ function findExportedSchemaNames(source) {
45
52
  for (const node of result.program.body) {
46
53
  if (node.type !== "ExportNamedDeclaration") continue;
47
54
  const decl = node.declaration;
48
- if (!decl || decl.type !== "VariableDeclaration") continue;
55
+ if (!decl) continue;
56
+ if (decl.type === "ClassDeclaration") {
57
+ const className = decl.id?.name;
58
+ if (typeof className === "string" && !className.startsWith("_") && extendsSchemaClass(decl.superClass)) names.push(className);
59
+ continue;
60
+ }
61
+ if (decl.type !== "VariableDeclaration") continue;
49
62
  for (const declarator of decl.declarations) {
50
63
  const id = declarator.id;
51
64
  if (id.type !== "Identifier") continue;
@@ -63,55 +76,90 @@ function findExportedSchemaNames(source) {
63
76
  }
64
77
  }
65
78
  function typeRefContainsSchema(t) {
66
- if (!t || typeof t !== "object") return false;
67
- const node = t;
68
- if (node.type === "TSTypeReference" && node.typeName) {
69
- const tn = node.typeName;
70
- if (tn.type === "Identifier" && typeof tn.name === "string" && tn.name.includes("Schema")) return true;
71
- if (tn.type === "TSQualifiedName" && typeof tn.name === "string" && tn.name.includes("Schema")) return true;
72
- }
73
- if (node.typeParameters) {
74
- const params = node.typeParameters;
75
- if (Array.isArray(params.params)) {
76
- for (const p of params.params) if (typeRefContainsSchema(p)) return true;
77
- }
78
- }
79
- if (node.types && Array.isArray(node.types)) {
80
- for (const m of node.types) if (typeRefContainsSchema(m)) return true;
81
- }
79
+ if (!t) return false;
80
+ if (t.type === "TSTypeReference" && t.typeName.type === "Identifier") return t.typeName.name.includes("Schema");
81
+ if (t.type === "TSUnionType" || t.type === "TSIntersectionType") return t.types.some((member) => typeRefContainsSchema(member));
82
82
  return false;
83
83
  }
84
84
  function initRefersToSchema(expr) {
85
- if (!expr || typeof expr !== "object") return false;
86
- const node = expr;
87
- if (node.type === "CallExpression" && node.callee) {
88
- const callee = node.callee;
89
- if (callee.type === "Identifier" && callee.name === "pipe") {
90
- const args = node.arguments;
91
- if (Array.isArray(args)) {
92
- for (const arg of args) if (initRefersToSchema(arg)) return true;
93
- }
94
- return false;
95
- }
85
+ if (!expr) return false;
86
+ if (expr.type === "CallExpression") {
87
+ const callee = expr.callee;
88
+ if (callee.type === "Identifier" && callee.name === "pipe") return expr.arguments.some((arg) => arg.type !== "SpreadElement" && initRefersToSchema(arg));
96
89
  if (callee.type === "MemberExpression") return memberChainStartsWithS(callee);
97
- if (callee.type === "Identifier" && callee.name.includes("Schema")) return true;
90
+ if (callee.type === "Identifier") return callee.name.includes("Schema");
91
+ return false;
98
92
  }
99
- if (node.type === "MemberExpression") return memberChainStartsWithS(node);
93
+ if (expr.type === "MemberExpression") return memberChainStartsWithS(expr);
100
94
  return false;
101
95
  }
102
96
  function memberChainStartsWithS(node) {
103
97
  const obj = node.object;
104
- if (!obj) return false;
105
- if (obj.type === "Identifier" && obj.name === "S") return true;
106
- if (obj.type === "Identifier" && typeof obj.name === "string" && obj.name.includes("Schema")) return true;
98
+ if (obj.type === "Identifier") return obj.name === "S" || obj.name.includes("Schema");
107
99
  if (obj.type === "MemberExpression") return memberChainStartsWithS(obj);
100
+ if (obj.type === "CallExpression") {
101
+ const callee = obj.callee;
102
+ if (callee.type === "MemberExpression") return memberChainStartsWithS(callee);
103
+ if (callee.type === "Identifier") return callee.name === "S" || callee.name.includes("Schema");
104
+ }
108
105
  return false;
109
106
  }
110
107
  /**
108
+ * True when a class extends `Schema.Class(...)` or `Schema.TaggedClass(...)`.
109
+ * The constructor is curried — `Schema.Class<Foo>()({...})` is two nested
110
+ * CallExpressions wrapping one MemberExpression — so unwrap the call chain
111
+ * before inspecting the member.
112
+ *
113
+ * `Schema.TaggedError` is deliberately excluded: an error is a failure value,
114
+ * not a codec, so it is not subject to the round-trip laws (and its `cause`
115
+ * field is routinely `S.Unknown`, which does not round-trip).
116
+ */
117
+ function extendsSchemaClass(superClass) {
118
+ if (!superClass) return false;
119
+ let callee = superClass;
120
+ while (callee.type === "CallExpression") callee = callee.callee;
121
+ if (callee.type !== "MemberExpression") return false;
122
+ if (callee.property.type !== "Identifier") return false;
123
+ const propName = callee.property.name;
124
+ if (propName !== "Class" && propName !== "TaggedClass") return false;
125
+ return memberChainStartsWithS(callee);
126
+ }
127
+ /**
128
+ * Build the law-suite body injected into a consumer's `schema-laws.test.ts`.
129
+ *
130
+ * Import specifiers are relative to `lawFilePath`, never to the Vite root: the
131
+ * file being rewritten lives in `src/`, so a root-relative specifier resolves
132
+ * to `src/src/…` and yields a suite that silently imports nothing.
133
+ *
134
+ * @since 1.4.0
135
+ */
136
+ const generateSchemaLaws = (lawFilePath, srcDir) => {
137
+ const schemas = findExportedSchemas(srcDir);
138
+ if (schemas.length === 0) return "// no schemas found\nexport {}\n";
139
+ const specifierOf = (filePath) => {
140
+ const rel = relative(dirname(lawFilePath), filePath).replace(/\.ts$/, "");
141
+ return rel.startsWith(".") ? rel : `./${rel}`;
142
+ };
143
+ return [
144
+ `import { ruleOfSchemas } from '@systemfsoftware/effect-schema-law'`,
145
+ schemas.map((s) => `import { ${s.name} } from '${specifierOf(s.filePath)}'`).join("\n"),
146
+ "",
147
+ schemas.map((s) => `ruleOfSchemas('${s.name}', ${s.name})`).join("\n")
148
+ ].join("\n");
149
+ };
150
+ /**
111
151
  * Vite plugin that walks the consumer's `src/` directory, finds every
112
152
  * exported Effect `Schema`, and auto-injects `ruleOfSchemas` round-trip
113
153
  * property tests for each one.
114
154
  *
155
+ * The laws are injected by rewriting the consumer's own
156
+ * `src/schema-laws.test.ts` — the one test filename the placement taxonomy
157
+ * whitelists by name. It is deliberately NOT a virtual module: the generated
158
+ * body carries real `import` edges to each schema file, so vitest's
159
+ * related-file walk reaches them. A virtual module breaks that walk (its id
160
+ * has no path on disk), which silently drops every generated law from
161
+ * `stryker --related` runs and reports the survivors as coverage gaps.
162
+ *
115
163
  * @example
116
164
  * ```ts
117
165
  * // vitest.config.ts
@@ -125,27 +173,17 @@ function memberChainStartsWithS(node) {
125
173
  const inlineSchemaTests = (options) => {
126
174
  let config;
127
175
  return {
128
- name: "@systemfsoftware/inline-schema-tests",
176
+ name: "@systemfsoftware/schema-laws",
129
177
  enforce: "pre",
130
178
  configResolved(c) {
131
179
  config = c;
132
180
  },
133
- resolveId(id) {
134
- if (id === "virtual:@systemfsoftware/inline-schema-tests") return "\0virtual:@systemfsoftware/inline-schema-tests";
135
- },
136
- load(id) {
137
- if (id !== "\0virtual:@systemfsoftware/inline-schema-tests") return;
138
- const srcDir = resolve(config.root, options?.dir ?? "src");
139
- const schemas = findExportedSchemas(config.root, srcDir);
140
- if (schemas.length === 0) return "// no schemas found";
141
- return [
142
- `import { ruleOfSchemas } from '@systemfsoftware/effect-schema-law'`,
143
- schemas.map((s) => `import { ${s.name} } from '${s.importPath}'`).join("\n"),
144
- "",
145
- schemas.map((s) => `ruleOfSchemas('${s.name}', ${s.name})`).join("\n")
146
- ].join("\n");
181
+ transform(_code, id) {
182
+ const lawFile = id.split("?")[0];
183
+ if (lawFile === void 0 || !lawFile.endsWith(`/schema-laws.test.ts`)) return;
184
+ return generateSchemaLaws(lawFile, resolve(config.root, options?.dir ?? "src"));
147
185
  }
148
186
  };
149
187
  };
150
188
  //#endregion
151
- export { inlineSchemaTests };
189
+ export { LAW_FILE_BASENAME, generateSchemaLaws, inlineSchemaTests };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@systemfsoftware/effect-schema-vite",
3
3
  "license": "MIT",
4
- "version": "1.2.0",
4
+ "version": "1.4.0",
5
5
  "author": "Ryan Lee <drdgvhbh@gmail.com>",
6
6
  "repository": {
7
7
  "type": "git",
@@ -45,11 +45,11 @@
45
45
  "tsdown": "^0.22.9",
46
46
  "vite": "^7",
47
47
  "vitest": "^4",
48
+ "@systemfsoftware/arethetypeswrong-cli": "^1.0.7",
48
49
  "@systemfsoftware/oxlint-config": "^0.1.0",
49
- "@systemfsoftware/tsconfig": "^1.2.6",
50
+ "@systemfsoftware/effect-schema-law": "^0.5.0",
50
51
  "@systemfsoftware/vitest-config": "^0.1.0",
51
- "@systemfsoftware/arethetypeswrong-cli": "^1.0.7",
52
- "@systemfsoftware/effect-schema-law": "^0.4.0"
52
+ "@systemfsoftware/tsconfig": "^1.2.6"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@systemfsoftware/effect-schema-law": "*",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "scripts": {
64
64
  "clean": "rimraf dist",
65
- "build": "tsdown",
65
+ "build": "tsdown && pnpm api:check",
66
66
  "typecheck": "tsc --noEmit --incremental",
67
67
  "test": "vitest run",
68
68
  "test:run": "vitest run",