zodrift 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -45,11 +45,12 @@ What it catches:
45
45
  - extra fields
46
46
  - required vs optional mismatch
47
47
  - basic type mismatch
48
+ - semantic mismatch when your TS type is not assignable to `z.input<typeof Schema>` / `z.output<typeof Schema>` (optional mode)
48
49
 
49
50
  CI quick start:
50
51
 
51
52
  ```yaml
52
- - run: npx zodrift check --pattern "src/**/*.{ts,tsx}"
53
+ - run: npx zodrift check --pattern "src/**/*.{ts,tsx}" --semantics both
53
54
  ```
54
55
 
55
56
  Exit codes:
@@ -63,6 +64,9 @@ Useful commands:
63
64
  # Check current project
64
65
  npx zodrift check --pattern "src/**/*.{ts,tsx}"
65
66
 
67
+ # Add semantic compatibility checks for z.input/z.output
68
+ npx zodrift check --pattern "src/**/*.{ts,tsx}" --semantics both
69
+
66
70
  # Machine-readable report for CI artifacts
67
71
  npx zodrift check --format json --out reports/zodrift.json
68
72
 
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ function printHelp() {
9
9
  process.stdout.write(`zodrift
10
10
 
11
11
  Usage:
12
- zodrift check [--pattern <glob>] [--format pretty|json|sarif] [--out <file>] [--changed]
12
+ zodrift check [--pattern <glob>] [--format pretty|json|sarif] [--semantics off|input|output|both] [--out <file>] [--changed]
13
13
  zodrift fix [--pattern <glob>] [--target schema] [--dry-run] [--write]
14
14
  zodrift codegen [--from ts|zod] [--pattern <glob>] [--out-dir <dir>] [--write]
15
15
  zodrift openapi [--pattern <glob>] [--out <file>]
@@ -18,6 +18,7 @@ Usage:
18
18
  Examples:
19
19
  npx zodrift check
20
20
  npx zodrift check --pattern "examples/**/*.ts"
21
+ npx zodrift check --semantics both
21
22
  npx zodrift check --format json --out reports/zodrift.json
22
23
  npx zodrift fix --pattern "src/**/*.ts" --dry-run
23
24
  `);
@@ -3,16 +3,29 @@ import path from "node:path";
3
3
  import { runCheck } from "../core/checker.js";
4
4
  import { renderOutput } from "../reporters/index.js";
5
5
  import { flagBoolean, flagNumber, flagString } from "../utils/args.js";
6
+ function parseSemanticsMode(value) {
7
+ if (value === "off" || value === "input" || value === "output" || value === "both") {
8
+ return value;
9
+ }
10
+ return null;
11
+ }
6
12
  export function runCheckCommand(flags) {
7
13
  const cwd = process.cwd();
8
14
  const pattern = flagString(flags, "pattern", "**/*.{ts,tsx}");
9
15
  const format = flagString(flags, "format", "pretty");
16
+ const semanticsRaw = flagString(flags, "semantics", "off");
17
+ const semantics = parseSemanticsMode(semanticsRaw);
18
+ if (!semantics) {
19
+ process.stderr.write(`Invalid --semantics value: ${semanticsRaw}. Use one of: off, input, output, both.\n`);
20
+ return 2;
21
+ }
10
22
  const maxIssues = flagNumber(flags, "max-issues");
11
23
  const changedOnly = flagBoolean(flags, "changed", false);
12
24
  const result = runCheck({
13
25
  cwd,
14
26
  pattern,
15
27
  format,
28
+ semantics,
16
29
  maxIssues,
17
30
  changedOnly,
18
31
  });
@@ -30,6 +30,7 @@ export function runFixCommand(flags) {
30
30
  cwd,
31
31
  pattern,
32
32
  format: "pretty",
33
+ semantics: "off",
33
34
  changedOnly: false,
34
35
  });
35
36
  const fileLines = new Map();
@@ -1,6 +1,7 @@
1
1
  import { comparePair } from "./compare.js";
2
2
  import { discoverSourceFiles } from "./file-discovery.js";
3
3
  import { pairTypesWithSchemas } from "./pairing.js";
4
+ import { collectSemanticIssues, pairKey } from "./semantic-checker.js";
4
5
  import { parseTypeDeclarations } from "./ts-parser.js";
5
6
  import { parseSchemaDeclarations } from "./zod-parser.js";
6
7
  export function runCheck(options) {
@@ -12,14 +13,23 @@ export function runCheck(options) {
12
13
  const typeParsed = parseTypeDeclarations(files);
13
14
  const schemaParsed = parseSchemaDeclarations(files);
14
15
  const paired = pairTypesWithSchemas(typeParsed.declarations, schemaParsed.declarations);
16
+ const semantic = collectSemanticIssues({
17
+ cwd: options.cwd,
18
+ pairs: paired.pairs,
19
+ semantics: options.semantics,
20
+ });
15
21
  const pairs = [];
16
22
  let totalIssues = 0;
23
+ let semanticIssueCount = 0;
17
24
  for (const pair of paired.pairs) {
18
- const issues = comparePair(pair);
25
+ const structuralIssues = comparePair(pair);
26
+ const semanticIssues = semantic.issuesByPair.get(pairKey(pair)) ?? [];
27
+ const issues = [...structuralIssues, ...semanticIssues];
19
28
  const cappedIssues = options.maxIssues && options.maxIssues > 0
20
29
  ? issues.slice(0, options.maxIssues)
21
30
  : issues;
22
31
  totalIssues += cappedIssues.length;
32
+ semanticIssueCount += cappedIssues.filter((issue) => issue.kind === "semantic_mismatch").length;
23
33
  pairs.push({
24
34
  typeName: pair.typeDecl.name,
25
35
  schemaName: pair.schemaDecl.name,
@@ -34,6 +44,8 @@ export function runCheck(options) {
34
44
  checkedPairs: pairs.length,
35
45
  unmatchedTypes: paired.unmatchedTypes,
36
46
  unmatchedSchemas: paired.unmatchedSchemas,
37
- errors: [...typeParsed.errors, ...schemaParsed.errors],
47
+ semanticsMode: options.semantics,
48
+ semanticIssueCount,
49
+ errors: [...typeParsed.errors, ...schemaParsed.errors, ...semantic.errors],
38
50
  };
39
51
  }
@@ -0,0 +1,10 @@
1
+ import type { DriftIssue, Pairing, SemanticsMode } from "./types.js";
2
+ export declare function pairKey(pair: Pairing): string;
3
+ export declare function collectSemanticIssues(options: {
4
+ cwd: string;
5
+ pairs: Pairing[];
6
+ semantics: SemanticsMode;
7
+ }): {
8
+ issuesByPair: Map<string, DriftIssue[]>;
9
+ errors: string[];
10
+ };
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ const TYPE_ALIAS_NAME = "__ZodriftTypeAlias";
5
+ const INPUT_ALIAS_NAME = "__ZodriftInputAlias";
6
+ const OUTPUT_ALIAS_NAME = "__ZodriftOutputAlias";
7
+ const TYPE_IMPORT_NAME = "__ZodriftType";
8
+ const SCHEMA_IMPORT_NAME = "__ZodriftSchema";
9
+ function pairName(pair) {
10
+ return `${pair.typeDecl.name} ↔ ${pair.schemaDecl.name}`;
11
+ }
12
+ export function pairKey(pair) {
13
+ return `${path.resolve(pair.typeDecl.sourceFilePath)}::${pair.typeDecl.name}::${path.resolve(pair.schemaDecl.sourceFilePath)}::${pair.schemaDecl.name}`;
14
+ }
15
+ function loadCompilerOptions(cwd) {
16
+ const fallback = {
17
+ target: ts.ScriptTarget.ES2022,
18
+ module: ts.ModuleKind.NodeNext,
19
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
20
+ jsx: ts.JsxEmit.Preserve,
21
+ allowJs: false,
22
+ skipLibCheck: true,
23
+ strict: true,
24
+ exactOptionalPropertyTypes: true,
25
+ noEmit: true,
26
+ };
27
+ const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json");
28
+ if (!configPath) {
29
+ return fallback;
30
+ }
31
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
32
+ if (read.error) {
33
+ return fallback;
34
+ }
35
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
36
+ return {
37
+ ...parsed.options,
38
+ module: ts.ModuleKind.NodeNext,
39
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
40
+ jsx: parsed.options.jsx ?? ts.JsxEmit.Preserve,
41
+ rootDir: cwd,
42
+ strict: true,
43
+ exactOptionalPropertyTypes: true,
44
+ skipLibCheck: true,
45
+ noEmit: true,
46
+ };
47
+ }
48
+ function toImportSpecifier(fromFilePath, targetFilePath) {
49
+ const relative = path
50
+ .relative(path.dirname(fromFilePath), targetFilePath)
51
+ .split(path.sep)
52
+ .join("/");
53
+ const withDot = relative.startsWith(".") ? relative : `./${relative}`;
54
+ const withoutExt = withDot.replace(/\.[cm]?tsx?$/i, "");
55
+ return `${withoutExt}.js`;
56
+ }
57
+ function formatDiagnostic(diagnostic) {
58
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
59
+ if (!diagnostic.file || typeof diagnostic.start !== "number") {
60
+ return message;
61
+ }
62
+ const pos = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
63
+ const filePath = path.resolve(diagnostic.file.fileName);
64
+ return `${filePath}:${pos.line + 1}:${pos.character + 1} ${message}`;
65
+ }
66
+ function typeText(checker, type) {
67
+ return checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
68
+ }
69
+ function compareEquivalence(checker, left, right) {
70
+ return {
71
+ leftToRight: checker.isTypeAssignableTo(left, right),
72
+ rightToLeft: checker.isTypeAssignableTo(right, left),
73
+ };
74
+ }
75
+ function makeSemanticIssue(params) {
76
+ return {
77
+ kind: "semantic_mismatch",
78
+ pairName: pairName(params.pair),
79
+ path: `(semantic:${params.mode})`,
80
+ message: params.message,
81
+ typeValue: params.typeValue,
82
+ schemaValue: params.schemaValue,
83
+ typeLocation: params.pair.typeDecl.location,
84
+ schemaLocation: params.pair.schemaDecl.location,
85
+ };
86
+ }
87
+ function buildSemanticProbeContent(pair, probeFilePath) {
88
+ const typeSpecifier = toImportSpecifier(probeFilePath, pair.typeDecl.sourceFilePath);
89
+ const schemaSpecifier = toImportSpecifier(probeFilePath, pair.schemaDecl.sourceFilePath);
90
+ return `import { z } from "zod";
91
+ import type { ${pair.typeDecl.name} as ${TYPE_IMPORT_NAME} } from "${typeSpecifier}";
92
+ import { ${pair.schemaDecl.name} as ${SCHEMA_IMPORT_NAME} } from "${schemaSpecifier}";
93
+
94
+ type ${TYPE_ALIAS_NAME} = ${TYPE_IMPORT_NAME};
95
+ type ${INPUT_ALIAS_NAME} = z.input<typeof ${SCHEMA_IMPORT_NAME}>;
96
+ type ${OUTPUT_ALIAS_NAME} = z.output<typeof ${SCHEMA_IMPORT_NAME}>;
97
+ `;
98
+ }
99
+ function findAliasType(checker, sourceFile, aliasName) {
100
+ const alias = sourceFile.statements.find((statement) => ts.isTypeAliasDeclaration(statement) && statement.name.text === aliasName);
101
+ if (!alias) {
102
+ return undefined;
103
+ }
104
+ return checker.getTypeAtLocation(alias);
105
+ }
106
+ export function collectSemanticIssues(options) {
107
+ const issuesByPair = new Map();
108
+ const errors = [];
109
+ if (options.semantics === "off" || options.pairs.length === 0) {
110
+ return { issuesByPair, errors };
111
+ }
112
+ const compilerOptions = loadCompilerOptions(options.cwd);
113
+ const tempDir = fs.mkdtempSync(path.join(options.cwd, ".zodrift-semantic-"));
114
+ const runInput = options.semantics === "input" || options.semantics === "both";
115
+ const runOutput = options.semantics === "output" || options.semantics === "both";
116
+ try {
117
+ for (let index = 0; index < options.pairs.length; index += 1) {
118
+ const pair = options.pairs[index];
119
+ const pairIssues = [];
120
+ const probeFilePath = path.join(tempDir, `probe-${index}.ts`);
121
+ fs.writeFileSync(probeFilePath, buildSemanticProbeContent(pair, probeFilePath), "utf8");
122
+ const program = ts.createProgram([probeFilePath], compilerOptions);
123
+ const diagnostics = ts
124
+ .getPreEmitDiagnostics(program)
125
+ .filter((diag) => diag.category === ts.DiagnosticCategory.Error);
126
+ if (diagnostics.length > 0) {
127
+ const message = formatDiagnostic(diagnostics[0]);
128
+ pairIssues.push(makeSemanticIssue({
129
+ pair,
130
+ mode: runInput ? "input" : "output",
131
+ message: `semantic check could not evaluate pair: ${message}`,
132
+ }));
133
+ issuesByPair.set(pairKey(pair), pairIssues);
134
+ continue;
135
+ }
136
+ const checker = program.getTypeChecker();
137
+ const probeSource = program.getSourceFile(probeFilePath);
138
+ if (!probeSource) {
139
+ pairIssues.push(makeSemanticIssue({
140
+ pair,
141
+ mode: runInput ? "input" : "output",
142
+ message: "semantic check could not load probe source file",
143
+ }));
144
+ issuesByPair.set(pairKey(pair), pairIssues);
145
+ continue;
146
+ }
147
+ const typeType = findAliasType(checker, probeSource, TYPE_ALIAS_NAME);
148
+ const inputType = findAliasType(checker, probeSource, INPUT_ALIAS_NAME);
149
+ const outputType = findAliasType(checker, probeSource, OUTPUT_ALIAS_NAME);
150
+ if (!typeType || !inputType || !outputType) {
151
+ pairIssues.push(makeSemanticIssue({
152
+ pair,
153
+ mode: runInput ? "input" : "output",
154
+ message: "semantic check could not infer probe alias types",
155
+ }));
156
+ issuesByPair.set(pairKey(pair), pairIssues);
157
+ continue;
158
+ }
159
+ const evaluate = (mode, schemaType) => {
160
+ const comparison = compareEquivalence(checker, typeType, schemaType);
161
+ if (comparison.leftToRight) {
162
+ return;
163
+ }
164
+ pairIssues.push(makeSemanticIssue({
165
+ pair,
166
+ mode,
167
+ message: `semantic mismatch (${mode}): ${pair.typeDecl.name} is not assignable to z.${mode}<typeof ${pair.schemaDecl.name}> (type->schema=${comparison.leftToRight}, schema->type=${comparison.rightToLeft})`,
168
+ typeValue: typeText(checker, typeType),
169
+ schemaValue: typeText(checker, schemaType),
170
+ }));
171
+ };
172
+ if (runInput) {
173
+ evaluate("input", inputType);
174
+ }
175
+ if (runOutput) {
176
+ evaluate("output", outputType);
177
+ }
178
+ if (pairIssues.length > 0) {
179
+ issuesByPair.set(pairKey(pair), pairIssues);
180
+ }
181
+ }
182
+ }
183
+ catch (error) {
184
+ errors.push(`Failed semantic check pass: ${String(error)}`);
185
+ }
186
+ finally {
187
+ fs.rmSync(tempDir, { recursive: true, force: true });
188
+ }
189
+ return { issuesByPair, errors };
190
+ }
@@ -65,7 +65,7 @@ export interface SchemaDeclaration {
65
65
  sourceFilePath: string;
66
66
  objectNodeText?: string;
67
67
  }
68
- export type DriftIssueKind = "missing_in_schema" | "extra_in_schema" | "optional_mismatch" | "type_mismatch";
68
+ export type DriftIssueKind = "missing_in_schema" | "extra_in_schema" | "optional_mismatch" | "type_mismatch" | "semantic_mismatch";
69
69
  export interface DriftIssue {
70
70
  kind: DriftIssueKind;
71
71
  pairName: string;
@@ -89,6 +89,8 @@ export interface CheckResult {
89
89
  checkedPairs: number;
90
90
  unmatchedTypes: string[];
91
91
  unmatchedSchemas: string[];
92
+ semanticsMode: SemanticsMode;
93
+ semanticIssueCount: number;
92
94
  errors: string[];
93
95
  }
94
96
  export interface Pairing {
@@ -100,10 +102,12 @@ export interface ReporterPayload {
100
102
  cwd: string;
101
103
  }
102
104
  export type OutputFormat = "pretty" | "json" | "sarif";
105
+ export type SemanticsMode = "off" | "input" | "output" | "both";
103
106
  export interface CheckOptions {
104
107
  cwd: string;
105
108
  pattern: string;
106
109
  format: OutputFormat;
110
+ semantics: SemanticsMode;
107
111
  maxIssues?: number;
108
112
  changedOnly: boolean;
109
113
  }
@@ -3,6 +3,8 @@ export function renderJson(payload) {
3
3
  summary: {
4
4
  checkedPairs: payload.result.checkedPairs,
5
5
  totalIssues: payload.result.totalIssues,
6
+ semanticsMode: payload.result.semanticsMode,
7
+ semanticIssueCount: payload.result.semanticIssueCount,
6
8
  unmatchedTypes: payload.result.unmatchedTypes,
7
9
  unmatchedSchemas: payload.result.unmatchedSchemas,
8
10
  errors: payload.result.errors,
@@ -31,5 +31,8 @@ export function renderPretty(payload) {
31
31
  .join(", ")}`);
32
32
  }
33
33
  lines.push(`Checked pairs: ${payload.result.checkedPairs} | Issues: ${payload.result.totalIssues}`);
34
+ if (payload.result.semanticsMode !== "off") {
35
+ lines.push(`Semantic mode: ${payload.result.semanticsMode} | Semantic issues: ${payload.result.semanticIssueCount}`);
36
+ }
34
37
  return lines.join("\n").trim();
35
38
  }
@@ -3,6 +3,7 @@ function levelForIssue(kind) {
3
3
  switch (kind) {
4
4
  case "type_mismatch":
5
5
  case "optional_mismatch":
6
+ case "semantic_mismatch":
6
7
  return "error";
7
8
  default:
8
9
  return "warning";
@@ -18,6 +19,8 @@ function ruleName(kind) {
18
19
  return "optional-mismatch";
19
20
  case "type_mismatch":
20
21
  return "type-mismatch";
22
+ case "semantic_mismatch":
23
+ return "semantic-mismatch";
21
24
  default:
22
25
  return "drift";
23
26
  }
@@ -40,6 +43,10 @@ export function renderSarif(payload) {
40
43
  id: "type-mismatch",
41
44
  shortDescription: { text: "Type mismatch between TS and Zod" },
42
45
  },
46
+ {
47
+ id: "semantic-mismatch",
48
+ shortDescription: { text: "TypeScript semantic mismatch against z.input/z.output" },
49
+ },
43
50
  ];
44
51
  const results = payload.result.pairs.flatMap((pair) => pair.issues.map((issue) => {
45
52
  const location = issue.schemaLocation ?? issue.typeLocation;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zodrift",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "ESLint-style CI drift checker for TypeScript types and Zod schemas.",
5
5
  "author": "greyllmmoder",
6
6
  "homepage": "https://github.com/greyllmmoder/zodrift#readme",