zod-openapi-contract-lint-kit 0.1.1__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: zod-openapi-contract-lint-kit
3
+ Version: 0.1.1
4
+ Summary: Local read-only scanner for Zod/OpenAPI contract drift risks.
5
+ Author: Zod OpenAPI Contract Lint Kit contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/vasiliy0/zod-openapi-contract-lint-kit
8
+ Project-URL: Repository, https://github.com/vasiliy0/zod-openapi-contract-lint-kit
9
+ Project-URL: Issues, https://github.com/vasiliy0/zod-openapi-contract-lint-kit/issues
10
+ Keywords: zod,openapi,typescript,api,lint
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # Zod OpenAPI Contract Lint Kit
26
+
27
+ Local read-only prototype for finding common drift risks between Zod schemas, OpenAPI metadata, route responses, and generated API clients.
28
+
29
+ ## Why this exists
30
+
31
+ Teams often start with Zod as runtime validation and later generate OpenAPI specs/clients. Drift appears when schemas use constructs generators cannot represent cleanly, when route metadata is missing, or when auth/error responses are inconsistent.
32
+
33
+ This prototype scans TypeScript files and reports review items. It does not execute application code, import project modules, call network APIs, or modify files.
34
+
35
+ ## Current v1 checks
36
+
37
+ - Zod object schemas exported without nearby OpenAPI metadata (`.openapi(...)` or `.describe(...)`).
38
+ - `z.any()` / `z.unknown()` in public contracts.
39
+ - `z.date()` fields that may serialize differently in OpenAPI/client transports.
40
+ - `z.record(...)` map types that may need `additionalProperties` review.
41
+ - `z.union(...)` without a nearby discriminator hint.
42
+ - `z.transform(...)`, `.refine(...)`, `.superRefine(...)` in public schemas.
43
+ - Route handlers with auth hints but no visible `401` / `403` response docs nearby.
44
+ - Error responses without an obvious schema/envelope reference.
45
+
46
+ ## Try locally
47
+
48
+ ```bash
49
+ python3 scanner.py examples
50
+ python3 scanner.py examples --format json
51
+ python3 scanner.py examples --output report.md
52
+ python3 scanner.py examples --fail-on-severity high
53
+ python3 scanner.py examples --min-severity high
54
+ python3 scanner.py examples --only-rule zod-date-serialization-review
55
+ python3 scanner.py --list-rules
56
+ python3 scanner.py --list-rules --format json
57
+ python3 scanner.py examples --ignore-rule runtime-only-zod-effect
58
+ ```
59
+
60
+ ## CI usage
61
+
62
+ See [`docs/CI_USAGE.md`](docs/CI_USAGE.md) for report-only, high-risk-only, and scoped rollout examples.
63
+
64
+ ## Safety
65
+
66
+ - Read-only local scan.
67
+ - No GitHub/npm/OpenAPI service account required.
68
+ - No dependency installation required for the prototype.
69
+ - Findings are review prompts, not automatic migration claims.
70
+ - CI failure is opt-in via `--fail-on-severity`.
71
+ - Rule filtering is explicit and local; unknown rule ids fail fast instead of silently changing coverage.
72
+ - `--min-severity` supports high-risk-only reports for CI summaries.
73
+ - `--list-rules` can be used to review active rule coverage before adding the scanner to CI.
74
+
75
+ ## Roadmap
76
+
77
+ - Expand fixtures across common Zod/OpenAPI routing patterns.
78
+ - Add fixtures for hono/zod-openapi/express-zod-api route variants.
79
+ - Package as a small developer CLI after the prototype stabilizes.
80
+ - Add more routing-library examples and release notes.
@@ -0,0 +1,56 @@
1
+ # Zod OpenAPI Contract Lint Kit
2
+
3
+ Local read-only prototype for finding common drift risks between Zod schemas, OpenAPI metadata, route responses, and generated API clients.
4
+
5
+ ## Why this exists
6
+
7
+ Teams often start with Zod as runtime validation and later generate OpenAPI specs/clients. Drift appears when schemas use constructs generators cannot represent cleanly, when route metadata is missing, or when auth/error responses are inconsistent.
8
+
9
+ This prototype scans TypeScript files and reports review items. It does not execute application code, import project modules, call network APIs, or modify files.
10
+
11
+ ## Current v1 checks
12
+
13
+ - Zod object schemas exported without nearby OpenAPI metadata (`.openapi(...)` or `.describe(...)`).
14
+ - `z.any()` / `z.unknown()` in public contracts.
15
+ - `z.date()` fields that may serialize differently in OpenAPI/client transports.
16
+ - `z.record(...)` map types that may need `additionalProperties` review.
17
+ - `z.union(...)` without a nearby discriminator hint.
18
+ - `z.transform(...)`, `.refine(...)`, `.superRefine(...)` in public schemas.
19
+ - Route handlers with auth hints but no visible `401` / `403` response docs nearby.
20
+ - Error responses without an obvious schema/envelope reference.
21
+
22
+ ## Try locally
23
+
24
+ ```bash
25
+ python3 scanner.py examples
26
+ python3 scanner.py examples --format json
27
+ python3 scanner.py examples --output report.md
28
+ python3 scanner.py examples --fail-on-severity high
29
+ python3 scanner.py examples --min-severity high
30
+ python3 scanner.py examples --only-rule zod-date-serialization-review
31
+ python3 scanner.py --list-rules
32
+ python3 scanner.py --list-rules --format json
33
+ python3 scanner.py examples --ignore-rule runtime-only-zod-effect
34
+ ```
35
+
36
+ ## CI usage
37
+
38
+ See [`docs/CI_USAGE.md`](docs/CI_USAGE.md) for report-only, high-risk-only, and scoped rollout examples.
39
+
40
+ ## Safety
41
+
42
+ - Read-only local scan.
43
+ - No GitHub/npm/OpenAPI service account required.
44
+ - No dependency installation required for the prototype.
45
+ - Findings are review prompts, not automatic migration claims.
46
+ - CI failure is opt-in via `--fail-on-severity`.
47
+ - Rule filtering is explicit and local; unknown rule ids fail fast instead of silently changing coverage.
48
+ - `--min-severity` supports high-risk-only reports for CI summaries.
49
+ - `--list-rules` can be used to review active rule coverage before adding the scanner to CI.
50
+
51
+ ## Roadmap
52
+
53
+ - Expand fixtures across common Zod/OpenAPI routing patterns.
54
+ - Add fixtures for hono/zod-openapi/express-zod-api route variants.
55
+ - Package as a small developer CLI after the prototype stabilizes.
56
+ - Add more routing-library examples and release notes.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zod-openapi-contract-lint-kit"
7
+ version = "0.1.1"
8
+ description = "Local read-only scanner for Zod/OpenAPI contract drift risks."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Zod OpenAPI Contract Lint Kit contributors" }]
13
+ keywords = ["zod", "openapi", "typescript", "api", "lint"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Code Generators",
23
+ "Topic :: Software Development :: Quality Assurance"
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/vasiliy0/zod-openapi-contract-lint-kit"
28
+ Repository = "https://github.com/vasiliy0/zod-openapi-contract-lint-kit"
29
+ Issues = "https://github.com/vasiliy0/zod-openapi-contract-lint-kit/issues"
30
+
31
+ [project.scripts]
32
+ zod-openapi-contract-lint-kit = "zod_openapi_contract_lint_kit.cli:main"
33
+ zod-openapi-lint = "zod_openapi_contract_lint_kit.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ zod_openapi_contract_lint_kit = ["rules.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Zod OpenAPI contract lint scanner."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only static scanner for Zod/OpenAPI contract drift risks."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Iterable
11
+
12
+ DEFAULT_RULES = Path(__file__).with_name("rules.json")
13
+ SUPPORTED_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"}
14
+ SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3}
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Rule:
19
+ id: str
20
+ severity: str
21
+ pattern: str
22
+ why: str
23
+ fix: str
24
+ suppress_if_nearby: tuple[str, ...] = ()
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Finding:
29
+ file: str
30
+ line: int
31
+ rule_id: str
32
+ severity: str
33
+ signal: str
34
+ why: str
35
+ fix: str
36
+
37
+
38
+ def load_rules(path: Path = DEFAULT_RULES) -> list[Rule]:
39
+ data = json.loads(path.read_text(encoding="utf-8"))
40
+ rules = []
41
+ for item in data["rules"]:
42
+ rules.append(Rule(
43
+ id=item["id"],
44
+ severity=item["severity"],
45
+ pattern=item["pattern"],
46
+ why=item["why"],
47
+ fix=item["fix"],
48
+ suppress_if_nearby=tuple(item.get("suppress_if_nearby", [])),
49
+ ))
50
+ return rules
51
+
52
+
53
+ def filter_rules(rules: Iterable[Rule], only_rule: set[str] | None = None, ignore_rule: set[str] | None = None) -> list[Rule]:
54
+ only_rule = only_rule or set()
55
+ ignore_rule = ignore_rule or set()
56
+ known_ids = {rule.id for rule in rules}
57
+ unknown = (only_rule | ignore_rule) - known_ids
58
+ if unknown:
59
+ raise ValueError("Unknown rule id(s): " + ", ".join(sorted(unknown)))
60
+ filtered = []
61
+ for rule in rules:
62
+ if only_rule and rule.id not in only_rule:
63
+ continue
64
+ if rule.id in ignore_rule:
65
+ continue
66
+ filtered.append(rule)
67
+ return filtered
68
+
69
+
70
+ def discover(root: Path) -> list[Path]:
71
+ if root.is_file():
72
+ return [root] if root.suffix.lower() in SUPPORTED_SUFFIXES else []
73
+ ignored = {"node_modules", "dist", "build", ".git", ".next", "coverage"}
74
+ files = []
75
+ for path in root.rglob("*"):
76
+ if not path.is_file() or path.suffix.lower() not in SUPPORTED_SUFFIXES:
77
+ continue
78
+ if any(part in ignored for part in path.parts):
79
+ continue
80
+ files.append(path)
81
+ return sorted(files)
82
+
83
+
84
+ def nearby_text(lines: list[str], index: int, radius: int = 4) -> str:
85
+ start = max(0, index - radius)
86
+ end = min(len(lines), index + radius + 1)
87
+ return "\n".join(lines[start:end])
88
+
89
+
90
+ def is_suppressed(rule: Rule, lines: list[str], index: int) -> bool:
91
+ if not rule.suppress_if_nearby:
92
+ return False
93
+ context = nearby_text(lines, index)
94
+ return any(token in context for token in rule.suppress_if_nearby)
95
+
96
+
97
+ def display_path(path: Path, root: Path) -> str:
98
+ try:
99
+ return str(path.relative_to(root))
100
+ except ValueError:
101
+ return str(path)
102
+
103
+
104
+ def scan_file(path: Path, root: Path, rules: list[Rule]) -> list[Finding]:
105
+ text = path.read_text(encoding="utf-8", errors="replace")
106
+ lines = text.splitlines()
107
+ findings: list[Finding] = []
108
+ for i, line in enumerate(lines):
109
+ for rule in rules:
110
+ if re.search(rule.pattern, line, flags=re.I):
111
+ if is_suppressed(rule, lines, i):
112
+ continue
113
+ findings.append(Finding(
114
+ file=display_path(path, root),
115
+ line=i + 1,
116
+ rule_id=rule.id,
117
+ severity=rule.severity,
118
+ signal=line.strip(),
119
+ why=rule.why,
120
+ fix=rule.fix,
121
+ ))
122
+ return findings
123
+
124
+
125
+ def scan(root: Path, rules_path: Path = DEFAULT_RULES, only_rule: set[str] | None = None, ignore_rule: set[str] | None = None) -> dict[str, Any]:
126
+ root = root.resolve()
127
+ base = root if root.is_dir() else root.parent
128
+ rules = filter_rules(load_rules(rules_path), only_rule=only_rule, ignore_rule=ignore_rule)
129
+ files = discover(root)
130
+ findings: list[Finding] = []
131
+ for file in files:
132
+ findings.extend(scan_file(file, base, rules))
133
+ return {
134
+ "scanned_files": len(files),
135
+ "active_rule_count": len(rules),
136
+ "finding_count": len(findings),
137
+ "findings": [asdict(f) for f in findings],
138
+ "summary_by_severity": severity_summary(findings),
139
+ "summary_by_rule": rule_summary(findings),
140
+ "notes": [
141
+ "Read-only static scan; no TypeScript execution, imports, network calls, or file changes.",
142
+ "Findings are contract-review prompts and may need project-specific suppression rules.",
143
+ ],
144
+ }
145
+
146
+
147
+ def severity_summary(findings: list[Finding]) -> dict[str, int]:
148
+ summary: dict[str, int] = {}
149
+ for finding in findings:
150
+ summary[finding.severity] = summary.get(finding.severity, 0) + 1
151
+ return summary
152
+
153
+
154
+ def rule_summary(findings: list[Finding]) -> dict[str, int]:
155
+ summary: dict[str, int] = {}
156
+ for finding in findings:
157
+ summary[finding.rule_id] = summary.get(finding.rule_id, 0) + 1
158
+ return summary
159
+
160
+
161
+ def render_markdown(report: dict[str, Any]) -> str:
162
+ lines = ["# Zod OpenAPI Contract Lint Report", ""]
163
+ lines.append(f"Scanned files: {report['scanned_files']}")
164
+ lines.append(f"Active rules: {report.get('active_rule_count', 'n/a')}")
165
+ lines.append(f"Findings: {report['finding_count']}")
166
+ if report["summary_by_severity"]:
167
+ lines.append("")
168
+ lines.append("## Summary by severity")
169
+ for severity, count in report["summary_by_severity"].items():
170
+ lines.append(f"- **{severity}**: {count}")
171
+ if report.get("summary_by_rule"):
172
+ lines.append("")
173
+ lines.append("## Summary by rule")
174
+ for rule_id, count in report["summary_by_rule"].items():
175
+ lines.append(f"- `{rule_id}`: {count}")
176
+ if report["findings"]:
177
+ lines.append("")
178
+ lines.append("## Findings")
179
+ for finding in report["findings"]:
180
+ lines.append(f"- **{finding['severity']}** `{finding['rule_id']}` in `{finding['file']}:{finding['line']}`")
181
+ lines.append(f" - Signal: `{finding['signal']}`")
182
+ lines.append(f" - Why: {finding['why']}")
183
+ lines.append(f" - Fix: {finding['fix']}")
184
+ else:
185
+ lines.append("")
186
+ lines.append("No known contract drift signals found by the current rule set.")
187
+ lines.append("")
188
+ lines.append("## Notes")
189
+ for note in report["notes"]:
190
+ lines.append(f"- {note}")
191
+ return "\n".join(lines) + "\n"
192
+
193
+
194
+ def should_fail(report: dict[str, Any], threshold: str) -> bool:
195
+ minimum = SEVERITY_ORDER[threshold]
196
+ return any(SEVERITY_ORDER.get(item["severity"], 0) >= minimum for item in report["findings"])
197
+
198
+
199
+ def filter_findings_by_severity(findings: list[Finding], minimum_severity: str | None = None) -> list[Finding]:
200
+ if not minimum_severity:
201
+ return findings
202
+ minimum = SEVERITY_ORDER[minimum_severity]
203
+ return [finding for finding in findings if SEVERITY_ORDER.get(finding.severity, 0) >= minimum]
204
+
205
+
206
+ def apply_report_filters(report: dict[str, Any], minimum_severity: str | None = None) -> dict[str, Any]:
207
+ if not minimum_severity:
208
+ return report
209
+ filtered = filter_findings_by_severity([Finding(**item) for item in report["findings"]], minimum_severity)
210
+ notes = list(report["notes"])
211
+ notes.append(f"Filtered findings below {minimum_severity} severity.")
212
+ return {
213
+ **report,
214
+ "finding_count": len(filtered),
215
+ "findings": [asdict(finding) for finding in filtered],
216
+ "summary_by_severity": severity_summary(filtered),
217
+ "summary_by_rule": rule_summary(filtered),
218
+ "notes": notes,
219
+ }
220
+
221
+
222
+ def render_rule_inventory(rules: list[Rule], output_format: str = "markdown") -> str:
223
+ if output_format == "json":
224
+ return json.dumps({"rules": [asdict(rule) for rule in rules]}, indent=2) + "\n"
225
+ lines = ["# Zod OpenAPI Contract Lint rule inventory", ""]
226
+ for rule in rules:
227
+ lines.append(f"## `{rule.id}`")
228
+ lines.append("")
229
+ lines.append(f"- Severity: **{rule.severity}**")
230
+ lines.append(f"- Pattern: `{rule.pattern}`")
231
+ if rule.suppress_if_nearby:
232
+ lines.append("- Suppressed near: " + ", ".join(f"`{token}`" for token in rule.suppress_if_nearby))
233
+ lines.append(f"- Why: {rule.why}")
234
+ lines.append(f"- Fix: {rule.fix}")
235
+ lines.append("")
236
+ return "\n".join(lines)
237
+
238
+
239
+ def main(argv: list[str] | None = None) -> int:
240
+ parser = argparse.ArgumentParser(description="Scan Zod/OpenAPI contract drift risks.")
241
+ parser.add_argument("path", nargs="?", default=".", help="Project root or TypeScript file to scan")
242
+ parser.add_argument("--format", choices=["markdown", "json"], default="markdown")
243
+ parser.add_argument("--output", "-o", help="Write report to a file instead of stdout")
244
+ parser.add_argument("--fail-on-severity", choices=["low", "medium", "high"], help="Exit 1 when findings at or above this severity are detected")
245
+ parser.add_argument("--min-severity", choices=["low", "medium", "high"], help="Only include findings at or above this severity in the report")
246
+ parser.add_argument("--only-rule", action="append", default=[], help="Run only this rule id; repeat for multiple rules")
247
+ parser.add_argument("--ignore-rule", action="append", default=[], help="Skip this rule id; repeat for multiple rules")
248
+ parser.add_argument("--list-rules", action="store_true", help="Print the active rule inventory and exit")
249
+ parser.add_argument("--rules", type=Path, default=DEFAULT_RULES)
250
+ args = parser.parse_args(argv)
251
+ try:
252
+ active_rules = filter_rules(load_rules(args.rules), only_rule=set(args.only_rule), ignore_rule=set(args.ignore_rule))
253
+ if args.list_rules:
254
+ print(render_rule_inventory(active_rules, args.format), end="")
255
+ return 0
256
+ report = scan(Path(args.path), args.rules, only_rule=set(args.only_rule), ignore_rule=set(args.ignore_rule))
257
+ except ValueError as exc:
258
+ parser.error(str(exc))
259
+ report = apply_report_filters(report, args.min_severity)
260
+ output = json.dumps(report, indent=2) if args.format == "json" else render_markdown(report)
261
+ if args.output:
262
+ Path(args.output).write_text(output + ("" if output.endswith("\n") else "\n"), encoding="utf-8")
263
+ else:
264
+ print(output, end="" if args.format == "markdown" else "\n")
265
+ if args.fail_on_severity and should_fail(report, args.fail_on_severity):
266
+ return 1
267
+ return 0
268
+
269
+
270
+ if __name__ == "__main__":
271
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "id": "missing-openapi-metadata",
5
+ "severity": "high",
6
+ "pattern": "export\\s+const\\s+\\w+Schema\\s*=\\s*z\\.object\\(",
7
+ "why": "Exported Zod object schemas used as contracts often need OpenAPI metadata for stable docs and generated clients.",
8
+ "fix": "Add .openapi(...) metadata or .describe(...) fields, especially for public request/response schemas.",
9
+ "suppress_if_nearby": [".openapi(", ".describe("]
10
+ },
11
+ {
12
+ "id": "zod-any-unknown-contract",
13
+ "severity": "high",
14
+ "pattern": "z\\.(any|unknown)\\s*\\(",
15
+ "why": "z.any()/z.unknown() weakens generated OpenAPI schemas and client types.",
16
+ "fix": "Replace with explicit object/union/primitive schemas or document a deliberate escape hatch."
17
+ },
18
+ {
19
+ "id": "zod-date-serialization-review",
20
+ "severity": "medium",
21
+ "pattern": "z\\.date\\s*\\(",
22
+ "why": "z.date() is a runtime Date object while OpenAPI transports usually serialize dates as strings.",
23
+ "fix": "Use z.string().datetime()/date() for transport contracts, or document how the OpenAPI generator serializes Date values."
24
+ },
25
+ {
26
+ "id": "record-additional-properties-review",
27
+ "severity": "medium",
28
+ "pattern": "z\\.record\\s*\\(",
29
+ "why": "Map-like records can generate broad additionalProperties schemas that clients may handle inconsistently.",
30
+ "fix": "Review generated additionalProperties output and add examples/constraints where possible."
31
+ },
32
+ {
33
+ "id": "union-discriminator-review",
34
+ "severity": "medium",
35
+ "pattern": "z\\.union\\s*\\(",
36
+ "why": "Plain unions can produce ambiguous anyOf/oneOf output for generated clients.",
37
+ "fix": "Prefer discriminatedUnion when possible or add clear OpenAPI discriminator/description metadata.",
38
+ "suppress_if_nearby": ["discriminatedUnion", "discriminator"]
39
+ },
40
+ {
41
+ "id": "runtime-only-zod-effect",
42
+ "severity": "medium",
43
+ "pattern": "\\.(transform|refine|superRefine)\\s*\\(",
44
+ "why": "Runtime-only Zod effects may not be faithfully represented in OpenAPI output.",
45
+ "fix": "Keep transport contracts structural, and document runtime-only validation separately."
46
+ },
47
+ {
48
+ "id": "auth-route-missing-401-403-docs",
49
+ "severity": "high",
50
+ "pattern": "(requireAuth|withAuth|authMiddleware|bearerAuth|security)",
51
+ "why": "Authenticated routes should document 401/403 responses so clients can handle auth failures consistently.",
52
+ "fix": "Add explicit 401 and/or 403 response docs with a shared error schema.",
53
+ "suppress_if_nearby": ["401", "403"]
54
+ },
55
+ {
56
+ "id": "error-response-without-schema",
57
+ "severity": "medium",
58
+ "pattern": "status\\s*[:=]\\s*(400|401|403|404|409|422|500)|\\b(400|401|403|404|409|422|500)\\s*:",
59
+ "why": "Error responses without a documented schema cause inconsistent client error handling.",
60
+ "fix": "Reference a shared ErrorResponse schema/envelope in OpenAPI responses.",
61
+ "suppress_if_nearby": ["ErrorSchema", "ErrorResponse", "errorSchema"]
62
+ }
63
+ ]
64
+ }
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: zod-openapi-contract-lint-kit
3
+ Version: 0.1.1
4
+ Summary: Local read-only scanner for Zod/OpenAPI contract drift risks.
5
+ Author: Zod OpenAPI Contract Lint Kit contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/vasiliy0/zod-openapi-contract-lint-kit
8
+ Project-URL: Repository, https://github.com/vasiliy0/zod-openapi-contract-lint-kit
9
+ Project-URL: Issues, https://github.com/vasiliy0/zod-openapi-contract-lint-kit/issues
10
+ Keywords: zod,openapi,typescript,api,lint
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # Zod OpenAPI Contract Lint Kit
26
+
27
+ Local read-only prototype for finding common drift risks between Zod schemas, OpenAPI metadata, route responses, and generated API clients.
28
+
29
+ ## Why this exists
30
+
31
+ Teams often start with Zod as runtime validation and later generate OpenAPI specs/clients. Drift appears when schemas use constructs generators cannot represent cleanly, when route metadata is missing, or when auth/error responses are inconsistent.
32
+
33
+ This prototype scans TypeScript files and reports review items. It does not execute application code, import project modules, call network APIs, or modify files.
34
+
35
+ ## Current v1 checks
36
+
37
+ - Zod object schemas exported without nearby OpenAPI metadata (`.openapi(...)` or `.describe(...)`).
38
+ - `z.any()` / `z.unknown()` in public contracts.
39
+ - `z.date()` fields that may serialize differently in OpenAPI/client transports.
40
+ - `z.record(...)` map types that may need `additionalProperties` review.
41
+ - `z.union(...)` without a nearby discriminator hint.
42
+ - `z.transform(...)`, `.refine(...)`, `.superRefine(...)` in public schemas.
43
+ - Route handlers with auth hints but no visible `401` / `403` response docs nearby.
44
+ - Error responses without an obvious schema/envelope reference.
45
+
46
+ ## Try locally
47
+
48
+ ```bash
49
+ python3 scanner.py examples
50
+ python3 scanner.py examples --format json
51
+ python3 scanner.py examples --output report.md
52
+ python3 scanner.py examples --fail-on-severity high
53
+ python3 scanner.py examples --min-severity high
54
+ python3 scanner.py examples --only-rule zod-date-serialization-review
55
+ python3 scanner.py --list-rules
56
+ python3 scanner.py --list-rules --format json
57
+ python3 scanner.py examples --ignore-rule runtime-only-zod-effect
58
+ ```
59
+
60
+ ## CI usage
61
+
62
+ See [`docs/CI_USAGE.md`](docs/CI_USAGE.md) for report-only, high-risk-only, and scoped rollout examples.
63
+
64
+ ## Safety
65
+
66
+ - Read-only local scan.
67
+ - No GitHub/npm/OpenAPI service account required.
68
+ - No dependency installation required for the prototype.
69
+ - Findings are review prompts, not automatic migration claims.
70
+ - CI failure is opt-in via `--fail-on-severity`.
71
+ - Rule filtering is explicit and local; unknown rule ids fail fast instead of silently changing coverage.
72
+ - `--min-severity` supports high-risk-only reports for CI summaries.
73
+ - `--list-rules` can be used to review active rule coverage before adding the scanner to CI.
74
+
75
+ ## Roadmap
76
+
77
+ - Expand fixtures across common Zod/OpenAPI routing patterns.
78
+ - Add fixtures for hono/zod-openapi/express-zod-api route variants.
79
+ - Package as a small developer CLI after the prototype stabilizes.
80
+ - Add more routing-library examples and release notes.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/zod_openapi_contract_lint_kit/__init__.py
5
+ src/zod_openapi_contract_lint_kit/cli.py
6
+ src/zod_openapi_contract_lint_kit/rules.json
7
+ src/zod_openapi_contract_lint_kit.egg-info/PKG-INFO
8
+ src/zod_openapi_contract_lint_kit.egg-info/SOURCES.txt
9
+ src/zod_openapi_contract_lint_kit.egg-info/dependency_links.txt
10
+ src/zod_openapi_contract_lint_kit.egg-info/entry_points.txt
11
+ src/zod_openapi_contract_lint_kit.egg-info/top_level.txt
12
+ tests/test_scanner.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ zod-openapi-contract-lint-kit = zod_openapi_contract_lint_kit.cli:main
3
+ zod-openapi-lint = zod_openapi_contract_lint_kit.cli:main
@@ -0,0 +1,99 @@
1
+ from pathlib import Path
2
+ import importlib.util
3
+ import json
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ spec = importlib.util.spec_from_file_location("scanner", ROOT / "scanner.py")
11
+ scanner = importlib.util.module_from_spec(spec)
12
+ sys.modules["scanner"] = scanner
13
+ spec.loader.exec_module(scanner)
14
+
15
+
16
+ class TestScanner(unittest.TestCase):
17
+ def test_example_flags_contract_drift_risks(self):
18
+ report = scanner.scan(ROOT / "examples")
19
+ ids = {finding["rule_id"] for finding in report["findings"]}
20
+ self.assertIn("missing-openapi-metadata", ids)
21
+ self.assertIn("zod-any-unknown-contract", ids)
22
+ self.assertIn("zod-date-serialization-review", ids)
23
+ self.assertIn("record-additional-properties-review", ids)
24
+ self.assertIn("union-discriminator-review", ids)
25
+ self.assertIn("runtime-only-zod-effect", ids)
26
+ self.assertIn("auth-route-missing-401-403-docs", ids)
27
+ self.assertIn("error-response-without-schema", ids)
28
+ self.assertEqual(report["summary_by_rule"]["zod-date-serialization-review"], 1)
29
+
30
+ def test_nearby_openapi_metadata_suppresses_missing_metadata(self):
31
+ with tempfile.TemporaryDirectory() as tmpdir:
32
+ path = Path(tmpdir) / "schema.ts"
33
+ path.write_text('import { z } from "zod";\nexport const UserSchema = z.object({ id: z.string() }).openapi("User");\n', encoding="utf-8")
34
+ report = scanner.scan(path)
35
+ ids = {finding["rule_id"] for finding in report["findings"]}
36
+ self.assertNotIn("missing-openapi-metadata", ids)
37
+
38
+ def test_markdown_contains_safety_note_and_rule_summary(self):
39
+ text = scanner.render_markdown(scanner.scan(ROOT / "examples"))
40
+ self.assertIn("Zod OpenAPI Contract Lint Report", text)
41
+ self.assertIn("Read-only static scan", text)
42
+ self.assertIn("Summary by rule", text)
43
+ self.assertIn("Active rules", text)
44
+
45
+ def test_output_json_and_fail_on_severity(self):
46
+ with tempfile.TemporaryDirectory() as tmpdir:
47
+ output = Path(tmpdir) / "report.json"
48
+ exit_code = scanner.main([str(ROOT / "examples"), "--format", "json", "--output", str(output), "--fail-on-severity", "high"])
49
+ self.assertEqual(exit_code, 1)
50
+ report = json.loads(output.read_text())
51
+ self.assertGreaterEqual(report["summary_by_severity"]["high"], 1)
52
+ self.assertIn("summary_by_rule", report)
53
+
54
+ def test_fail_on_severity_stays_zero_above_medium(self):
55
+ with tempfile.TemporaryDirectory() as tmpdir:
56
+ path = Path(tmpdir) / "schema.ts"
57
+ path.write_text('import { z } from "zod";\nconst StartedAt = z.date();\n', encoding="utf-8")
58
+ self.assertEqual(scanner.main([str(path), "--fail-on-severity", "high"]), 0)
59
+ self.assertEqual(scanner.main([str(path), "--fail-on-severity", "medium"]), 1)
60
+
61
+ def test_only_rule_and_ignore_rule_filter_active_rules(self):
62
+ only_report = scanner.scan(ROOT / "examples", only_rule={"zod-date-serialization-review"})
63
+ self.assertEqual(only_report["active_rule_count"], 1)
64
+ self.assertEqual([f["rule_id"] for f in only_report["findings"]], ["zod-date-serialization-review"])
65
+
66
+ ignored_report = scanner.scan(ROOT / "examples", ignore_rule={"missing-openapi-metadata", "zod-any-unknown-contract", "auth-route-missing-401-403-docs"})
67
+ ids = {finding["rule_id"] for finding in ignored_report["findings"]}
68
+ self.assertNotIn("missing-openapi-metadata", ids)
69
+ self.assertNotIn("zod-any-unknown-contract", ids)
70
+ self.assertNotIn("auth-route-missing-401-403-docs", ids)
71
+ self.assertEqual(ignored_report["summary_by_severity"].get("high"), None)
72
+
73
+ def test_unknown_rule_is_cli_error(self):
74
+ with mock.patch("sys.stderr"):
75
+ with self.assertRaises(SystemExit):
76
+ scanner.main([str(ROOT / "examples"), "--only-rule", "missing-rule"])
77
+
78
+ def test_min_severity_filters_report_output(self):
79
+ with tempfile.TemporaryDirectory() as tmpdir:
80
+ output = Path(tmpdir) / "report.json"
81
+ exit_code = scanner.main([str(ROOT / "examples"), "--format", "json", "--output", str(output), "--min-severity", "high"])
82
+ self.assertEqual(exit_code, 0)
83
+ report = json.loads(output.read_text())
84
+ self.assertEqual(set(report["summary_by_severity"]), {"high"})
85
+ self.assertNotIn("zod-date-serialization-review", report["summary_by_rule"])
86
+ self.assertIn("Filtered findings below high severity.", report["notes"])
87
+
88
+ def test_list_rules_outputs_active_inventory(self):
89
+ rules = scanner.filter_rules(scanner.load_rules(), only_rule={"zod-date-serialization-review"})
90
+ text = scanner.render_rule_inventory(rules)
91
+ self.assertIn("zod-date-serialization-review", text)
92
+ self.assertIn("Severity", text)
93
+
94
+ data = json.loads(scanner.render_rule_inventory(rules, "json"))
95
+ self.assertEqual(data["rules"][0]["id"], "zod-date-serialization-review")
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()