create-daloy 0.39.1 → 0.41.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.
Files changed (30) hide show
  1. package/bin/create-daloy.mjs +13 -1
  2. package/package.json +1 -1
  3. package/sbom.cdx.json +9 -9
  4. package/sbom.spdx.json +5 -5
  5. package/templates/bun-basic/README.md +14 -0
  6. package/templates/bun-basic/_githooks/pre-push +16 -0
  7. package/templates/bun-basic/package.json +4 -2
  8. package/templates/bun-basic/tests/contract.test.ts +33 -0
  9. package/templates/bun-basic/tests/healthz.test.ts +7 -0
  10. package/templates/cloudflare-worker/README.md +14 -0
  11. package/templates/cloudflare-worker/_githooks/pre-push +17 -0
  12. package/templates/cloudflare-worker/package.json +4 -2
  13. package/templates/cloudflare-worker/src/index.ts +5 -0
  14. package/templates/cloudflare-worker/tests/app.test.ts +19 -0
  15. package/templates/cloudflare-worker/tests/contract.test.ts +38 -0
  16. package/templates/deno-basic/README.md +14 -0
  17. package/templates/deno-basic/_githooks/pre-push +16 -0
  18. package/templates/deno-basic/deno.json +8 -5
  19. package/templates/deno-basic/tests/contract_test.ts +37 -0
  20. package/templates/deno-basic/tests/healthz_test.ts +7 -0
  21. package/templates/node-basic/README.md +14 -0
  22. package/templates/node-basic/_githooks/pre-push +17 -0
  23. package/templates/node-basic/package.json +4 -2
  24. package/templates/node-basic/tests/contract.test.ts +38 -0
  25. package/templates/node-basic/tests/healthz.test.ts +19 -0
  26. package/templates/vercel/README.md +14 -0
  27. package/templates/vercel/_githooks/pre-push +17 -0
  28. package/templates/vercel/package.json +4 -2
  29. package/templates/vercel/tests/app.test.ts +6 -0
  30. package/templates/vercel/tests/contract.test.ts +38 -0
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { spawn } from "node:child_process";
6
6
  import { existsSync, openSync } from "node:fs";
7
- import { mkdir, readdir, readFile, writeFile, copyFile, rm } from "node:fs/promises";
7
+ import { mkdir, readdir, readFile, writeFile, copyFile, rm, stat, chmod } from "node:fs/promises";
8
8
  import { createInterface } from "node:readline/promises";
9
9
  import { stdin as input, stdout as output } from "node:process";
10
10
  import { ReadStream as TtyReadStream } from "node:tty";
@@ -79,6 +79,10 @@ const RENAME_ON_COPY = new Map([
79
79
  // Templates author this as `_vscode/` so npm pack does not drop the
80
80
  // dotfolder during publish.
81
81
  ["_vscode", ".vscode"],
82
+ // Directory: holds the opt-in `pre-push` contract gate. `core.hooksPath`
83
+ // points here once the user runs the `hooks:install` script. Authored as
84
+ // `_githooks/` so npm pack does not drop the dotfolder during publish.
85
+ ["_githooks", ".githooks"],
82
86
  ]);
83
87
 
84
88
  // Templates that target a runtime instead of an npm package manager.
@@ -499,6 +503,12 @@ async function copyTemplate(src, dest) {
499
503
  await copyTemplate(from, to);
500
504
  } else if (entry.isFile()) {
501
505
  await copyFile(from, to);
506
+ // Preserve the source file's mode so executable templates (e.g.
507
+ // `.githooks/pre-push`, the localhost contract gate) stay executable in
508
+ // the scaffold. `fs.copyFile` does not guarantee mode preservation on
509
+ // every platform (notably Linux `copy_file_range`), so set it explicitly.
510
+ const { mode } = await stat(from);
511
+ await chmod(to, mode);
502
512
  }
503
513
  }
504
514
  }
@@ -549,6 +559,8 @@ function rewritePackageManagerText(raw, packageManager) {
549
559
  .replace(/\bpnpm deploy\b/g, `${packageManager} run deploy`)
550
560
  .replace(/\bpnpm dev\b/g, `${packageManager} run dev`)
551
561
  .replace(/\bpnpm gen\b/g, `${packageManager} run gen`)
562
+ .replace(/\bpnpm contract\b/g, `${packageManager} run contract`)
563
+ .replace(/\bpnpm hooks:install\b/g, `${packageManager} run hooks:install`)
552
564
  .replace(/\bpnpm test\b/g, `${packageManager} test`)
553
565
  .replace(/\bpnpm audit\b/g, `${packageManager} audit`)
554
566
  .replace(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-daloy",
3
- "version": "0.39.1",
3
+ "version": "0.41.0",
4
4
  "description": "Scaffold a new DaloyJS project. Run with `pnpm create daloy`, `npm create daloy@latest`, `yarn create daloy`, or `bun create daloy`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/sbom.cdx.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:695a28ef-e70f-5825-96bc-ac3607a61156",
4
+ "serialNumber": "urn:uuid:e0065a2d-183f-5e2f-a081-7423be37ca75",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-06-17T18:34:51.003Z",
7
+ "timestamp": "2026-06-18T16:40:29.534Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "0.39.1"
12
+ "version": "0.41.0"
13
13
  }
14
14
  ],
15
15
  "authors": [],
16
16
  "component": {
17
17
  "type": "library",
18
- "bom-ref": "pkg:npm/create-daloy@0.39.1",
18
+ "bom-ref": "pkg:npm/create-daloy@0.41.0",
19
19
  "name": "create-daloy",
20
- "version": "0.39.1",
20
+ "version": "0.41.0",
21
21
  "description": "Scaffold a new DaloyJS project. Run with `pnpm create daloy`, `npm create daloy@latest`, `yarn create daloy`, or `bun create daloy`.",
22
- "purl": "pkg:npm/create-daloy@0.39.1",
22
+ "purl": "pkg:npm/create-daloy@0.41.0",
23
23
  "licenses": [
24
24
  {
25
25
  "license": {
@@ -42,9 +42,9 @@
42
42
  }
43
43
  ],
44
44
  "swid": {
45
- "tagId": "swidtag-create-daloy-0.39.1",
45
+ "tagId": "swidtag-create-daloy-0.41.0",
46
46
  "name": "create-daloy",
47
- "version": "0.39.1",
47
+ "version": "0.41.0",
48
48
  "tagVersion": 0,
49
49
  "patch": false
50
50
  }
@@ -53,7 +53,7 @@
53
53
  "components": [],
54
54
  "dependencies": [
55
55
  {
56
- "ref": "pkg:npm/create-daloy@0.39.1",
56
+ "ref": "pkg:npm/create-daloy@0.41.0",
57
57
  "dependsOn": []
58
58
  }
59
59
  ]
package/sbom.spdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "create-daloy-0.39.1",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/create-daloy-0.39.1-695a28ef-e70f-5825-96bc-ac3607a61156",
5
+ "name": "create-daloy-0.41.0",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/create-daloy-0.41.0-e0065a2d-183f-5e2f-a081-7423be37ca75",
7
7
  "creationInfo": {
8
- "created": "2026-06-17T18:34:51.003Z",
8
+ "created": "2026-06-18T16:40:29.534Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package-create-daloy",
18
18
  "name": "create-daloy",
19
- "versionInfo": "0.39.1",
19
+ "versionInfo": "0.41.0",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/create-daloy@0.39.1"
30
+ "referenceLocator": "pkg:npm/create-daloy@0.41.0"
31
31
  }
32
32
  ]
33
33
  }
@@ -44,6 +44,20 @@ bun run gen:client
44
44
  bun test
45
45
  ```
46
46
 
47
+ ## Contract gate
48
+
49
+ Check that your OpenAPI contract is internally consistent (operationIds present and unique, response examples matching their schemas, no dead routes). It ships as `tests/contract.test.ts` (run under `bun test`) and as a focused script:
50
+
51
+ ```bash
52
+ bun run contract # bun test tests/contract.test.ts
53
+ ```
54
+
55
+ For a localhost-only gate that runs before code leaves your machine, enable the bundled pre-push hook (opt-in; bypass once with `git push --no-verify`):
56
+
57
+ ```bash
58
+ bun run hooks:install # points core.hooksPath at .githooks
59
+ ```
60
+
47
61
  ## Imports
48
62
 
49
63
  This project uses TypeScript with `"moduleResolution": "Bundler"` and `"allowImportingTsExtensions": true`. Relative imports use the **`.ts` extension** directly:
@@ -0,0 +1,16 @@
1
+ #!/bin/sh
2
+ # daloyjs-pre-push-contract-hook v1
3
+ #
4
+ # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
+ # error-level issues: missing or duplicate operationIds, response examples that
6
+ # don't match their schema, accidental body schemas on safe methods, or routes
7
+ # with no declared responses. Catches the class of drift `tsc` can't see before
8
+ # it leaves your machine, with CI as the backstop.
9
+ #
10
+ # Enable once per clone: bun run hooks:install (sets core.hooksPath here)
11
+ # Bypass once: git push --no-verify
12
+ if ! command -v bun >/dev/null 2>&1; then
13
+ echo "pre-push: bun not found on PATH. Skipping contract gate." >&2
14
+ exit 0
15
+ fi
16
+ exec bun test tests/contract.test.ts
@@ -11,13 +11,15 @@
11
11
  "start": "bun src/index.ts",
12
12
  "typecheck": "tsc --noEmit",
13
13
  "test": "bun test",
14
+ "contract": "bun test tests/contract.test.ts",
14
15
  "gen:openapi": "bun run scripts/dump-openapi.ts",
15
16
  "gen:client": "openapi-ts",
16
17
  "gen": "pnpm gen:openapi && pnpm gen:client",
17
- "audit": "pnpm audit --prod"
18
+ "audit": "pnpm audit --prod",
19
+ "hooks:install": "git config core.hooksPath .githooks"
18
20
  },
19
21
  "dependencies": {
20
- "@daloyjs/core": "^0.39.1",
22
+ "@daloyjs/core": "^0.41.0",
21
23
  "zod": "^4.4.3"
22
24
  },
23
25
  "devDependencies": {
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { App } from "@daloyjs/core";
4
+ import { runContractTests } from "@daloyjs/core/contract";
5
+ import { buildApp } from "../src/build-app.ts";
6
+
7
+ // Contract gate. `runContractTests` re-derives the OpenAPI contract from the
8
+ // live route table and fails on the defects `tsc` can't see: missing or
9
+ // duplicate operationIds, response examples that don't match their schema, and
10
+ // routes that declare no responses. Running it under `bun test` means a broken
11
+ // contract fails CI before it can ship a misleading OpenAPI spec or generate a
12
+ // wrong typed client.
13
+ describe("contract", () => {
14
+ test("the app's OpenAPI contract is internally consistent", async () => {
15
+ const report = await runContractTests(buildApp());
16
+ expect(report.ok).toBe(true);
17
+ });
18
+
19
+ // Unhappy path: prove the gate actually rejects a broken contract. A route
20
+ // without an operationId can't generate a stable client method name, so the
21
+ // runner reports an error (operationId is required by default).
22
+ test("the contract gate rejects a route missing its operationId", async () => {
23
+ const broken = new App();
24
+ broken.route({
25
+ method: "GET",
26
+ path: "/missing-op-id",
27
+ responses: { 200: { description: "ok", body: z.object({ ok: z.boolean() }) } },
28
+ handler: async () => ({ status: 200 as const, body: { ok: true } }),
29
+ });
30
+ const report = await runContractTests(broken);
31
+ expect(report.ok).toBe(false);
32
+ });
33
+ });
@@ -10,4 +10,11 @@ describe("buildApp", () => {
10
10
  expect(body.ok).toBe(true);
11
11
  expect(body.runtime).toBe("bun");
12
12
  });
13
+
14
+ // Unhappy path: an unregistered route is rejected with 404 (problem+json).
15
+ test("unknown route returns 404", async () => {
16
+ const app = buildApp();
17
+ const res = await app.request("/__not_a_route__");
18
+ expect(res.status).toBe(404);
19
+ });
13
20
  });
@@ -17,6 +17,20 @@ pnpm deploy
17
17
 
18
18
  `@daloyjs/core/cloudflare` exposes `toFetchHandler(app)`, so the same `App` you would use on Node also runs on Workers.
19
19
 
20
+ ## Contract gate
21
+
22
+ Check that your OpenAPI contract is internally consistent (operationIds present and unique, response examples matching their schemas, no dead routes). It ships as `tests/contract.test.ts` (run under `pnpm test`) and as a focused script:
23
+
24
+ ```bash
25
+ pnpm contract # daloy inspect --check src/index.ts
26
+ ```
27
+
28
+ For a localhost-only gate that runs before code leaves your machine, enable the bundled pre-push hook (opt-in; bypass once with `git push --no-verify`):
29
+
30
+ ```bash
31
+ pnpm hooks:install # points core.hooksPath at .githooks
32
+ ```
33
+
20
34
  ## What's included
21
35
 
22
36
  - `@daloyjs/core/cloudflare` with starter security middleware: `secureHeaders` and `requestId`.
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ # daloyjs-pre-push-contract-hook v1
3
+ #
4
+ # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
+ # error-level issues: missing or duplicate operationIds, response examples that
6
+ # don't match their schema, accidental body schemas on safe methods, or routes
7
+ # with no declared responses. Catches the class of drift `tsc` can't see before
8
+ # it leaves your machine, with CI as the backstop.
9
+ #
10
+ # Enable once per clone: npm run hooks:install (sets core.hooksPath here)
11
+ # Bypass once: git push --no-verify
12
+ DALOY="./node_modules/.bin/daloy"
13
+ if [ ! -x "$DALOY" ]; then
14
+ echo "pre-push: daloy CLI not found — run your package manager's install. Skipping contract gate." >&2
15
+ exit 0
16
+ fi
17
+ exec "$DALOY" inspect --check src/index.ts
@@ -8,10 +8,12 @@
8
8
  "deploy": "wrangler deploy",
9
9
  "typecheck": "tsc --noEmit",
10
10
  "test": "node --import tsx/esm --test tests/**/*.test.ts",
11
- "audit": "pnpm audit --prod"
11
+ "contract": "daloy inspect --check src/index.ts",
12
+ "audit": "pnpm audit --prod",
13
+ "hooks:install": "git config core.hooksPath .githooks"
12
14
  },
13
15
  "dependencies": {
14
- "@daloyjs/core": "^0.39.1",
16
+ "@daloyjs/core": "^0.41.0",
15
17
  "zod": "^4.4.3"
16
18
  },
17
19
  "devDependencies": {
@@ -68,4 +68,9 @@ app.route({
68
68
  });
69
69
  // daloy-minimal:strip-end books
70
70
 
71
+ // Cloudflare invokes the default export's `fetch`. The `app` instance is also
72
+ // exported (a no-op for the Worker runtime) so `tests/contract.test.ts` can
73
+ // contract-check the route table in-process without booting the handler.
74
+ export { app };
75
+
71
76
  export default toFetchHandler(app);
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import handler from "../src/index.ts";
4
+
5
+ // Cloudflare Workers invoke the default export's `fetch(request, env?, ctx?)`.
6
+ // env / ctx are optional, so a plain Request is enough for in-process tests.
7
+ test("GET /healthz returns 200", async () => {
8
+ const res = await handler.fetch(new Request("https://example.test/healthz"));
9
+ assert.equal(res.status, 200);
10
+ const body = (await res.json()) as { ok: boolean; runtime: string };
11
+ assert.equal(body.ok, true);
12
+ assert.equal(body.runtime, "cloudflare-worker");
13
+ });
14
+
15
+ // Unhappy path: an unregistered route is rejected with 404 (problem+json).
16
+ test("unknown route returns 404", async () => {
17
+ const res = await handler.fetch(new Request("https://example.test/__not_a_route__"));
18
+ assert.equal(res.status, 404);
19
+ });
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { z } from "zod";
4
+ import { App } from "@daloyjs/core";
5
+ import { runContractTests } from "@daloyjs/core/contract";
6
+ import { app } from "../src/index.ts";
7
+
8
+ // Contract gate. `runContractTests` re-derives the OpenAPI contract from the
9
+ // live route table and fails on the defects `tsc` can't see: missing or
10
+ // duplicate operationIds, response examples that don't match their schema, and
11
+ // routes that declare no responses. Running it under `pnpm test` means a broken
12
+ // contract fails CI before it can ship a misleading OpenAPI spec or generate a
13
+ // wrong typed client.
14
+ test("the app's OpenAPI contract is internally consistent", async () => {
15
+ const report = await runContractTests(app);
16
+ assert.equal(
17
+ report.ok,
18
+ true,
19
+ `contract issues:\n${report.issues
20
+ .map((i) => ` [${i.level}] ${i.route}: ${i.message}`)
21
+ .join("\n")}`,
22
+ );
23
+ });
24
+
25
+ // Unhappy path: prove the gate actually rejects a broken contract. A route
26
+ // without an operationId can't generate a stable client method name, so the
27
+ // runner reports an error (operationId is required by default).
28
+ test("the contract gate rejects a route missing its operationId", async () => {
29
+ const broken = new App();
30
+ broken.route({
31
+ method: "GET",
32
+ path: "/missing-op-id",
33
+ responses: { 200: { description: "ok", body: z.object({ ok: z.boolean() }) } },
34
+ handler: async () => ({ status: 200 as const, body: { ok: true } }),
35
+ });
36
+ const report = await runContractTests(broken);
37
+ assert.equal(report.ok, false);
38
+ });
@@ -47,6 +47,20 @@ first-class Deno entry point.
47
47
  deno task test
48
48
  ```
49
49
 
50
+ ## Contract gate
51
+
52
+ Check that your OpenAPI contract is internally consistent (operationIds present and unique, response examples matching their schemas, no dead routes). It ships as `tests/contract_test.ts` (run under `deno task test`) and as a focused task:
53
+
54
+ ```bash
55
+ deno task contract # deno test --allow-net --allow-env tests/contract_test.ts
56
+ ```
57
+
58
+ For a localhost-only gate that runs before code leaves your machine, enable the bundled pre-push hook (opt-in; bypass once with `git push --no-verify`):
59
+
60
+ ```bash
61
+ deno task hooks:install # points core.hooksPath at .githooks
62
+ ```
63
+
50
64
  ## What's included
51
65
 
52
66
  - `@daloyjs/core` (loaded via `jsr:` specifiers in `deno.json`).
@@ -0,0 +1,16 @@
1
+ #!/bin/sh
2
+ # daloyjs-pre-push-contract-hook v1
3
+ #
4
+ # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
+ # error-level issues: missing or duplicate operationIds, response examples that
6
+ # don't match their schema, accidental body schemas on safe methods, or routes
7
+ # with no declared responses. Catches the class of drift the type-checker can't
8
+ # see before it leaves your machine, with CI as the backstop.
9
+ #
10
+ # Enable once per clone: deno task hooks:install (sets core.hooksPath here)
11
+ # Bypass once: git push --no-verify
12
+ if ! command -v deno >/dev/null 2>&1; then
13
+ echo "pre-push: deno not found on PATH. Skipping contract gate." >&2
14
+ exit 0
15
+ fi
16
+ exec deno test --allow-net --allow-env tests/contract_test.ts
@@ -5,13 +5,16 @@
5
5
  "start": "deno run --allow-net --allow-env --allow-read src/main.ts",
6
6
  "typecheck": "deno check src/main.ts",
7
7
  "test": "deno test --allow-net --allow-env tests/",
8
- "gen:openapi": "deno run --allow-net --allow-env --allow-read --allow-write scripts/dump-openapi.ts"
8
+ "contract": "deno test --allow-net --allow-env tests/contract_test.ts",
9
+ "gen:openapi": "deno run --allow-net --allow-env --allow-read --allow-write scripts/dump-openapi.ts",
10
+ "hooks:install": "git config core.hooksPath .githooks"
9
11
  },
10
12
  "imports": {
11
- "@daloyjs/core": "jsr:@daloyjs/daloy@^0.39.1",
12
- "@daloyjs/core/banner": "jsr:@daloyjs/daloy@^0.39.1/banner",
13
- "@daloyjs/core/deno": "jsr:@daloyjs/daloy@^0.39.1/deno",
14
- "@daloyjs/core/openapi": "jsr:@daloyjs/daloy@^0.39.1/openapi",
13
+ "@daloyjs/core": "jsr:@daloyjs/daloy@^0.41.0",
14
+ "@daloyjs/core/banner": "jsr:@daloyjs/daloy@^0.41.0/banner",
15
+ "@daloyjs/core/contract": "jsr:@daloyjs/daloy@^0.41.0/contract",
16
+ "@daloyjs/core/deno": "jsr:@daloyjs/daloy@^0.41.0/deno",
17
+ "@daloyjs/core/openapi": "jsr:@daloyjs/daloy@^0.41.0/openapi",
15
18
  "zod": "npm:zod@^4.4.3"
16
19
  },
17
20
  "compilerOptions": {
@@ -0,0 +1,37 @@
1
+ import { assertEquals } from "jsr:@std/assert@^1.0.0";
2
+ import { z } from "zod";
3
+ import { App } from "@daloyjs/core";
4
+ import { runContractTests } from "@daloyjs/core/contract";
5
+ import { buildApp } from "../src/build-app.ts";
6
+
7
+ // Contract gate. `runContractTests` re-derives the OpenAPI contract from the
8
+ // live route table and fails on the defects the type-checker can't see: missing
9
+ // or duplicate operationIds, response examples that don't match their schema,
10
+ // and routes that declare no responses. Running it under `deno task test` means
11
+ // a broken contract fails CI before it can ship a misleading OpenAPI spec or
12
+ // generate a wrong typed client.
13
+ Deno.test("the app's OpenAPI contract is internally consistent", async () => {
14
+ const report = await runContractTests(buildApp());
15
+ assertEquals(
16
+ report.ok,
17
+ true,
18
+ `contract issues:\n${report.issues
19
+ .map((i) => ` [${i.level}] ${i.route}: ${i.message}`)
20
+ .join("\n")}`,
21
+ );
22
+ });
23
+
24
+ // Unhappy path: prove the gate actually rejects a broken contract. A route
25
+ // without an operationId can't generate a stable client method name, so the
26
+ // runner reports an error (operationId is required by default).
27
+ Deno.test("the contract gate rejects a route missing its operationId", async () => {
28
+ const broken = new App();
29
+ broken.route({
30
+ method: "GET",
31
+ path: "/missing-op-id",
32
+ responses: { 200: { description: "ok", body: z.object({ ok: z.boolean() }) } },
33
+ handler: async () => ({ status: 200 as const, body: { ok: true } }),
34
+ });
35
+ const report = await runContractTests(broken);
36
+ assertEquals(report.ok, false);
37
+ });
@@ -9,3 +9,10 @@ Deno.test("GET /healthz returns 200", async () => {
9
9
  assertEquals(body.ok, true);
10
10
  assertEquals(body.runtime, "deno");
11
11
  });
12
+
13
+ // Unhappy path: an unregistered route is rejected with 404 (problem+json).
14
+ Deno.test("unknown route returns 404", async () => {
15
+ const app = buildApp();
16
+ const res = await app.request("/__not_a_route__");
17
+ assertEquals(res.status, 404);
18
+ });
@@ -39,6 +39,20 @@ pnpm gen
39
39
  # → generated/client/ (typed Hey API client)
40
40
  ```
41
41
 
42
+ ## Contract gate
43
+
44
+ Check that your OpenAPI contract is internally consistent (operationIds present and unique, response examples matching their schemas, no dead routes):
45
+
46
+ ```bash
47
+ pnpm contract # daloy inspect --check src/build-app.ts
48
+ ```
49
+
50
+ It also runs as `tests/contract.test.ts` under `pnpm test`. For a localhost-only gate that runs before code leaves your machine, enable the bundled pre-push hook (opt-in; bypass once with `git push --no-verify`):
51
+
52
+ ```bash
53
+ pnpm hooks:install # points core.hooksPath at .githooks
54
+ ```
55
+
42
56
  ## Build
43
57
 
44
58
  ```bash
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ # daloyjs-pre-push-contract-hook v1
3
+ #
4
+ # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
+ # error-level issues: missing or duplicate operationIds, response examples that
6
+ # don't match their schema, accidental body schemas on safe methods, or routes
7
+ # with no declared responses. Catches the class of drift `tsc` can't see before
8
+ # it leaves your machine, with CI as the backstop.
9
+ #
10
+ # Enable once per clone: npm run hooks:install (sets core.hooksPath here)
11
+ # Bypass once: git push --no-verify
12
+ DALOY="./node_modules/.bin/daloy"
13
+ if [ ! -x "$DALOY" ]; then
14
+ echo "pre-push: daloy CLI not found — run your package manager's install. Skipping contract gate." >&2
15
+ exit 0
16
+ fi
17
+ exec "$DALOY" inspect --check src/build-app.ts
@@ -12,13 +12,15 @@
12
12
  "build": "tsc -p tsconfig.build.json",
13
13
  "typecheck": "tsc --noEmit",
14
14
  "test": "node --import tsx/esm --test tests/**/*.test.ts",
15
+ "contract": "daloy inspect --check src/build-app.ts",
15
16
  "gen:openapi": "node --import tsx/esm scripts/dump-openapi.ts",
16
17
  "gen:client": "openapi-ts",
17
18
  "gen": "pnpm gen:openapi && pnpm gen:client",
18
- "audit": "pnpm audit --prod"
19
+ "audit": "pnpm audit --prod",
20
+ "hooks:install": "git config core.hooksPath .githooks"
19
21
  },
20
22
  "dependencies": {
21
- "@daloyjs/core": "^0.39.1",
23
+ "@daloyjs/core": "^0.41.0",
22
24
  "zod": "^4.4.3"
23
25
  },
24
26
  "devDependencies": {
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { z } from "zod";
4
+ import { App } from "@daloyjs/core";
5
+ import { runContractTests } from "@daloyjs/core/contract";
6
+ import { buildApp } from "../src/build-app.ts";
7
+
8
+ // Contract gate. `runContractTests` re-derives the OpenAPI contract from the
9
+ // live route table and fails on the defects `tsc` can't see: missing or
10
+ // duplicate operationIds, response examples that don't match their schema, and
11
+ // routes that declare no responses. Running it under `pnpm test` means a broken
12
+ // contract fails CI before it can ship a misleading OpenAPI spec or generate a
13
+ // wrong typed client.
14
+ test("the app's OpenAPI contract is internally consistent", async () => {
15
+ const report = await runContractTests(buildApp());
16
+ assert.equal(
17
+ report.ok,
18
+ true,
19
+ `contract issues:\n${report.issues
20
+ .map((i) => ` [${i.level}] ${i.route}: ${i.message}`)
21
+ .join("\n")}`,
22
+ );
23
+ });
24
+
25
+ // Unhappy path: prove the gate actually rejects a broken contract. A route
26
+ // without an operationId can't generate a stable client method name, so the
27
+ // runner reports an error (operationId is required by default).
28
+ test("the contract gate rejects a route missing its operationId", async () => {
29
+ const broken = new App();
30
+ broken.route({
31
+ method: "GET",
32
+ path: "/missing-op-id",
33
+ responses: { 200: { description: "ok", body: z.object({ ok: z.boolean() }) } },
34
+ handler: async () => ({ status: 200 as const, body: { ok: true } }),
35
+ });
36
+ const report = await runContractTests(broken);
37
+ assert.equal(report.ok, false);
38
+ });
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildApp } from "../src/build-app.ts";
4
+
5
+ // In-process tests via `app.request(...)` — no port, no network.
6
+ test("GET /healthz returns 200", async () => {
7
+ const app = buildApp();
8
+ const res = await app.request("/healthz");
9
+ assert.equal(res.status, 200);
10
+ const body = (await res.json()) as { ok: boolean };
11
+ assert.equal(body.ok, true);
12
+ });
13
+
14
+ // Unhappy path: an unregistered route is rejected with 404 (problem+json).
15
+ test("unknown route returns 404", async () => {
16
+ const app = buildApp();
17
+ const res = await app.request("/__not_a_route__");
18
+ assert.equal(res.status, 404);
19
+ });
@@ -31,6 +31,20 @@ To brand Scalar, change `docs: true` in `api/index.ts` to `docs: { scalar: { the
31
31
 
32
32
  <!-- daloy-minimal:strip-end docs -->
33
33
 
34
+ ## Contract gate
35
+
36
+ Check that your OpenAPI contract is internally consistent (operationIds present and unique, response examples matching their schemas, no dead routes). It ships as `tests/contract.test.ts` (run under `pnpm test`) and as a focused script:
37
+
38
+ ```bash
39
+ pnpm contract # daloy inspect --check api/index.ts
40
+ ```
41
+
42
+ For a localhost-only gate that runs before code leaves your machine, enable the bundled pre-push hook (opt-in; bypass once with `git push --no-verify`):
43
+
44
+ ```bash
45
+ pnpm hooks:install # points core.hooksPath at .githooks
46
+ ```
47
+
34
48
  ## Deploy
35
49
 
36
50
  ```bash
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ # daloyjs-pre-push-contract-hook v1
3
+ #
4
+ # Localhost-only contract gate. Rejects a push whose OpenAPI contract has
5
+ # error-level issues: missing or duplicate operationIds, response examples that
6
+ # don't match their schema, accidental body schemas on safe methods, or routes
7
+ # with no declared responses. Catches the class of drift `tsc` can't see before
8
+ # it leaves your machine, with CI as the backstop.
9
+ #
10
+ # Enable once per clone: npm run hooks:install (sets core.hooksPath here)
11
+ # Bypass once: git push --no-verify
12
+ DALOY="./node_modules/.bin/daloy"
13
+ if [ ! -x "$DALOY" ]; then
14
+ echo "pre-push: daloy CLI not found — run your package manager's install. Skipping contract gate." >&2
15
+ exit 0
16
+ fi
17
+ exec "$DALOY" inspect --check api/index.ts
@@ -8,10 +8,12 @@
8
8
  "deploy": "vercel deploy",
9
9
  "typecheck": "tsc --noEmit",
10
10
  "test": "node --import tsx/esm --test tests/**/*.test.ts",
11
- "audit": "pnpm audit --prod"
11
+ "contract": "daloy inspect --check api/index.ts",
12
+ "audit": "pnpm audit --prod",
13
+ "hooks:install": "git config core.hooksPath .githooks"
12
14
  },
13
15
  "dependencies": {
14
- "@daloyjs/core": "^0.39.1",
16
+ "@daloyjs/core": "^0.41.0",
15
17
  "zod": "^4.4.3"
16
18
  },
17
19
  "devDependencies": {
@@ -8,3 +8,9 @@ test("Vercel Node.js handler responds through DaloyJS", async () => {
8
8
  assert.equal(response.status, 200);
9
9
  assert.equal((await response.json()).runtime, "vercel");
10
10
  });
11
+
12
+ // Unhappy path: an unregistered route is rejected with 404 (problem+json).
13
+ test("unknown route returns 404", async () => {
14
+ const response = await handler.fetch(new Request("https://example.test/__not_a_route__"));
15
+ assert.equal(response.status, 404);
16
+ });
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { z } from "zod";
4
+ import { App } from "@daloyjs/core";
5
+ import { runContractTests } from "@daloyjs/core/contract";
6
+ import { app } from "../api/index.ts";
7
+
8
+ // Contract gate. `runContractTests` re-derives the OpenAPI contract from the
9
+ // live route table and fails on the defects `tsc` can't see: missing or
10
+ // duplicate operationIds, response examples that don't match their schema, and
11
+ // routes that declare no responses. Running it under `pnpm test` means a broken
12
+ // contract fails CI before it can ship a misleading OpenAPI spec or generate a
13
+ // wrong typed client.
14
+ test("the app's OpenAPI contract is internally consistent", async () => {
15
+ const report = await runContractTests(app);
16
+ assert.equal(
17
+ report.ok,
18
+ true,
19
+ `contract issues:\n${report.issues
20
+ .map((i) => ` [${i.level}] ${i.route}: ${i.message}`)
21
+ .join("\n")}`,
22
+ );
23
+ });
24
+
25
+ // Unhappy path: prove the gate actually rejects a broken contract. A route
26
+ // without an operationId can't generate a stable client method name, so the
27
+ // runner reports an error (operationId is required by default).
28
+ test("the contract gate rejects a route missing its operationId", async () => {
29
+ const broken = new App();
30
+ broken.route({
31
+ method: "GET",
32
+ path: "/missing-op-id",
33
+ responses: { 200: { description: "ok", body: z.object({ ok: z.boolean() }) } },
34
+ handler: async () => ({ status: 200 as const, body: { ok: true } }),
35
+ });
36
+ const report = await runContractTests(broken);
37
+ assert.equal(report.ok, false);
38
+ });