@suluk/hono 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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@suluk/hono",
3
+ "version": "0.1.0",
4
+ "description": "The Suluk derivation engine: minimal Hono+Zod RouteContracts in; v4 doc (dynamic per principal+time), validation, contract tests, and doc-coverage audit out. CANDIDATE tooling.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "@suluk/core": "0.1.0",
12
+ "@suluk/zod": "0.1.0",
13
+ "ajv": "^8.20.0",
14
+ "ajv-formats": "^3.0.1"
15
+ },
16
+ "peerDependencies": {
17
+ "zod": "^4.0.0",
18
+ "hono": "^4.0.0",
19
+ "@hono/zod-validator": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "latest",
23
+ "@suluk/openapi-compat": "0.1.0",
24
+ "zod": "^4.4.3",
25
+ "hono": "^4.0.0",
26
+ "@hono/zod-validator": "^0.8.0"
27
+ },
28
+ "scripts": {
29
+ "test": "bun test",
30
+ "typecheck": "tsc --noEmit -p ."
31
+ }
32
+ }
package/src/audit.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * audit — documentation-coverage detection over a v4 document. This is the *ceiling* side of the
3
+ * Conformance Grade: an under-documented route indicts the producer. Findings are advisory (never a gate),
4
+ * mirroring the honest-loss pattern of compat diagnostics / zod warnings. autofill() supplies sane defaults.
5
+ */
6
+ import type { OpenAPIv4Document, PathItem, Request } from "@suluk/core";
7
+ import { responseList } from "./contract";
8
+
9
+ export interface Finding {
10
+ /** "missing-doc" | "no-success-schema" | "response-no-description" | "no-examples" */
11
+ code: string;
12
+ severity: "warn" | "info";
13
+ path: string;
14
+ operation: string;
15
+ message: string;
16
+ }
17
+
18
+ function isSuccess(status: string): boolean {
19
+ return /^2/.test(status) || status === "2XX";
20
+ }
21
+
22
+ /** Walk every operation and report documentation gaps. */
23
+ export function audit(doc: OpenAPIv4Document): Finding[] {
24
+ const findings: Finding[] = [];
25
+ for (const [path, piRaw] of Object.entries(doc.paths ?? {})) {
26
+ const pi = piRaw as PathItem;
27
+ for (const [name, reqRaw] of Object.entries(pi.requests ?? {})) {
28
+ const req = reqRaw as Request;
29
+ const add = (code: string, severity: Finding["severity"], message: string) =>
30
+ findings.push({ code, severity, path, operation: name, message });
31
+
32
+ if (!req.summary && !req.description) add("missing-doc", "warn", "operation has neither summary nor description");
33
+
34
+ const responses = Object.entries(req.responses ?? {});
35
+ const hasSuccessSchema = responses.some(([status, r]) => isSuccess(String((r as { status?: unknown }).status ?? status)) && (r as { contentSchema?: unknown }).contentSchema);
36
+ if (!hasSuccessSchema) add("no-success-schema", "info", "no 2xx response declares a content schema");
37
+
38
+ for (const [status, r] of responses) {
39
+ if (!(r as { description?: string }).description) add("response-no-description", "info", `response ${status} has no description`);
40
+ }
41
+ }
42
+ }
43
+ return findings;
44
+ }
45
+
46
+ /** A coarse coverage score in [0,1]: 1 = fully documented (no findings), lower = more gaps. */
47
+ export function coverage(doc: OpenAPIv4Document): number {
48
+ let ops = 0;
49
+ for (const pi of Object.values(doc.paths ?? {})) ops += Object.keys((pi as PathItem).requests ?? {}).length;
50
+ if (ops === 0) return 1;
51
+ const warns = audit(doc).filter((f) => f.severity === "warn").length;
52
+ return Math.max(0, 1 - warns / ops);
53
+ }
54
+
55
+ /**
56
+ * Fill obvious documentation gaps in-place-safe (returns a new doc): synthesize a summary from the
57
+ * operation name + method/path, and a description for undescribed responses. Conservative — never
58
+ * overwrites authored text. This is the "automatically document under-documented routes" lever.
59
+ */
60
+ export function autofill(doc: OpenAPIv4Document): OpenAPIv4Document {
61
+ const out: OpenAPIv4Document = structuredClone(doc);
62
+ for (const [path, piRaw] of Object.entries(out.paths ?? {})) {
63
+ const pi = piRaw as PathItem;
64
+ for (const [name, reqRaw] of Object.entries(pi.requests ?? {})) {
65
+ const req = reqRaw as Request;
66
+ if (!req.summary && !req.description) req.summary = humanize(name, req.method, path);
67
+ for (const r of Object.values(req.responses ?? {})) {
68
+ const resp = r as { status: string | number; description?: string };
69
+ if (!resp.description) resp.description = defaultStatusText(String(resp.status));
70
+ }
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function humanize(name: string, method: string, path: string): string {
77
+ const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/_/g, " ");
78
+ return `${spaced.charAt(0).toUpperCase()}${spaced.slice(1)} (${method.toUpperCase()} ${path})`;
79
+ }
80
+
81
+ function defaultStatusText(status: string): string {
82
+ const map: Record<string, string> = {
83
+ "200": "OK", "201": "Created", "204": "No Content", "400": "Bad Request",
84
+ "401": "Unauthorized", "403": "Forbidden", "404": "Not Found", "409": "Conflict",
85
+ "422": "Unprocessable Entity", "500": "Internal Server Error",
86
+ };
87
+ return map[status] ?? (/^5/.test(status) ? "Server Error" : /^4/.test(status) ? "Client Error" : "Response");
88
+ }
package/src/checks.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * contractChecks — auto-generated tests that act as checks for mistakes (the doc/contract as an executable
3
+ * verification). Each check is a pure function returning a verdict, so they run in CI, in the vscode
4
+ * extension, or as bun tests (toBunTest emits source). They catch the mistakes a contract can encode:
5
+ * - a request/response schema that isn't valid JSON Schema 2020-12
6
+ * - a provided example that does NOT satisfy its own schema
7
+ * - the emitted v4 document failing the v4 meta-schema
8
+ * - two routes whose ADA signatures provably collide (ambiguous routing)
9
+ */
10
+ import { validateDocument, buildAda } from "@suluk/core";
11
+ import { validateSchema2020 } from "./schema-check";
12
+ import { responseList, type RouteContract } from "./contract";
13
+ import { emitV4 } from "./emit";
14
+ import { zodToV4 } from "@suluk/zod";
15
+ import type * as z from "zod";
16
+
17
+ export interface Check {
18
+ name: string;
19
+ run(): { pass: boolean; message?: string };
20
+ }
21
+
22
+ function schemaValid(label: string, schema: z.ZodType): Check {
23
+ return {
24
+ name: `schema valid 2020-12: ${label}`,
25
+ run() {
26
+ const { schema: js } = zodToV4(schema as Parameters<typeof zodToV4>[0]);
27
+ const r = validateSchema2020(js);
28
+ return { pass: r.valid, message: r.valid ? undefined : r.errors.map((e) => `${e.path} ${e.message}`).join("; ") };
29
+ },
30
+ };
31
+ }
32
+
33
+ function exampleConforms(label: string, schema: z.ZodType, example: unknown, shouldPass = true): Check {
34
+ return {
35
+ name: `example ${shouldPass ? "⊨" : "⊭"} schema: ${label}`,
36
+ run() {
37
+ const ok = (schema as { safeParse(v: unknown): { success: boolean } }).safeParse(example).success;
38
+ return { pass: ok === shouldPass, message: ok === shouldPass ? undefined : `example ${ok ? "unexpectedly passed" : "failed"} its schema` };
39
+ },
40
+ };
41
+ }
42
+
43
+ /** Build the full check suite for a set of route contracts. */
44
+ export function contractChecks(routes: readonly RouteContract[]): Check[] {
45
+ const checks: Check[] = [];
46
+
47
+ for (const route of routes) {
48
+ const id = `${route.method.toUpperCase()} ${route.path}`;
49
+ const req = route.request;
50
+ for (const [slot, schema] of [["json", req?.json], ["query", req?.query], ["params", req?.params], ["header", req?.header]] as const) {
51
+ if (schema) checks.push(schemaValid(`${id} ${slot}`, schema));
52
+ }
53
+ for (const ex of req?.examples ?? []) if (req?.json) checks.push(exampleConforms(`${id} request example`, req.json, ex));
54
+ for (const r of responseList(route.responses)) {
55
+ if (r.schema) {
56
+ checks.push(schemaValid(`${id} ${r.status}`, r.schema));
57
+ for (const ex of r.examples ?? []) checks.push(exampleConforms(`${id} ${r.status} example`, r.schema, ex));
58
+ }
59
+ }
60
+ }
61
+
62
+ // whole-document checks
63
+ checks.push({
64
+ name: "emitted v4 document validates against the meta-schema",
65
+ run() {
66
+ const { document } = emitV4(routes);
67
+ const r = validateDocument(document);
68
+ return { pass: r.valid, message: r.valid ? undefined : r.errors.slice(0, 3).map((e) => `${e.path} ${e.message}`).join("; ") };
69
+ },
70
+ });
71
+ checks.push({
72
+ name: "no two routes provably collide (unambiguous routing)",
73
+ run() {
74
+ const { document } = emitV4(routes);
75
+ const bad = buildAda(document).collisions.filter((c) => c.verdict === "provable-collision");
76
+ return { pass: bad.length === 0, message: bad.length ? bad.map((c) => `${c.a.name} vs ${c.b.name}`).join("; ") : undefined };
77
+ },
78
+ });
79
+ return checks;
80
+ }
81
+
82
+ export interface CheckRun {
83
+ total: number;
84
+ passed: number;
85
+ failures: { name: string; message?: string }[];
86
+ }
87
+
88
+ /** Run every check and summarize. */
89
+ export function runContractChecks(routes: readonly RouteContract[]): CheckRun {
90
+ const checks = contractChecks(routes);
91
+ const failures: CheckRun["failures"] = [];
92
+ for (const c of checks) {
93
+ const v = c.run();
94
+ if (!v.pass) failures.push({ name: c.name, message: v.message });
95
+ }
96
+ return { total: checks.length, passed: checks.length - failures.length, failures };
97
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The RouteContract — the single source of truth a user authors (minimally), from which every Derivation
3
+ * (the v4 doc, Scalar/Swagger, validation, tests, audit) is projected. It is plain data + Zod schemas, so
4
+ * the pure derivations (emitV4 / audit / contractChecks) need no running server — only mount() touches Hono.
5
+ */
6
+ import type * as z from "zod";
7
+ import type { SecurityRequirement } from "@suluk/core";
8
+
9
+ export type Method = "get" | "post" | "put" | "patch" | "delete" | "head" | "options";
10
+
11
+ export interface RouteRequest {
12
+ /** Path params (Hono `:name`), as a Zod object. */
13
+ params?: z.ZodType;
14
+ /** Query string, as a Zod object. */
15
+ query?: z.ZodType;
16
+ /** Request headers that participate in the contract, as a Zod object. */
17
+ header?: z.ZodType;
18
+ /** Request body (defaults to application/json). */
19
+ json?: z.ZodType;
20
+ /** Override the body media type. */
21
+ contentType?: string;
22
+ /** Optional concrete example bodies — used by contractChecks to assert example⊨schema. */
23
+ examples?: unknown[];
24
+ }
25
+
26
+ export interface RouteResponse {
27
+ status: number;
28
+ description?: string;
29
+ schema?: z.ZodType;
30
+ /** Defaults to application/json when a schema is present. */
31
+ contentType?: string;
32
+ /** Optional concrete example responses — used by contractChecks. */
33
+ examples?: unknown[];
34
+ }
35
+
36
+ export interface RouteContract {
37
+ method: Method;
38
+ /** Hono-style path, e.g. "/pet/:petId" or "/files/*". Converted to a v4 uriTemplate on emit. */
39
+ path: string;
40
+ /** The operation's v4 by-name handle (C009). Derived from method+path if omitted. */
41
+ name?: string;
42
+ summary?: string;
43
+ description?: string;
44
+ tags?: string[];
45
+ deprecated?: boolean;
46
+ /** ISO date; with EmitContext.now, the operation is marked deprecated once now ≥ this. */
47
+ deprecatedSince?: string;
48
+ /** ISO date; with EmitContext.now, the operation is HIDDEN once now ≥ this (the "when" axis). */
49
+ removedSince?: string;
50
+ /** Explicit by-name security requirements (C014). */
51
+ security?: SecurityRequirement[];
52
+ /** Required scopes. Drives BOTH the per-principal filter (the "who") and synthesized security. */
53
+ scopes?: string[];
54
+ request?: RouteRequest;
55
+ /** Responses, as a list (each carries its own status) or a status-keyed map. */
56
+ responses?: RouteResponse[] | Record<string, RouteResponse>;
57
+ /** Optional live handler, used only by mount(). */
58
+ handler?: (c: unknown) => unknown | Promise<unknown>;
59
+ }
60
+
61
+ /** Identity helper that preserves literal inference when authoring a contract array. */
62
+ export function contract<const T extends readonly RouteContract[]>(routes: T): T {
63
+ return routes;
64
+ }
65
+
66
+ /** Normalize responses (list or map) to a list. */
67
+ export function responseList(r: RouteContract["responses"]): RouteResponse[] {
68
+ if (!r) return [];
69
+ return Array.isArray(r) ? r : Object.values(r);
70
+ }
package/src/emit.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * emitV4 — the keystone Derivation: render(contracts, principal, now) -> v4 Document.
3
+ *
4
+ * NOT a static file: the document is a pure function of the contracts × the requesting principal (scopes,
5
+ * the "who") × time (now, the "when"). A public export is just emitV4(routes) with no principal/now.
6
+ */
7
+ import { buildAda } from "@suluk/core";
8
+ import type {
9
+ OpenAPIv4Document, PathItem, Request, Response, ParameterSchema, SecurityRequirement, Server, Info, SecurityScheme,
10
+ } from "@suluk/core";
11
+ import { zodToV4 } from "@suluk/zod";
12
+ import { responseList, type RouteContract, type Method } from "./contract";
13
+
14
+ export interface EmitContext {
15
+ info?: Partial<Info>;
16
+ servers?: Server[];
17
+ /** The "who": include only operations whose required scopes the principal holds. Omit ⇒ full public doc. */
18
+ principal?: { scopes?: string[] };
19
+ /** The "when": ISO date / Date. Drives deprecatedSince + removedSince. Omit ⇒ no time filtering. */
20
+ now?: string | Date;
21
+ /** Name of the security scheme that `scopes` map onto (e.g. "bearerAuth"). Enables scopes→security. */
22
+ securityScheme?: string;
23
+ /** Declared security schemes for components (C014). */
24
+ securitySchemes?: Record<string, SecurityScheme>;
25
+ /** Include operations flagged deprecated (default true; they are marked, not hidden). */
26
+ includeDeprecated?: boolean;
27
+ }
28
+
29
+ export interface EmitDiagnostic {
30
+ kind: "collision" | "filtered" | "note";
31
+ operation?: string;
32
+ message: string;
33
+ }
34
+
35
+ export interface EmitResult {
36
+ document: OpenAPIv4Document;
37
+ diagnostics: EmitDiagnostic[];
38
+ }
39
+
40
+ function pascal(s: string): string {
41
+ return s.replace(/(^|[-_])(\w)/g, (_, __, c: string) => c.toUpperCase());
42
+ }
43
+
44
+ /** Hono path "/pet/:petId" / "/files/*" → v4 uriTemplate "pet/{petId}" / "files/{+wildcard}" (no leading slash). */
45
+ function toUriTemplate(honoPath: string): { template: string; segments: string[] } {
46
+ const segs = honoPath.replace(/^\//, "").split("/").filter(Boolean).map((s) => {
47
+ if (s === "*") return "{+wildcard}";
48
+ if (s.startsWith(":")) return `{${s.slice(1).replace(/\{.*$/, "")}}`; // strip Hono regex constraints
49
+ return s;
50
+ });
51
+ return { template: segs.join("/"), segments: segs };
52
+ }
53
+
54
+ function deriveName(method: Method, segments: string[]): string {
55
+ const parts = segments.map((s) =>
56
+ s.startsWith("{+") ? "By" + pascal(s.slice(2, -1)) : s.startsWith("{") ? "By" + pascal(s.slice(1, -1)) : pascal(s),
57
+ );
58
+ return method + parts.join("");
59
+ }
60
+
61
+ function zParam(schema: unknown): Record<string, unknown> | undefined {
62
+ if (!schema) return undefined;
63
+ return zodToV4(schema as Parameters<typeof zodToV4>[0]).schema;
64
+ }
65
+
66
+ function toMs(d: string | Date): number {
67
+ return (typeof d === "string" ? new Date(d) : d).getTime();
68
+ }
69
+
70
+ /** Build one v4 Request from a route contract. */
71
+ function buildRequest(route: RouteContract, deprecated: boolean, ctx: EmitContext): Request {
72
+ const req: Request = { method: route.method, responses: {} };
73
+ if (route.summary) req.summary = route.summary;
74
+ if (route.description) req.description = route.description;
75
+ if (route.tags) req.tags = route.tags;
76
+ if (deprecated) req.deprecated = true;
77
+
78
+ if (route.request?.json) {
79
+ req.contentType = route.request.contentType ?? "application/json";
80
+ req.contentSchema = zodToV4(route.request.json as Parameters<typeof zodToV4>[0]).schema;
81
+ }
82
+
83
+ const ps: ParameterSchema = {};
84
+ const q = zParam(route.request?.query); if (q) ps.query = q;
85
+ const p = zParam(route.request?.params); if (p) ps.path = p;
86
+ const h = zParam(route.request?.header); if (h) ps.header = h;
87
+ if (Object.keys(ps).length) req.parameterSchema = ps;
88
+
89
+ const responses: Record<string, Response> = {};
90
+ for (const r of responseList(route.responses)) {
91
+ const resp: Response = { status: r.status };
92
+ if (r.description) resp.description = r.description;
93
+ if (r.schema) {
94
+ resp.contentType = r.contentType ?? "application/json";
95
+ resp.contentSchema = zodToV4(r.schema as Parameters<typeof zodToV4>[0]).schema;
96
+ }
97
+ responses[String(r.status)] = resp;
98
+ }
99
+ if (Object.keys(responses).length === 0) responses["200"] = { status: 200 };
100
+ req.responses = responses;
101
+
102
+ // security: explicit wins; else synthesize from scopes if a scheme name is configured.
103
+ const security: SecurityRequirement[] | undefined =
104
+ route.security ?? (route.scopes && ctx.securityScheme ? [{ [ctx.securityScheme]: route.scopes }] : undefined);
105
+ if (security) req.security = security;
106
+ return req;
107
+ }
108
+
109
+ /**
110
+ * Project a list of route contracts into a v4 document for a given principal + time.
111
+ * - WHEN: removedSince ≤ now ⇒ hidden; deprecatedSince ≤ now ⇒ marked deprecated.
112
+ * - WHO: if a principal is supplied, an operation requiring scopes the principal lacks is omitted.
113
+ */
114
+ export function emitV4(routes: readonly RouteContract[], ctx: EmitContext = {}): EmitResult {
115
+ const diagnostics: EmitDiagnostic[] = [];
116
+ const nowMs = ctx.now != null ? toMs(ctx.now) : undefined;
117
+ const principalScopes = ctx.principal ? new Set(ctx.principal.scopes ?? []) : undefined;
118
+
119
+ const paths: Record<string, PathItem> = {};
120
+ for (const route of routes) {
121
+ const { template, segments } = toUriTemplate(route.path);
122
+ const name = route.name ?? deriveName(route.method, segments);
123
+
124
+ // WHEN filter
125
+ if (nowMs != null && route.removedSince && toMs(route.removedSince) <= nowMs) {
126
+ diagnostics.push({ kind: "filtered", operation: name, message: `hidden: removed since ${route.removedSince}` });
127
+ continue;
128
+ }
129
+ // WHO filter
130
+ if (principalScopes && route.scopes && !route.scopes.every((s) => principalScopes.has(s))) {
131
+ diagnostics.push({ kind: "filtered", operation: name, message: `hidden: principal lacks scope(s) ${route.scopes.join(", ")}` });
132
+ continue;
133
+ }
134
+ const deprecated =
135
+ !!route.deprecated || (nowMs != null && !!route.deprecatedSince && toMs(route.deprecatedSince) <= nowMs);
136
+ if (deprecated && ctx.includeDeprecated === false) {
137
+ diagnostics.push({ kind: "filtered", operation: name, message: "hidden: deprecated (includeDeprecated=false)" });
138
+ continue;
139
+ }
140
+
141
+ const pi = (paths[template] ??= { requests: {} });
142
+ if (pi.requests[name]) {
143
+ diagnostics.push({ kind: "collision", operation: name, message: `duplicate operation name '${name}' at '${template}'` });
144
+ pi.requests[`${name}_${route.method}`] = buildRequest(route, deprecated, ctx);
145
+ } else {
146
+ pi.requests[name] = buildRequest(route, deprecated, ctx);
147
+ }
148
+ }
149
+
150
+ const document: OpenAPIv4Document = {
151
+ openapi: "4.0.0-candidate",
152
+ info: { title: ctx.info?.title ?? "API", version: ctx.info?.version ?? "0.0.0", ...ctx.info },
153
+ paths,
154
+ };
155
+ if (ctx.servers) document.servers = ctx.servers;
156
+ if (ctx.securitySchemes) document.components = { securitySchemes: ctx.securitySchemes };
157
+
158
+ // static collision audit over the ADA (detect-and-tolerate; surfaced as diagnostics, never a gate).
159
+ for (const c of buildAda(document).collisions) {
160
+ if (c.verdict === "provable-collision") {
161
+ diagnostics.push({ kind: "collision", operation: `${c.a.name} / ${c.b.name}`, message: `provable signature collision at '${c.a.pathTemplate}'` });
162
+ }
163
+ }
164
+ return { document, diagnostics };
165
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @suluk/hono — the derivation engine. The user authors minimal RouteContracts (Hono + Zod); everything
3
+ * else is derived: the v4 document (dynamic per principal + time), request validation, contract tests, and
4
+ * a documentation-coverage audit. See tooling/ARCHITECTURE.md. CANDIDATE tooling.
5
+ */
6
+ export { contract, responseList, type RouteContract, type RouteRequest, type RouteResponse, type Method } from "./contract";
7
+ export { emitV4, type EmitContext, type EmitResult, type EmitDiagnostic } from "./emit";
8
+ export { audit, coverage, autofill, type Finding } from "./audit";
9
+ export { contractChecks, runContractChecks, type Check, type CheckRun } from "./checks";
10
+ export { validateSchema2020, type SchemaCheck } from "./schema-check";
11
+ export { mount } from "./mount";
package/src/mount.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * mount — the thin Hono adapter. Wires the SAME RouteContract list that emitV4 reads onto a live Hono app,
3
+ * using @hono/zod-validator with the contract's own Zod schemas. Because the doc and the running app are
4
+ * projected from one source, they cannot drift. This is the only file that imports Hono.
5
+ */
6
+ import type { Hono } from "hono";
7
+ import { zValidator } from "@hono/zod-validator";
8
+ import type { RouteContract } from "./contract";
9
+
10
+ type Registrar = Record<string, (path: string, ...rest: unknown[]) => unknown>;
11
+
12
+ /** Mount each contract's handler (with request validation derived from its Zod schemas) onto `app`. */
13
+ export function mount<T extends Hono>(app: T, routes: readonly RouteContract[]): T {
14
+ const registrar = app as unknown as Registrar;
15
+ for (const route of routes) {
16
+ const middlewares: unknown[] = [];
17
+ const req = route.request;
18
+ if (req?.json) middlewares.push(zValidator("json", req.json as Parameters<typeof zValidator>[1]));
19
+ if (req?.query) middlewares.push(zValidator("query", req.query as Parameters<typeof zValidator>[1]));
20
+ if (req?.params) middlewares.push(zValidator("param", req.params as Parameters<typeof zValidator>[1]));
21
+ if (req?.header) middlewares.push(zValidator("header", req.header as Parameters<typeof zValidator>[1]));
22
+ const handler = route.handler ?? ((c: { json: (b: unknown, s?: number) => unknown }) => c.json({ message: "not implemented" }, 501));
23
+ registrar[route.method](route.path, ...middlewares, handler);
24
+ }
25
+ return app;
26
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Validate that a value is a well-formed JSON Schema 2020-12 (the dialect v4 Schema Objects must use).
3
+ * Used by contractChecks to catch a Zod→v4 conversion that produced a malformed schema.
4
+ */
5
+ import Ajv2020 from "ajv/dist/2020";
6
+ import addFormats from "ajv-formats";
7
+
8
+ export interface SchemaCheck {
9
+ valid: boolean;
10
+ errors: { path: string; message: string }[];
11
+ }
12
+
13
+ function build() {
14
+ const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
15
+ addFormats(ajv);
16
+ return ajv.getSchema("https://json-schema.org/draft/2020-12/schema")!;
17
+ }
18
+
19
+ let metaFn: ReturnType<typeof build> | undefined;
20
+
21
+ export function validateSchema2020(schema: unknown): SchemaCheck {
22
+ if (!metaFn) metaFn = build();
23
+ const valid = metaFn(schema) as boolean;
24
+ const errors = (metaFn.errors ?? []).map((e: { instancePath?: string; message?: string }) => ({
25
+ path: e.instancePath || "/",
26
+ message: e.message ?? "invalid",
27
+ }));
28
+ return { valid, errors };
29
+ }
@@ -0,0 +1,131 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import * as z from "zod";
3
+ import { Hono } from "hono";
4
+ import { validateDocument, buildAda, matchRequest } from "@suluk/core";
5
+ import { validate31, downgrade } from "@suluk/openapi-compat";
6
+ import { contract, emitV4, audit, coverage, autofill, runContractChecks, mount } from "../src/index";
7
+
8
+ const Pet = z.object({ id: z.number().int().optional(), name: z.string().min(1), tags: z.array(z.string()) });
9
+
10
+ const routes = contract([
11
+ {
12
+ method: "get", path: "/pet", name: "listPets",
13
+ summary: "List pets", tags: ["pets"],
14
+ responses: [{ status: 200, description: "ok", schema: z.array(Pet) }],
15
+ },
16
+ {
17
+ method: "post", path: "/pet", name: "createPet",
18
+ summary: "Create a pet", tags: ["pets"], scopes: ["write:pets"],
19
+ request: { json: Pet, examples: [{ name: "Rex", tags: [] }] },
20
+ responses: [{ status: 201, description: "created", schema: Pet }],
21
+ handler: (c: any) => c.json({ name: "Rex", tags: [] }, 201),
22
+ },
23
+ {
24
+ method: "get", path: "/pet/:petId", name: "getPet",
25
+ summary: "Get a pet by id",
26
+ request: { params: z.object({ petId: z.string() }) },
27
+ responses: [{ status: 200, description: "ok", schema: Pet }, { status: 404, description: "not found" }],
28
+ handler: (c: any) => c.json({ name: "Rex", tags: [] }),
29
+ },
30
+ {
31
+ // an undocumented + deprecated-since route, to exercise audit + the time axis
32
+ method: "get", path: "/legacy", name: "legacyThing",
33
+ deprecatedSince: "2020-01-01", removedSince: "2030-01-01",
34
+ responses: [{ status: 200 }],
35
+ },
36
+ ]);
37
+
38
+ describe("emitV4 — the keystone derivation", () => {
39
+ test("produces a v4 document that validates against the meta-schema", () => {
40
+ const { document } = emitV4(routes, { info: { title: "Pets", version: "1.0.0" } });
41
+ const r = validateDocument(document);
42
+ if (!r.valid) console.error(r.errors);
43
+ expect(r.valid).toBe(true);
44
+ });
45
+
46
+ test("Hono path :petId becomes a v4 uriTemplate {petId}, indexed in the ADA", () => {
47
+ const { document } = emitV4(routes);
48
+ expect(Object.keys(document.paths)).toContain("pet/{petId}");
49
+ const ada = buildAda(document);
50
+ expect(matchRequest(ada, "GET", "/pet/123")!.operation.name).toBe("getPet");
51
+ });
52
+
53
+ test("downgrades to valid OpenAPI 3.1 (Scalar/Swagger-ready)", () => {
54
+ const { document } = emitV4(routes, { info: { title: "Pets", version: "1" } });
55
+ expect(validate31(downgrade(document).document).valid).toBe(true);
56
+ });
57
+ });
58
+
59
+ describe("the document is a FUNCTION of who + when (dynamic projection)", () => {
60
+ test("WHO: a principal without write:pets does not see createPet", () => {
61
+ const reader = emitV4(routes, { principal: { scopes: [] } });
62
+ const names = Object.values(reader.document.paths).flatMap((pi) => Object.keys(pi.requests));
63
+ expect(names).toContain("listPets");
64
+ expect(names).not.toContain("createPet"); // requires write:pets
65
+ });
66
+ test("WHO: a principal WITH write:pets sees createPet", () => {
67
+ const writer = emitV4(routes, { principal: { scopes: ["write:pets"] } });
68
+ const names = Object.values(writer.document.paths).flatMap((pi) => Object.keys(pi.requests));
69
+ expect(names).toContain("createPet");
70
+ });
71
+ test("WHO: no principal ⇒ full public doc (no filtering)", () => {
72
+ const full = emitV4(routes);
73
+ const names = Object.values(full.document.paths).flatMap((pi) => Object.keys(pi.requests));
74
+ expect(names).toContain("createPet");
75
+ });
76
+ test("WHEN: after removedSince the legacy route is hidden", () => {
77
+ const future = emitV4(routes, { now: "2031-06-01" });
78
+ const names = Object.values(future.document.paths).flatMap((pi) => Object.keys(pi.requests));
79
+ expect(names).not.toContain("legacyThing");
80
+ });
81
+ test("WHEN: before removedSince but after deprecatedSince it is shown, marked deprecated", () => {
82
+ const now = emitV4(routes, { now: "2026-06-10" });
83
+ const legacy = now.document.paths["legacy"].requests["legacyThing"];
84
+ expect(legacy.deprecated).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe("audit — under-documented route detection (Conformance-Grade ceiling)", () => {
89
+ test("flags the undocumented legacy route, not the documented ones", () => {
90
+ const { document } = emitV4(routes);
91
+ const findings = audit(document);
92
+ const undocumented = findings.filter((f) => f.code === "missing-doc").map((f) => f.operation);
93
+ expect(undocumented).toContain("legacyThing");
94
+ expect(undocumented).not.toContain("getPet");
95
+ });
96
+ test("coverage is < 1 when a route is undocumented, and autofill raises it to 1", () => {
97
+ const { document } = emitV4(routes);
98
+ expect(coverage(document)).toBeLessThan(1);
99
+ expect(coverage(autofill(document))).toBe(1);
100
+ });
101
+ });
102
+
103
+ describe("contractChecks — auto-generated tests that catch mistakes", () => {
104
+ test("all checks pass for a well-formed contract set", () => {
105
+ const run = runContractChecks(routes);
106
+ if (run.failures.length) console.error(run.failures);
107
+ expect(run.failures).toEqual([]);
108
+ expect(run.total).toBeGreaterThan(5);
109
+ });
110
+ test("a request example that violates its schema is caught", () => {
111
+ const bad = contract([{
112
+ method: "post", path: "/x", name: "x",
113
+ request: { json: z.object({ n: z.number() }), examples: [{ n: "not a number" }] },
114
+ responses: [{ status: 200 }],
115
+ }]);
116
+ expect(runContractChecks(bad).failures.length).toBeGreaterThan(0);
117
+ });
118
+ });
119
+
120
+ describe("mount — one source feeds both the doc and the live app", () => {
121
+ const app = mount(new Hono(), routes);
122
+ test("validation derived from the same contracts rejects bad input, accepts good", async () => {
123
+ const bad = await app.request("/pet", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "" }) });
124
+ const good = await app.request("/pet", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Rex", tags: [] }) });
125
+ expect(bad.status).toBe(400); // name.min(1) fails
126
+ expect(good.status).toBe(201);
127
+ });
128
+ test("a GET route responds", async () => {
129
+ expect((await app.request("/pet/9")).status).toBe(200);
130
+ });
131
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": { "types": ["bun"], "resolveJsonModule": true },
4
+ "include": ["src", "test"]
5
+ }