diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..a8824ce
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,4 @@
+# Reuse the pinned devenv direnv integration used by the public toolchain.
+source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
+
+use devenv
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7ec4b0c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,52 @@
+name: tabit-ci
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions: {}
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ # Full history: moon diffs against main for affected files, which a shallow clone lacks.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31
+ with:
+ extra_nix_config: |
+ experimental-features = nix-command flakes
+
+ - name: Build exported tools and policy
+ run: |
+ rumdl="$(nix build --no-link --print-out-paths .#rumdl)"
+ biome="$(nix build --no-link --print-out-paths .#biome)"
+ base_config="$(nix build --no-link --print-out-paths .#rumdl-base-config)"
+ echo "$rumdl/bin" >> "$GITHUB_PATH"
+ echo "$biome/bin" >> "$GITHUB_PATH"
+ echo "RUMDL_BASE_CONFIG=$base_config/rumdl/base.toml" >> "$GITHUB_ENV"
+ echo "RUMDL_BIN=$rumdl/bin/rumdl" >> "$GITHUB_ENV"
+
+ # The dev shell's runtimes, from the same nixpkgs pin, so CI and local runs agree.
+ - name: Put Bun, Node.js, and moon on PATH
+ run: |
+ mapfile -t outputs < <(nix build --no-link --print-out-paths --inputs-from . nixpkgs#bun nixpkgs#nodejs nixpkgs#moon)
+ for output in "${outputs[@]}"; do
+ echo "$output/bin" >> "$GITHUB_PATH"
+ done
+
+ - name: Check the flake
+ run: nix flake check
+
+ - name: Run workspace CI
+ run: moon run :ci --force
diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml
new file mode 100644
index 0000000..678b04c
--- /dev/null
+++ b/.github/workflows/renovate.yml
@@ -0,0 +1,56 @@
+name: tabit-renovate
+
+on:
+ schedule:
+ - cron: "0 6 * * *"
+ workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+ group: renovate
+ cancel-in-progress: false
+
+jobs:
+ renovate:
+ name: run
+ runs-on: ubuntu-latest
+ environment: main
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31
+ with:
+ extra_nix_config: |
+ experimental-features = nix-command flakes
+
+ - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
+ with:
+ bun-version: 1.4.2
+
+ - name: Mint Renovate App token
+ id: app-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ vars.RENOVATE_APP_CLIENT_ID }}
+ private-key: ${{ secrets.RENOVATE_APP_PRIVATE_KEY }}
+
+ - name: Put pinned devenv CLI on PATH
+ run: echo "$(nix build --no-link --print-out-paths --inputs-from . nixpkgs#devenv)/bin" >> "$GITHUB_PATH"
+
+ - name: Assert devenv is on PATH
+ run: command -v devenv
+
+ - name: Put pinned Node.js on PATH
+ run: echo "$(nix build --no-link --print-out-paths --inputs-from . nixpkgs#nodejs)/bin" >> "$GITHUB_PATH"
+
+ - name: Assert pinned Node.js is on PATH
+ run: node --version
+
+ - name: Run Renovate
+ env:
+ RENOVATE_CONFIG_FILE: tools/renovate/bot-config.json5
+ RENOVATE_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: bunx renovate@44.46.2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c1ceddc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.devenv/
+.direnv/
+.rumdl_cache/
+result
+result-*
+node_modules/
+.moon/cache/
+.ref-gate-test-*/
diff --git a/.moon/workspace.yml b/.moon/workspace.yml
new file mode 100644
index 0000000..63a84c5
--- /dev/null
+++ b/.moon/workspace.yml
@@ -0,0 +1,10 @@
+# yaml-language-server: $schema=https://moonrepo.dev/schemas/workspace.json
+
+projects:
+ sources:
+ root: '.'
+
+vcs:
+ client: 'git'
+ provider: 'github'
+ defaultBranch: 'main'
diff --git a/.rumdl.toml b/.rumdl.toml
new file mode 100644
index 0000000..ddf41e6
--- /dev/null
+++ b/.rumdl.toml
@@ -0,0 +1 @@
+extends = "$RUMDL_BASE_CONFIG"
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 0000000..229e05b
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1 @@
+* @mattwilkinsonn
diff --git a/LICENSE-APACHE b/LICENSE-APACHE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE-APACHE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/LICENSE-MIT b/LICENSE-MIT
new file mode 100644
index 0000000..dfb986a
--- /dev/null
+++ b/LICENSE-MIT
@@ -0,0 +1,19 @@
+Copyright (c) 2026 Rigel AI Software Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
index db737fb..41885da 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,36 @@
-# config-render
+# @rigelbuild/tabit
-Typed config modules rendered to deterministic, schema-checked JSON.
+Define Zod-backed TypeScript configuration and render deterministic JSON with Bun.
+
+Pronounced TAH-bit. Named for the star π3 Ori, "the enduring one".
+
+Install with `bun add @rigelbuild/tabit`.
+
+## Define a config module
+
+```ts
+import { defineConfig } from "@rigelbuild/tabit";
+import { z } from "zod";
+
+const Settings = z.strictObject({ service: z.string(), retries: z.int().default(3) });
+export default defineConfig(Settings, () => ({ service: "worker" }), {
+ out: "./settings.json",
+ schemaOut: "./settings.schema.json",
+});
+```
+
+The module filename ends in `.config.ts`. Config objects should use `z.strictObject` or an explicitly open object schema. Rendering rejects unclosed object schemas, transforms, intersections, and values that JSON cannot represent. It also rejects schema types that JSON Schema cannot represent: dates, bigints, maps, functions, and `undefined`.
+
+## Render and check
+
+```sh
+tabit [--check] [
...]
+```
+
+The command searches the current directory recursively for config modules. It skips dot directories and `node_modules`, and selects files with runtime imports from this package; type-only imports and exports do not count. Pass directories to limit the search. `tabit` writes JSON and optional JSON Schema files beside each module. `tabit --check` compares the expected bytes without writing.
+
+The exit code is `0` when rendering succeeds or every output is current, `1` for an invalid config or stale output, and `2` for invalid command usage.
+
+## Tests
+
+Import `expectCommitted` and `renderForSnapshot` from `@rigelbuild/tabit/testing` to check rendered files or collect output for snapshots. Paths may be absolute or resolved against the caller's current working directory. `expectCommitted` reports missing output files with their paths.
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..aac27a4
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
+ "root": true,
+ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
+ "files": {
+ "includes": [
+ "**",
+ "!bun.lock",
+ "!rumdl",
+ "!fixtures/rendered.json",
+ "!src/**/*.rendered.json"
+ ]
+ },
+ "formatter": { "indentStyle": "tab" },
+ "linter": {
+ "enabled": true,
+ "rules": { "preset": "recommended" }
+ }
+}
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..b312dcc
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,33 @@
+{
+ "lockfileVersion": 2,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "@rigelbuild/tabit",
+ "devDependencies": {
+ "@rigelbuild/ref-gate": "^1.0.0",
+ "@types/bun": "1.4.0",
+ "typescript": "6.0.3",
+ "zod": "4.6.5",
+ },
+ "peerDependencies": {
+ "zod": "^4.4.3",
+ },
+ },
+ },
+ "packages": {
+ "@rigelbuild/ref-gate": ["@rigelbuild/ref-gate@1.0.0", "", { "bin": { "ref-gate": "index.ts" } }, "sha512-ZWq24B8dYrCzC0O/QjLIFL9PhbBSjaDdJ6ZOvYyVk2IzY+icK9jDdwAgne9Ylg7UqgA2ywgUjWuBtbr7kko/tg=="],
+
+ "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
+
+ "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="],
+
+ "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
+
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+
+ "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+
+ "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
+ }
+}
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000..20578c4
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,3 @@
+[install]
+minimumReleaseAge = 432000
+minimumReleaseAgeExcludes = ["@types/bun", "bun-types", "@rigelbuild/ref-gate"]
diff --git a/devenv-module.nix b/devenv-module.nix
new file mode 100644
index 0000000..fff2240
--- /dev/null
+++ b/devenv-module.nix
@@ -0,0 +1,9 @@
+{ toolchain }:
+{ pkgs, ... }:
+let
+ exports = toolchain.${pkgs.stdenv.hostPlatform.system};
+in
+{
+ packages = [ exports.rumdl exports.biome ];
+ env.RUMDL_BASE_CONFIG = "${exports."rumdl-base-config"}/rumdl/base.toml";
+}
diff --git a/devenv.lock b/devenv.lock
new file mode 100644
index 0000000..20cf7d3
--- /dev/null
+++ b/devenv.lock
@@ -0,0 +1,65 @@
+{
+ "nodes": {
+ "devenv": {
+ "locked": {
+ "dir": "src/modules",
+ "lastModified": 1790701375,
+ "narHash": "sha256-0FpLHIRDYiyo3EMTnMQSobZQknZaa3JuWo6vmMCK76o=",
+ "owner": "cachix",
+ "repo": "devenv",
+ "rev": "fe20b5cba7ab5e93ae73f956a8d3efc50e1753f4",
+ "type": "github"
+ },
+ "original": {
+ "dir": "src/modules",
+ "owner": "cachix",
+ "repo": "devenv",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "inputs": {
+ "nixpkgs-src": "nixpkgs-src"
+ },
+ "locked": {
+ "lastModified": 1789506231,
+ "narHash": "sha256-vNkJbtmqyBfFhn/HnzQo4ROuoyYWuy/allqTfJb4kMY=",
+ "owner": "cachix",
+ "repo": "devenv-nixpkgs",
+ "rev": "c2f38fe7f9e04d9aadd354d380f2bd40531d9737",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "rolling",
+ "repo": "devenv-nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs-src": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1789370336,
+ "narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "c7def046b9a883d46974757852106483d741586f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "devenv": "devenv",
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
\ No newline at end of file
diff --git a/devenv.nix b/devenv.nix
new file mode 100644
index 0000000..cf5b9cd
--- /dev/null
+++ b/devenv.nix
@@ -0,0 +1,10 @@
+{ pkgs, ... }:
+let
+ flake = builtins.getFlake (toString ./.);
+ packages = flake.packages.${pkgs.stdenv.hostPlatform.system};
+in
+{
+ imports = [ flake.devenvModules.default ];
+ packages = [ pkgs.bun pkgs.nodejs pkgs.moon ];
+ env.RUMDL_BIN = "${packages.rumdl}/bin/rumdl";
+}
diff --git a/devenv.yaml b/devenv.yaml
new file mode 100644
index 0000000..6bf1e6c
--- /dev/null
+++ b/devenv.yaml
@@ -0,0 +1,3 @@
+inputs:
+ nixpkgs:
+ url: github:cachix/devenv-nixpkgs/rolling
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..f74baa6
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,47 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "inputs": {
+ "nixpkgs-src": "nixpkgs-src"
+ },
+ "locked": {
+ "lastModified": 1789506231,
+ "narHash": "sha256-vNkJbtmqyBfFhn/HnzQo4ROuoyYWuy/allqTfJb4kMY=",
+ "owner": "cachix",
+ "repo": "devenv-nixpkgs",
+ "rev": "c2f38fe7f9e04d9aadd354d380f2bd40531d9737",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "ref": "rolling",
+ "repo": "devenv-nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs-src": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1789370336,
+ "narHash": "sha256-6RSEDHIWQtesQKWSu5qRai8L2h4KgCgMEfJHstW99G4=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "c7def046b9a883d46974757852106483d741586f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..7b4f871
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,54 @@
+{
+ description = "Typed TypeScript configuration rendered to deterministic JSON";
+
+ inputs.nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
+
+ outputs =
+ { self, nixpkgs }:
+ let
+ systems = [
+ "x86_64-linux"
+ "aarch64-linux"
+ "x86_64-darwin"
+ "aarch64-darwin"
+ ];
+ forAllSystems = f:
+ builtins.listToAttrs (map (system: {
+ name = system;
+ value = f system;
+ }) systems);
+ packagesFor = system:
+ let pkgs = nixpkgs.legacyPackages.${system};
+ in {
+ rumdl = pkgs.rumdl;
+ biome = pkgs.biome;
+ "rumdl-base-config" = import ./rumdl-base-config.nix { inherit pkgs; };
+ };
+ moduleCheck = system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ packageOutputs = self.packages.${system};
+ module = self.devenvModules.default { inherit pkgs; };
+ expectedPackages = [ packageOutputs.rumdl packageOutputs.biome ];
+ hasExpectedPackages =
+ builtins.length module.packages == builtins.length expectedPackages
+ && builtins.all (package: builtins.elem package module.packages) expectedPackages;
+ in
+ if !hasExpectedPackages then
+ throw "devenvModules.default must add only the exported rumdl and biome packages"
+ else
+ pkgs.runCommand "tabit-devenv-module-check" { } ''
+ test -f "${module.env.RUMDL_BASE_CONFIG}"
+ touch "$out"
+ '';
+ in
+ {
+ packages = forAllSystems packagesFor;
+ devenvModules.default = import ./devenv-module.nix {
+ toolchain = self.packages;
+ };
+ checks = forAllSystems (system: {
+ devenv-module = moduleCheck system;
+ });
+ };
+}
diff --git a/moon.yml b/moon.yml
new file mode 100644
index 0000000..1064b45
--- /dev/null
+++ b/moon.yml
@@ -0,0 +1,47 @@
+# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json
+layer: 'library'
+language: 'typescript'
+tags: ['bun']
+
+tasks:
+ install:
+ command: 'bun install --frozen-lockfile'
+ inputs: ['/package.json', '/bun.lock', '/bunfig.toml']
+ options:
+ internal: true
+ cache: false
+ runFromWorkspaceRoot: true
+
+ lint:
+ command: 'biome check .'
+ deps: ['install']
+ options:
+ cache: false
+ runFromWorkspaceRoot: true
+
+ markdownlint:
+ command: 'rumdl check .'
+ deps: ['install']
+ options:
+ cache: false
+ runFromWorkspaceRoot: true
+
+ refgate:
+ command: 'bun node_modules/@rigelbuild/ref-gate/index.ts --config ref-gate.config.json'
+ deps: ['install']
+ options:
+ cache: false
+ runFromWorkspaceRoot: true
+
+ typecheck:
+ command: 'bun run tsc --noEmit -p .'
+ deps: ['install']
+
+ test:
+ command: 'bun test'
+ deps: ['install']
+
+ ci:
+ deps: ['lint', 'markdownlint', 'refgate', 'typecheck', 'test']
+ options:
+ cache: false
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8d397b7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@rigelbuild/tabit",
+ "version": "0.1.0",
+ "type": "module",
+ "license": "MIT OR Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/RigelBuild/tabit.git"
+ },
+ "bin": {
+ "tabit": "./src/cli.ts"
+ },
+ "exports": {
+ ".": "./src/index.ts",
+ "./testing": "./src/testing.ts"
+ },
+ "files": [
+ "package.json",
+ "src",
+ "README.md",
+ "LICENSE-MIT",
+ "LICENSE-APACHE"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "peerDependencies": {
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@rigelbuild/ref-gate": "^1.0.0",
+ "@types/bun": "1.4.0",
+ "typescript": "6.0.3",
+ "zod": "4.6.5"
+ }
+}
diff --git a/ref-gate.config.json b/ref-gate.config.json
new file mode 100644
index 0000000..4d2b48a
--- /dev/null
+++ b/ref-gate.config.json
@@ -0,0 +1,25 @@
+{
+ "prefilter": {
+ "ere": "sealed|SEA-[0-9]",
+ "ignoreCase": true
+ },
+ "patterns": [
+ { "source": "\\bsealed/[a-z]", "flags": "i" },
+ {
+ "source": "\\bsealed\\s+[\\w.-]+(?:/[\\w.-]+)*\\.(?:ts|tsx|js|jsx|go|nix|md|json5?|ya?ml|toml|sh|py|rs|lock)\\b",
+ "flags": "i"
+ },
+ { "source": "\\bsealed-(?:repo|monorepo|docs|private)\\b", "flags": "i" },
+ { "source": "\\bsealed's\\b", "flags": "i" },
+ {
+ "source": "\\bsealed (repo|monorepo|design corpus|platform|convention|PR #)",
+ "flags": "i"
+ },
+ { "source": "\\bSEA-\\d+\\b", "flags": "" }
+ ],
+ "ignore": [],
+ "carveOutPaths": ["ref-gate.config.json", "bun.lock"],
+ "carveOutPrefixes": [],
+ "allowlist": {},
+ "remediationDoc": "README.md"
+}
diff --git a/rumdl-base-config.nix b/rumdl-base-config.nix
new file mode 100644
index 0000000..d184911
--- /dev/null
+++ b/rumdl-base-config.nix
@@ -0,0 +1,5 @@
+{ pkgs }:
+pkgs.runCommand "rumdl-base-config" { } ''
+ mkdir -p "$out/rumdl"
+ cp ${./rumdl/base.toml} "$out/rumdl/base.toml"
+''
diff --git a/rumdl/base.toml b/rumdl/base.toml
new file mode 100644
index 0000000..bbeeeed
--- /dev/null
+++ b/rumdl/base.toml
@@ -0,0 +1,21 @@
+# Shared rules keep markdown behavior aligned across consuming repositories.
+[MD013]
+enabled = false
+
+[MD046]
+style = "fenced"
+
+[MD032]
+enabled = false
+
+[MD076]
+enabled = false
+
+[MD077]
+enabled = false
+
+[MD075]
+enabled = false
+
+[MD049]
+enabled = false
diff --git a/src/cli.test.ts b/src/cli.test.ts
new file mode 100644
index 0000000..1c80013
--- /dev/null
+++ b/src/cli.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, describe, expect, spyOn, test } from "bun:test";
+import {
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ symlink,
+ writeFile,
+} from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { runCli } from "./cli.ts";
+
+const directories: string[] = [];
+afterEach(async () => {
+ await Promise.all(
+ directories
+ .splice(0)
+ .map((directory) => rm(directory, { recursive: true, force: true })),
+ );
+});
+
+async function temporaryDirectory() {
+ const directory = await mkdtemp(join(tmpdir(), "tabit-cli-"));
+ directories.push(directory);
+ const scope = join(directory, "node_modules", "@rigelbuild");
+ await mkdir(scope, { recursive: true });
+
+ await symlink(process.cwd(), join(scope, "tabit"));
+ await symlink(
+ join(process.cwd(), "node_modules", "zod"),
+ join(directory, "node_modules", "zod"),
+ );
+ return directory;
+}
+
+const moduleSource =
+ "import { defineConfig } from '@rigelbuild/tabit';\nimport { z } from 'zod';\nexport default defineConfig(z.strictObject({ name: z.string() }), () => ({ name: 'sample' }), { out: './result.json' });\n";
+
+describe("CLI", () => {
+ test("check reports an existing stale output without changing its bytes", async () => {
+ const directory = await temporaryDirectory();
+ const output = join(directory, "result.json");
+ await writeFile(output, "stale bytes\n");
+ await writeFile(join(directory, "sample.config.ts"), moduleSource);
+ const error = spyOn(console, "error").mockImplementation(() => {});
+ try {
+ expect(await runCli(["--check", directory])).toBe(1);
+ expect(error).toHaveBeenCalledWith(
+ `${output} is stale; run tabit to update it.`,
+ );
+ expect(await readFile(output, "utf8")).toBe("stale bytes\n");
+ } finally {
+ error.mockRestore();
+ }
+ });
+
+ test("check reports a missing output and leaves it absent", async () => {
+ const directory = await temporaryDirectory();
+ await writeFile(
+ join(directory, "sample.config.ts"),
+ moduleSource.replace("./result.json", "./missing.json"),
+ );
+ expect(await runCli(["--check", directory])).toBe(1);
+ expect(await Bun.file(join(directory, "missing.json")).exists()).toBe(
+ false,
+ );
+ });
+
+ test("render writes output and check passes only while bytes match", async () => {
+ const directory = await temporaryDirectory();
+ const output = join(directory, "result.json");
+ await writeFile(join(directory, "sample.config.ts"), moduleSource);
+ expect(await runCli([directory])).toBe(0);
+ expect(await runCli(["--check", directory])).toBe(0);
+ await writeFile(output, "edited bytes\n");
+ expect(await runCli(["--check", directory])).toBe(1);
+ expect(await readFile(output, "utf8")).toBe("edited bytes\n");
+ });
+
+ test("returns 1 when a matching module does not default-export a config", async () => {
+ const directory = await temporaryDirectory();
+ await writeFile(
+ join(directory, "invalid.config.ts"),
+ "import {} from '@rigelbuild/tabit'; export default {};\n",
+ );
+ expect(await runCli([directory])).toBe(1);
+ });
+ test("does not load files with only a type-only package reference", async () => {
+ const directory = await temporaryDirectory();
+ await writeFile(
+ join(directory, "types.config.ts"),
+ "export type { ConfigModule } from '@rigelbuild/tabit';\n",
+ );
+ expect(await runCli([directory])).toBe(0);
+ });
+
+ test("usage errors return 2 and print tabit usage", async () => {
+ const error = spyOn(console, "error").mockImplementation(() => {});
+ try {
+ expect(await runCli(["--unknown"])).toBe(2);
+ expect(error).toHaveBeenCalledWith("Usage: tabit [--check] [...]");
+ } finally {
+ error.mockRestore();
+ }
+ });
+});
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..e807e75
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,82 @@
+#!/usr/bin/env bun
+import { readFile, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { isConfigModule } from "./define.ts";
+import { findModules } from "./discover.ts";
+import { renderModule } from "./render.ts";
+
+function usage(): string {
+ return "Usage: tabit [--check] [...]";
+}
+
+export async function runCli(args: readonly string[]): Promise {
+ let check = false;
+ const directories: string[] = [];
+ for (const arg of args) {
+ if (arg === "--check" && !check) {
+ check = true;
+ continue;
+ }
+ if (arg.startsWith("-")) {
+ console.error(usage());
+ return 2;
+ }
+ directories.push(resolve(arg));
+ }
+ let modules: string[];
+ try {
+ modules = await findModules(
+ directories.length > 0 ? directories : [process.cwd()],
+ );
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ return 1;
+ }
+ let stale = false;
+ for (const modulePath of modules) {
+ try {
+ const loaded: unknown = await import(pathToFileURL(modulePath).href);
+ if (
+ loaded === null ||
+ typeof loaded !== "object" ||
+ !("default" in loaded) ||
+ !isConfigModule(loaded.default)
+ ) {
+ throw new Error(
+ "Config module must default-export a value created by defineConfig",
+ );
+ }
+ for (const file of renderModule(loaded.default, dirname(modulePath))) {
+ let current: string | undefined;
+ try {
+ current = await readFile(file.path, "utf8");
+ } catch (error) {
+ if (
+ !(error instanceof Error) ||
+ !("code" in error) ||
+ error.code !== "ENOENT"
+ ) {
+ throw error;
+ }
+ }
+ if (check) {
+ if (current !== file.contents) {
+ console.error(`${file.path} is stale; run tabit to update it.`);
+ stale = true;
+ }
+ } else {
+ await writeFile(file.path, file.contents, "utf8");
+ }
+ }
+ } catch (error) {
+ console.error(
+ `${modulePath}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ stale = true;
+ }
+ }
+ return stale ? 1 : 0;
+}
+
+if (import.meta.main) process.exitCode = await runCli(process.argv.slice(2));
diff --git a/src/closed.test.ts b/src/closed.test.ts
new file mode 100644
index 0000000..ffb71c0
--- /dev/null
+++ b/src/closed.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, test } from "bun:test";
+import { z } from "zod";
+import { validateClosedSchema } from "./closed.ts";
+import { defineConfig } from "./define.ts";
+import { renderModule } from "./render.ts";
+
+function render(schema: z.ZodType, value: unknown, schemaOut = false) {
+ const target = schemaOut
+ ? { out: "result.json", schemaOut: "result.schema.json" }
+ : { out: "result.json" };
+ return renderModule(
+ defineConfig(schema, () => value, target),
+ "/tmp",
+ );
+}
+
+describe("closed schemas", () => {
+ test("rejects a misspelled key with the Zod issue", () => {
+ expect(() =>
+ render(z.strictObject({ name: z.string() }), { name: "a", nam: "b" }),
+ ).toThrow("Unrecognized key");
+ });
+
+ const plainObject = z.object({ value: z.string() });
+
+ test.each([
+ ["top-level", plainObject, "$"],
+ [
+ "record value",
+ z.record(z.string(), plainObject),
+ "/additionalProperties",
+ ],
+ [
+ "catchall",
+ z.strictObject({}).catchall(plainObject),
+ "/additionalProperties",
+ ],
+ ["tuple entry", z.tuple([plainObject]), "/prefixItems/0"],
+ ["tuple rest", z.tuple([]).rest(plainObject), "/items"],
+ ["anyOf member", z.union([plainObject, z.string()]), "/anyOf/0"],
+ ])(
+ "rejects a plain object schema at %s and reports %s",
+ (_name, schema, path) => {
+ expect(() => render(schema, {})).toThrow(path);
+ },
+ );
+ test("escapes slashes in unrepresentable schema property paths", () => {
+ const schema = z.strictObject({ "x/y": z.date().optional() });
+ expect(() => validateClosedSchema(schema, "input")).toThrow(
+ "/properties/x~1y",
+ );
+ });
+
+ test("escapes slashes in open schema property paths", () => {
+ const schema = z.strictObject({ "x/y": z.object({ value: z.string() }) });
+ expect(() => validateClosedSchema(schema, "input")).toThrow(
+ "/properties/x~1y",
+ );
+ });
+
+ test("escapes both slash and tilde in schema property segments", () => {
+ const schema = z.strictObject({ "x/y~z": z.date().optional() });
+ expect(() => validateClosedSchema(schema, "input")).toThrow(
+ "/properties/x~1y~0z",
+ );
+ });
+
+ test("rejects a plain object reachable only through a recursive $ref definition", () => {
+ let recursive: z.ZodType;
+ recursive = z.lazy(() =>
+ z.strictObject({ inner: plainObject, next: recursive.optional() }),
+ );
+ const schema = z.strictObject({ root: recursive, other: recursive });
+ expect(() =>
+ render(schema, {
+ root: { inner: { value: "x" } },
+ other: { inner: { value: "y" } },
+ }),
+ ).toThrow("/$defs/__schema0/properties/inner");
+ });
+});
diff --git a/src/closed.ts b/src/closed.ts
new file mode 100644
index 0000000..797d417
--- /dev/null
+++ b/src/closed.ts
@@ -0,0 +1,111 @@
+import { z } from "zod";
+import { RenderError } from "./render.ts";
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function escapePointerSegment(segment: string): string {
+ return segment.replaceAll("~", "~0").replaceAll("/", "~1");
+}
+
+function formatPointer(path: readonly PropertyKey[]): string {
+ return `/${path.map((part) => escapePointerSegment(String(part))).join("/")}`;
+}
+
+export function openObjectPaths(jsonSchema: unknown): string[] {
+ if (!isRecord(jsonSchema)) return [];
+ const definitions = isRecord(jsonSchema.$defs) ? jsonSchema.$defs : {};
+ const seenReferences = new Set();
+ const paths: string[] = [];
+ const visit = (node: unknown, path: readonly PropertyKey[]): void => {
+ if (!isRecord(node)) return;
+ if (node.type === "object" && !("additionalProperties" in node))
+ paths.push(path.length === 0 ? "$" : formatPointer(path));
+ if (typeof node.$ref === "string") {
+ const prefix = "#/$defs/";
+ if (node.$ref.startsWith(prefix)) {
+ const key = node.$ref
+ .slice(prefix.length)
+ .replaceAll("~1", "/")
+ .replaceAll("~0", "~");
+ if (!seenReferences.has(key) && key in definitions) {
+ seenReferences.add(key);
+ visit(definitions[key], [...path, "$defs", key]);
+ }
+ }
+ }
+ for (const key of ["properties", "$defs"]) {
+ const children = node[key];
+ if (isRecord(children)) {
+ for (const [name, child] of Object.entries(children))
+ visit(child, [...path, key, name]);
+ }
+ }
+ for (const key of ["additionalProperties", "items"]) {
+ const child = node[key];
+ if (isRecord(child)) visit(child, [...path, key]);
+ }
+ for (const key of ["prefixItems", "anyOf", "oneOf", "allOf"]) {
+ const children = node[key];
+ if (Array.isArray(children)) {
+ children.forEach((child, index) => {
+ visit(child, [...path, key, index]);
+ });
+ }
+ }
+ };
+ visit(jsonSchema, []);
+ return paths;
+}
+
+export function validateClosedSchema(
+ schema: z.ZodType,
+ io: "input" | "output",
+): unknown {
+ const document = z.toJSONSchema(schema, {
+ io,
+ unrepresentable: ({ zodSchema, path, message }) => {
+ const type = zodSchema._zod.def.type;
+ if (
+ type === "transform" ||
+ (type === "pipe" &&
+ zodSchema._zod.def.out._zod.def.type === "transform")
+ ) {
+ throw new RenderError(
+ "Transform schemas are not supported",
+ formatPointer(path),
+ );
+ }
+ throw new RenderError(message, formatPointer(path));
+ },
+ override: ({ zodSchema, jsonSchema: current, path }) => {
+ const type = zodSchema._zod.def.type;
+ if (type === "intersection") {
+ throw new RenderError(
+ "Intersection schemas are not supported",
+ formatPointer(path),
+ );
+ }
+ if (
+ type === "transform" ||
+ (type === "pipe" &&
+ zodSchema._zod.def.out._zod.def.type === "transform")
+ ) {
+ throw new RenderError(
+ "Transform schemas are not supported",
+ formatPointer(path),
+ );
+ }
+ return current;
+ },
+ });
+ const openPaths = openObjectPaths(document);
+ const openPath = openPaths[0];
+ if (openPath !== undefined)
+ throw new RenderError(
+ "Object schemas must reject or describe additional properties",
+ openPath,
+ );
+ return document;
+}
diff --git a/src/define.ts b/src/define.ts
new file mode 100644
index 0000000..fc5e680
--- /dev/null
+++ b/src/define.ts
@@ -0,0 +1,64 @@
+import type { z } from "zod";
+
+export const MODULE_BRAND = Symbol.for("@rigelbuild/tabit.module");
+
+export interface RenderTarget {
+ out: string;
+ schemaOut?: string;
+}
+
+export interface ConfigModule {
+ readonly [MODULE_BRAND]: true;
+ readonly schema: S;
+ readonly build: () => z.input;
+ readonly target: RenderTarget;
+}
+
+export function defineConfig(
+ schema: S,
+ build: () => z.input,
+ target: RenderTarget,
+): ConfigModule {
+ if (!target.out.endsWith(".json")) {
+ throw new TypeError("Config output path must end in .json");
+ }
+ if (target.schemaOut !== undefined && !target.schemaOut.endsWith(".json")) {
+ throw new TypeError("Config schema output path must end in .json");
+ }
+ return { [MODULE_BRAND]: true, schema, build, target };
+}
+
+export function isConfigModule(
+ value: unknown,
+): value is ConfigModule {
+ if (value === null || typeof value !== "object") return false;
+ const brand = Object.getOwnPropertyDescriptor(value, MODULE_BRAND);
+ if (
+ brand?.value !== true ||
+ !("schema" in value) ||
+ !("build" in value) ||
+ !("target" in value)
+ ) {
+ return false;
+ }
+ const candidate = value.schema;
+ const target = value.target;
+ if (
+ typeof candidate !== "object" ||
+ candidate === null ||
+ !("_zod" in candidate) ||
+ typeof value.build !== "function" ||
+ target === null ||
+ typeof target !== "object" ||
+ !("out" in target) ||
+ typeof target.out !== "string" ||
+ !target.out.endsWith(".json")
+ ) {
+ return false;
+ }
+ return (
+ !("schemaOut" in target) ||
+ target.schemaOut === undefined ||
+ (typeof target.schemaOut === "string" && target.schemaOut.endsWith(".json"))
+ );
+}
diff --git a/src/discover.test.ts b/src/discover.test.ts
new file mode 100644
index 0000000..acdf186
--- /dev/null
+++ b/src/discover.test.ts
@@ -0,0 +1,142 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { z } from "zod";
+import { isConfigModule } from "./define.ts";
+import { findModules } from "./discover.ts";
+
+const directories: string[] = [];
+afterEach(async () => {
+ await Promise.all(
+ directories
+ .splice(0)
+ .map((directory) => rm(directory, { recursive: true, force: true })),
+ );
+});
+
+async function temporaryDirectory() {
+ const directory = await mkdtemp(join(tmpdir(), "tabit-discovery-"));
+ directories.push(directory);
+ return directory;
+}
+
+describe("module discovery", () => {
+ test("skips config files that do not import the library", async () => {
+ const directory = await temporaryDirectory();
+ await writeFile(join(directory, "plain.config.ts"), "export default {};\n");
+ await writeFile(
+ join(directory, "tool.config.ts"),
+ "import 'node:assert'; export default {};\n",
+ );
+ const nested = join(directory, ".hidden");
+ await mkdir(nested);
+ await writeFile(
+ join(nested, "hidden.config.ts"),
+ "import '@rigelbuild/tabit';\n",
+ );
+ expect(await findModules([directory])).toEqual([]);
+ });
+ test("selects imports separated by comments but ignores type-only imports and comments", async () => {
+ const directory = await temporaryDirectory();
+ await writeFile(
+ join(directory, "commented.config.ts"),
+ "import { /* note */ defineConfig } from /* note */ '@rigelbuild/tabit';\n",
+ );
+ await writeFile(
+ join(directory, "type-only.config.ts"),
+ "import type { ConfigModule } from '@rigelbuild/tabit';\n",
+ );
+ await writeFile(
+ join(directory, "string.config.ts"),
+ "const note = \"import { defineConfig } from '@rigelbuild/tabit'\";\n",
+ );
+ expect(await findModules([directory])).toEqual([
+ join(directory, "commented.config.ts"),
+ ]);
+ });
+ test("ignores named imports whose bindings are all type-only", async () => {
+ const directory = await temporaryDirectory();
+ await writeFile(
+ join(directory, "type-only-named.config.ts"),
+ "import { type ConfigModule } from '@rigelbuild/tabit';\n",
+ );
+ expect(await findModules([directory])).toEqual([]);
+ });
+
+ test("discovers module files reached through symlinks", async () => {
+ const directory = await temporaryDirectory();
+ const target = join(directory, "target");
+ await mkdir(target);
+ const source = join(target, "linked.config.ts");
+ await writeFile(
+ source,
+ "import { defineConfig } from '@rigelbuild/tabit';\n",
+ );
+ await symlink(source, join(directory, "alias.config.ts"));
+ expect(await findModules([directory])).toEqual([
+ join(directory, "alias.config.ts"),
+ source,
+ ]);
+ });
+
+ test("stops directory symlink cycles while finding ordinary modules", async () => {
+ const directory = await temporaryDirectory();
+ await writeFile(
+ join(directory, "normal.config.ts"),
+ "import '@rigelbuild/tabit';\n",
+ );
+ await symlink(directory, join(directory, "cycle"));
+ expect(await findModules([directory])).toEqual([
+ join(directory, "normal.config.ts"),
+ ]);
+ });
+
+ test("ignores import types and type re-exports but accepts side-effect imports", async () => {
+ const directory = await temporaryDirectory();
+ await writeFile(
+ join(directory, "import-type.config.ts"),
+ "type Config = import('@rigelbuild/tabit').ConfigModule;\n",
+ );
+ await writeFile(
+ join(directory, "type-reexport.config.ts"),
+ "export type { ConfigModule } from '@rigelbuild/tabit';\n",
+ );
+ await writeFile(
+ join(directory, "side-effect.config.ts"),
+ "import '@rigelbuild/tabit';\n",
+ );
+ expect(await findModules([directory])).toEqual([
+ join(directory, "side-effect.config.ts"),
+ ]);
+ });
+
+ test("hidden-directory fixture contains a real named package import", async () => {
+ const directory = await temporaryDirectory();
+ const nested = join(directory, ".hidden");
+ await mkdir(nested);
+ await writeFile(
+ join(nested, "hidden.config.ts"),
+ "import { defineConfig } from '@rigelbuild/tabit';\nexport default defineConfig;\n",
+ );
+ expect(await findModules([directory])).toEqual([]);
+ });
+
+ test("rejects malformed branded modules and invalid target suffixes", () => {
+ const otherBrand = Symbol.for("@rigelbuild/tabit.module");
+ const schema = z.strictObject({ value: z.string() });
+ const base = {
+ [otherBrand]: true,
+ schema,
+ build: () => ({ value: "x" }),
+ };
+ expect(isConfigModule({ ...base, target: {} })).toBe(false);
+ expect(isConfigModule({ ...base, target: { out: "a.txt" } })).toBe(false);
+ expect(
+ isConfigModule({
+ ...base,
+ target: { out: "a.json", schemaOut: "b.txt" },
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/src/discover.ts b/src/discover.ts
new file mode 100644
index 0000000..a319f21
--- /dev/null
+++ b/src/discover.ts
@@ -0,0 +1,143 @@
+import { readdir, readFile, realpath, stat } from "node:fs/promises";
+import { resolve } from "node:path";
+
+async function visit(
+ directory: string,
+ files: string[],
+ visited: Set,
+): Promise {
+ const realDirectory = await realpath(directory);
+ if (visited.has(realDirectory)) return;
+ visited.add(realDirectory);
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
+ const path = resolve(directory, entry.name);
+ if (entry.isDirectory()) await visit(path, files, visited);
+ else if (entry.isSymbolicLink()) {
+ try {
+ const target = await stat(path);
+ if (target.isDirectory()) await visit(path, files, visited);
+ else if (target.isFile() && entry.name.endsWith(".config.ts"))
+ files.push(path);
+ } catch (error) {
+ if (
+ !(error instanceof Error) ||
+ !("code" in error) ||
+ error.code !== "ELOOP"
+ )
+ throw error;
+ }
+ } else if (entry.isFile() && entry.name.endsWith(".config.ts")) {
+ files.push(path);
+ }
+ }
+}
+
+function tokens(source: string): string[] {
+ const result: string[] = [];
+ let index = 0;
+ while (index < source.length) {
+ const char = source[index];
+ const next = source[index + 1];
+ if (char === "'" || char === '"' || char === "`") {
+ const quote = char;
+ let text = "";
+ index += 1;
+ while (index < source.length && source[index] !== quote) {
+ if (source[index] === "\\") index += 1;
+ if (index < source.length) text += source[index];
+ index += 1;
+ }
+ index += 1;
+ result.push(`string:${text}`);
+ continue;
+ }
+ if (char === "/" && next === "/") {
+ index += 2;
+ while (index < source.length && source[index] !== "\n") index += 1;
+ continue;
+ }
+ if (char === "/" && next === "*") {
+ index += 2;
+ while (
+ index < source.length &&
+ !(source[index] === "*" && source[index + 1] === "/")
+ )
+ index += 1;
+ index += 2;
+ continue;
+ }
+ if (char !== undefined && /[A-Za-z_$]/.test(char)) {
+ let word = char;
+ index += 1;
+ while (index < source.length && /[\w$]/.test(source[index] ?? "")) {
+ word += source[index];
+ index += 1;
+ }
+ result.push(word);
+ continue;
+ }
+ if (char !== undefined && !/\s/.test(char)) result.push(char);
+ index += 1;
+ }
+ return result;
+}
+
+function matchesPackage(token: string | undefined): boolean {
+ if (!token?.startsWith("string:")) return false;
+ const specifier = token.slice("string:".length);
+ return (
+ specifier === "@rigelbuild/tabit" ||
+ specifier.startsWith("@rigelbuild/tabit/")
+ );
+}
+
+const transpiler = new Bun.Transpiler({ loader: "ts" });
+
+function importsRenderer(source: string): boolean {
+ const input = tokens(transpiler.transformSync(source));
+ for (let index = 0; index < input.length; index += 1) {
+ if (input[index] === "import") {
+ let cursor = index + 1;
+ if (input[cursor] === "(") cursor += 1;
+ else if (input[cursor]?.startsWith("string:")) {
+ if (matchesPackage(input[cursor])) return true;
+ continue;
+ } else {
+ while (
+ cursor < input.length &&
+ input[cursor] !== "from" &&
+ input[cursor] !== ";"
+ )
+ cursor += 1;
+ if (input[cursor] === "from") cursor += 1;
+ }
+ if (matchesPackage(input[cursor])) return true;
+ }
+ if (input[index] === "export") {
+ let cursor = index + 1;
+ while (
+ cursor < input.length &&
+ input[cursor] !== "from" &&
+ input[cursor] !== ";"
+ )
+ cursor += 1;
+ if (input[cursor] === "from" && matchesPackage(input[cursor + 1]))
+ return true;
+ }
+ }
+ return false;
+}
+
+export async function findModules(dirs: readonly string[]): Promise {
+ const files: string[] = [];
+ const visited = new Set();
+ for (const dir of dirs) await visit(resolve(dir), files, visited);
+ const candidates = await Promise.all(
+ files.map(async (file) => ({ file, source: await readFile(file, "utf8") })),
+ );
+ return candidates
+ .filter(({ source }) => importsRenderer(source))
+ .map(({ file }) => file)
+ .sort();
+}
diff --git a/src/fixture.rendered.json b/src/fixture.rendered.json
new file mode 100644
index 0000000..5da8b90
--- /dev/null
+++ b/src/fixture.rendered.json
@@ -0,0 +1,6 @@
+{
+ "values": [
+ "short",
+ "items"
+ ]
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..d181421
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,7 @@
+export { openObjectPaths } from "./closed.ts";
+export type { ConfigModule, RenderTarget } from "./define.ts";
+export { defineConfig, isConfigModule, MODULE_BRAND } from "./define.ts";
+export { findModules } from "./discover.ts";
+export { orderUnnamedKeys } from "./order.ts";
+export type { RenderedFile } from "./render.ts";
+export { RenderError, renderModule } from "./render.ts";
diff --git a/src/order.ts b/src/order.ts
new file mode 100644
index 0000000..27be58e
--- /dev/null
+++ b/src/order.ts
@@ -0,0 +1,248 @@
+import { z } from "zod";
+import { formatIssuePath, RenderError } from "./render.ts";
+
+type InternalSchema = z.ZodType & { _zod: { def: { type: string } } };
+
+function orderValue(
+ value: unknown,
+ schema: z.ZodType,
+ path: readonly PropertyKey[],
+ active: WeakSet,
+ unionInput: unknown,
+ objectInput: unknown,
+): unknown {
+ const current = schema as InternalSchema;
+ const definition = current._zod.def;
+ if (definition.type === "lazy") {
+ const lazy = current as typeof current & {
+ _zod: { def: { getter: () => z.ZodType } };
+ };
+ return orderValue(
+ value,
+ lazy._zod.def.getter(),
+ path,
+ active,
+ unionInput,
+ objectInput,
+ );
+ }
+ if (definition.type === "pipe") {
+ const pipe = current as typeof current & {
+ _zod: { def: { in: z.ZodType; out: z.ZodType } };
+ };
+ const parsedInput = pipe._zod.def.in.safeParse(unionInput);
+ const innerInput = parsedInput.success ? parsedInput.data : unionInput;
+ return orderValue(
+ value,
+ pipe._zod.def.out,
+ path,
+ active,
+ innerInput,
+ innerInput,
+ );
+ }
+ if (
+ [
+ "optional",
+ "nonoptional",
+ "nullable",
+ "default",
+ "prefault",
+ "readonly",
+ "catch",
+ ].includes(definition.type)
+ ) {
+ const wrapped = current as typeof current & {
+ _zod: { def: { innerType: z.ZodType } };
+ };
+ const hasDefault =
+ unionInput === undefined &&
+ (definition.type === "default" || definition.type === "prefault");
+ const innerType = wrapped._zod.def.innerType;
+ const wrappedInput = hasDefault
+ ? definition.type === "prefault"
+ ? (
+ current as typeof current & {
+ _zod: { def: { defaultValue: unknown } };
+ }
+ )._zod.def.defaultValue
+ : value
+ : unionInput;
+ return orderValue(
+ value,
+ innerType,
+ path,
+ active,
+ wrappedInput,
+ wrappedInput === unionInput ? objectInput : wrappedInput,
+ );
+ }
+ if (definition.type === "union") {
+ const union = current as typeof current & { options: readonly z.ZodType[] };
+ let option: z.ZodType | undefined;
+ let branchInput = unionInput;
+ for (const candidate of union.options) {
+ const parsed = candidate.safeParse(unionInput);
+ if (parsed.success) {
+ option = candidate;
+ branchInput = parsed.data;
+ break;
+ }
+ }
+ return orderValue(
+ value,
+ option ?? z.unknown(),
+ path,
+ active,
+ branchInput,
+ branchInput,
+ );
+ }
+ if (Array.isArray(value)) {
+ if (active.has(value))
+ throw new RenderError(
+ "Circular value is not JSON",
+ formatIssuePath(path),
+ );
+ active.add(value);
+ try {
+ const originalItems = Array.isArray(objectInput) ? objectInput : value;
+ if (definition.type === "array") {
+ const array = current as typeof current & { element: z.ZodType };
+ return Array.from(value, (item, index) =>
+ orderValue(
+ item,
+ array.element,
+ [...path, index],
+ active,
+ originalItems[index],
+ originalItems[index],
+ ),
+ );
+ }
+ if (definition.type === "tuple") {
+ const tuple = current as typeof current & {
+ _zod: {
+ def: { items: readonly z.ZodType[]; rest: z.ZodType | null };
+ };
+ };
+ const originalItems = Array.isArray(objectInput) ? objectInput : value;
+ return Array.from(value, (item, index) => {
+ const itemSchema = tuple._zod.def.items[index] ?? tuple._zod.def.rest;
+ return itemSchema === null || itemSchema === undefined
+ ? item
+ : orderValue(
+ item,
+ itemSchema,
+ [...path, index],
+ active,
+ originalItems[index],
+ originalItems[index],
+ );
+ });
+ }
+ if (definition.type === "unknown" || definition.type === "any")
+ return Array.from(value, (item, index) =>
+ orderValue(
+ item,
+ z.unknown(),
+ [...path, index],
+ active,
+ originalItems[index],
+ originalItems[index],
+ ),
+ );
+ return value;
+ } finally {
+ active.delete(value);
+ }
+ }
+ if (value === null || typeof value !== "object") return value;
+ if (
+ Object.getPrototypeOf(value) !== Object.prototype &&
+ Object.getPrototypeOf(value) !== null
+ )
+ return value;
+ if (active.has(value))
+ throw new RenderError("Circular value is not JSON", formatIssuePath(path));
+ active.add(value);
+ try {
+ const object = value as Record;
+ const ordered: Record = Object.create(null);
+ const put = (key: string, child: unknown, childSchema: z.ZodType) => {
+ if (child !== undefined) {
+ const originalChild =
+ objectInput !== null && typeof objectInput === "object"
+ ? Reflect.get(objectInput, key)
+ : child;
+ Object.defineProperty(ordered, key, {
+ value: orderValue(
+ child,
+ childSchema,
+ [...path, key],
+ active,
+ originalChild,
+ originalChild,
+ ),
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ });
+ }
+ };
+ const protoKey =
+ objectInput !== null && typeof objectInput === "object"
+ ? Object.getOwnPropertyDescriptor(objectInput, "__proto__")
+ : undefined;
+ if (protoKey?.enumerable && !Object.hasOwn(object, "__proto__")) {
+ Object.defineProperty(object, "__proto__", {
+ value: protoKey.value,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ });
+ }
+ if (definition.type === "unknown" || definition.type === "any") {
+ for (const key of Object.keys(object).sort())
+ put(key, object[key], z.unknown());
+ return ordered;
+ }
+ if (definition.type === "object") {
+ const objectSchema = current as typeof current & {
+ shape: Record;
+ _zod: { def: { catchall?: z.ZodType } };
+ };
+ for (const [key, childSchema] of Object.entries(objectSchema.shape))
+ put(key, object[key], childSchema);
+ const extraSchema = objectSchema._zod.def.catchall ?? z.unknown();
+ for (const key of Object.keys(object)
+ .filter((name) => !Object.hasOwn(objectSchema.shape, name))
+ .sort())
+ put(key, object[key], extraSchema);
+ return ordered;
+ }
+ if (definition.type === "record") {
+ const record = current as typeof current & { valueType: z.ZodType };
+ for (const key of Object.keys(object).sort())
+ put(key, object[key], record.valueType);
+ return ordered;
+ }
+ for (const key of Object.keys(object).sort())
+ put(key, object[key], z.unknown());
+ return ordered;
+ } finally {
+ active.delete(value);
+ }
+}
+
+export function orderParsedValue(
+ value: unknown,
+ schema: z.ZodType,
+ inputValue: unknown,
+): unknown {
+ return orderValue(value, schema, [], new WeakSet(), inputValue, inputValue);
+}
+
+export function orderUnnamedKeys(value: unknown, schema: z.ZodType): unknown {
+ return orderParsedValue(value, schema, value);
+}
diff --git a/src/render.test.ts b/src/render.test.ts
new file mode 100644
index 0000000..c809b09
--- /dev/null
+++ b/src/render.test.ts
@@ -0,0 +1,337 @@
+import { describe, expect, test } from "bun:test";
+import { z } from "zod";
+import { validateClosedSchema } from "./closed.ts";
+import { defineConfig } from "./define.ts";
+import { renderModule } from "./render.ts";
+
+function render(schema: z.ZodType, value: unknown, schemaOut = false) {
+ const target = schemaOut
+ ? { out: "result.json", schemaOut: "result.schema.json" }
+ : { out: "result.json" };
+ return renderModule(
+ defineConfig(schema, () => value, target),
+ "/tmp",
+ );
+}
+
+describe("schema output", () => {
+ test("emits output-mode schema with defaulted fields required", () => {
+ const files = render(
+ z.strictObject({ name: z.string(), attempts: z.int().default(3) }),
+ { name: "worker" },
+ true,
+ );
+ expect(files[0]?.contents).toContain('"attempts": 3');
+ expect(files[1]?.path).toBe("/tmp/result.schema.json");
+ expect(files[1]?.contents).toMatch(
+ /"required": \[\s*"name",\s*"attempts"\s*\]/,
+ );
+ });
+ test("rejects a bare transform with a root schema path", () => {
+ expect(() =>
+ validateClosedSchema(
+ z.transform((value: unknown) => value),
+ "input",
+ ),
+ ).toThrow("Transform schemas are not supported at /");
+ });
+ test("reports a nested bare transform schema path", () => {
+ const schema = z.strictObject({
+ nested: z.transform((value: unknown) => value),
+ });
+ expect(() => validateClosedSchema(schema, "input")).toThrow(
+ "Transform schemas are not supported at /properties/nested",
+ );
+ });
+
+ test.each([
+ [
+ "intersection",
+ z
+ .strictObject({ left: z.string() })
+ .and(z.strictObject({ right: z.string() })),
+ ],
+ ["transform", z.transform((value: unknown) => value)],
+ ] as const)("rejects output-mode %s with a path", (_kind, schema) => {
+ expect(() => validateClosedSchema(schema, "output")).toThrow("at /");
+ });
+ test.each([
+ ["date", z.date()],
+ ["bigint", z.bigint()],
+ ["map", z.map(z.string(), z.string())],
+ ["function", z.function()],
+ ["undefined", z.undefined()],
+ ] as const)(
+ "rejects optional %s schemas for schema output",
+ (_kind, unsupported) => {
+ const schema = z.strictObject({ value: unsupported.optional() });
+ expect(() => render(schema, { value: undefined }, true)).toThrow(
+ `at /properties/value`,
+ );
+ },
+ );
+ test("rejects an optional set when emitting schema output", () => {
+ const schema = z.strictObject({ value: z.set(z.string()).optional() });
+ expect(() => render(schema, {}, true)).toThrow("/properties/value");
+ });
+});
+describe("render paths and JSON boundaries", () => {
+ test("reports a nested Zod issue path", () => {
+ expect(() =>
+ render(z.strictObject({ nested: z.strictObject({ count: z.int() }) }), {
+ nested: { count: "bad" },
+ }),
+ ).toThrow("$.nested.count");
+ });
+ test("reports nested invalid-union paths and punctuation keys", () => {
+ const schema = z.strictObject({
+ items: z.array(
+ z.union([
+ z.strictObject({ nested: z.strictObject({ count: z.number() }) }),
+ z.strictObject({ other: z.string() }),
+ ]),
+ ),
+ });
+ expect(() =>
+ render(schema, { items: [{ nested: { count: "bad" } }] }),
+ ).toThrow("$.items[0].nested.count");
+ expect(() =>
+ render(z.strictObject({ "a.b": z.number() }), { "a.b": "bad" }),
+ ).toThrow('$["a.b"]');
+ });
+
+ test("uses the prefault value when selecting a transformed union branch", () => {
+ const schema = z
+ .union([
+ z.strictObject({ y: z.number(), x: z.number() }),
+ z.preprocess(
+ (value) => (typeof value === "string" ? { x: 1, y: 2 } : {}),
+ z.strictObject({ x: z.number(), y: z.number() }),
+ ),
+ ])
+ .prefault("choose-second");
+ const expected = '{\n\t"x": 1,\n\t"y": 2\n}\n';
+ const [file] = render(schema, undefined);
+ expect(file?.contents).toBe(expected);
+ });
+
+ test("uses the preprocessed array input when selecting a nested union branch", () => {
+ const union = z.union([
+ z.strictObject({ y: z.number(), x: z.number() }),
+ z.preprocess(
+ (value) => (typeof value === "string" ? { x: 1, y: 2 } : {}),
+ z.strictObject({ x: z.number(), y: z.number() }),
+ ),
+ ]);
+ const schema = z.preprocess(
+ (value) => (value === "array" ? ["choose-second"] : []),
+ z.array(union),
+ );
+ const [file] = render(schema, "array");
+ expect(file?.contents).toBe('[\n\t{\n\t\t"x": 1,\n\t\t"y": 2\n\t}\n]\n');
+ });
+
+ test("uses bracket notation for punctuation keys in JSON-value errors", () => {
+ expect(() => render(z.unknown(), { "a.b": Number.NaN })).toThrow(
+ '$["a.b"]',
+ );
+ });
+
+ test("reports nested Date and non-finite number paths", () => {
+ expect(() =>
+ render(z.unknown(), { config: { created: new Date(0) } }),
+ ).toThrow("$.config.created");
+ expect(() => render(z.unknown(), { values: [1, Number.NaN] })).toThrow(
+ "$.values[1]",
+ );
+ });
+
+ test("formats circular value paths with punctuation keys", () => {
+ const circular: Record = {};
+ circular["a.b"] = circular;
+ expect(() => render(z.unknown(), circular)).toThrow('$["a.b"]');
+ });
+
+ test("rejects sparse array holes with their index", () => {
+ const sparse: unknown[] = [];
+ sparse.length = 2;
+ sparse[0] = "present";
+ expect(() => render(z.unknown(), sparse)).toThrow("$[1]");
+ });
+});
+
+describe("ordering parsed values", () => {
+ test("recurses through arrays admitted by unknown and any schemas", () => {
+ const [file] = render(z.unknown(), [[{ z: 1, a: 2 }], [3]]);
+ expect(file?.contents).toContain('"a": 2');
+ expect(file?.contents).toContain('"z": 1');
+ const anyFile = render(z.any(), [[{ z: 1, a: 2 }]])[0];
+ expect(anyFile?.contents).toContain('"a": 2');
+ });
+
+ test("unwraps lazy and nonoptional schemas while preserving nested order", () => {
+ let recursive: z.ZodType;
+ recursive = z.lazy(() =>
+ z.strictObject({
+ z: z.number(),
+ a: z.record(z.string(), z.number()),
+ next: recursive.optional(),
+ }),
+ );
+ const [file] = render(recursive.nonoptional(), {
+ a: { z: 1, b: 2 },
+ z: 3,
+ });
+ expect(file?.contents).toBe(
+ '{\n\t"z": 3,\n\t"a": {\n\t\t"b": 2,\n\t\t"z": 1\n\t}\n}\n',
+ );
+ });
+
+ test("keeps prototype-sensitive catchall keys", () => {
+ const input: Record = { a: 1, constructor: 2 };
+ Object.defineProperty(input, "__proto__", {
+ value: 3,
+ enumerable: true,
+ });
+ const [file] = render(z.looseObject({}), input);
+ expect(file?.contents).toBe(
+ '{\n\t"__proto__": 3,\n\t"a": 1,\n\t"constructor": 2\n}\n',
+ );
+ });
+
+ test("orders union branches from parsed input instead of transformed output", () => {
+ const schema = z.union([
+ z.preprocess(
+ () => ({ z: 1, a: 2 }),
+ z.strictObject({ z: z.number(), a: z.number() }),
+ ),
+ z.strictObject({ a: z.number(), z: z.number() }),
+ ]);
+ const [file] = render(schema, "input");
+ expect(file?.contents).toBe('{\n\t"z": 1,\n\t"a": 2\n}\n');
+ });
+ test("uses original array and tuple inputs when selecting union branches", () => {
+ const union = z.union([
+ z.preprocess(
+ (value) => (typeof value === "string" ? { x: 1, y: 2 } : {}),
+ z.strictObject({ x: z.number(), y: z.number() }),
+ ),
+ z.strictObject({ y: z.number(), x: z.number() }),
+ ]);
+ const [arrayFile] = render(z.array(union), ["choose-first"]);
+ expect(arrayFile?.contents).toBe(
+ '[\n\t{\n\t\t"x": 1,\n\t\t"y": 2\n\t}\n]\n',
+ );
+ const [tupleFile] = render(z.tuple([union]), ["choose-first"]);
+ expect(tupleFile?.contents).toBe(
+ '[\n\t{\n\t\t"x": 1,\n\t\t"y": 2\n\t}\n]\n',
+ );
+ });
+
+ test.each([
+ [
+ "default",
+ z
+ .union([
+ z.strictObject({ z: z.number(), a: z.number() }),
+ z.strictObject({ a: z.number(), z: z.number() }),
+ ])
+ .default({ z: 1, a: 2 }),
+ ],
+ [
+ "prefault",
+ z
+ .union([
+ z.strictObject({ z: z.number(), a: z.number() }),
+ z.strictObject({ a: z.number(), z: z.number() }),
+ ])
+ .prefault({ z: 1, a: 2 }),
+ ],
+ ] as const)("preserves the selected %s union branch", (_name, schema) => {
+ const [file] = render(schema, undefined);
+ expect(file?.contents).toBe('{\n\t"z": 1,\n\t"a": 2\n}\n');
+ });
+});
+
+describe("rejected schema kinds", () => {
+ for (const schemaOut of [false, true]) {
+ test(`rejects intersections with schemaOut=${schemaOut}`, () => {
+ const schema = z
+ .strictObject({ left: z.string() })
+ .and(z.strictObject({ right: z.string() }));
+ expect(() => render(schema, {}, schemaOut)).toThrow(
+ "Intersection schemas are not supported at /",
+ );
+ });
+
+ test(`rejects transforms with schemaOut=${schemaOut}`, () => {
+ expect(() =>
+ render(z.string().transform(Number), "1", schemaOut),
+ ).toThrow("Transform schemas are not supported at /");
+ });
+ }
+
+ test("allows a pipe into a non-transform schema", () => {
+ expect(render(z.string().pipe(z.string().min(1)), "valid")).toHaveLength(1);
+ });
+});
+
+describe("JSON value ordering", () => {
+ test("keeps schema keys and sorts record keys", () => {
+ const schema = z.strictObject({
+ b: z.number(),
+ a: z.number(),
+ r: z.record(z.string(), z.number()),
+ });
+ const [file] = render(schema, { r: { z: 1, y: 2 }, a: 1, b: 2 });
+ expect(file?.contents).toBe(
+ '{\n\t"b": 2,\n\t"a": 1,\n\t"r": {\n\t\t"y": 2,\n\t\t"z": 1\n\t}\n}\n',
+ );
+ });
+
+ test("sorts catchall keys and drops undefined object keys", () => {
+ const schema = z
+ .strictObject({ known: z.string().optional() })
+ .catchall(z.number());
+ const [file] = render(schema, { z: 2, known: undefined, a: 1 });
+ expect(file?.contents).toBe('{\n\t"a": 1,\n\t"z": 2\n}\n');
+ });
+
+ test("rejects undefined array values with their path", () => {
+ expect(() => render(z.array(z.int().optional()), [1, undefined])).toThrow(
+ "$[1]",
+ );
+ });
+
+ test("rejects non-JSON values admitted by unknown schemas", () => {
+ expect(() => render(z.unknown(), new Date(0))).toThrow("$");
+ });
+
+ test("sorts nested record keys allowed by unknown schemas", () => {
+ const [file] = render(z.unknown(), { nested: { z: 1, a: 2 } });
+ expect(file?.contents).toBe(
+ '{\n\t"nested": {\n\t\t"a": 2,\n\t\t"z": 1\n\t}\n}\n',
+ );
+ });
+ test("sorts top-level and nested null-prototype objects", () => {
+ const nested = Object.setPrototypeOf({ z: 1, a: 2 }, null);
+ const input = Object.setPrototypeOf({ z: 3, nested }, null);
+ const [file] = render(z.unknown(), input);
+ expect(file?.contents).toBe(
+ '{\n\t"nested": {\n\t\t"a": 2,\n\t\t"z": 1\n\t},\n\t"z": 3\n}\n',
+ );
+ });
+
+ test("rejects duplicate artifact paths", () => {
+ const schema = z.strictObject({ value: z.string() });
+ expect(() =>
+ renderModule(
+ defineConfig(schema, () => ({ value: "x" }), {
+ out: "same.json",
+ schemaOut: "same.json",
+ }),
+ "/tmp",
+ ),
+ ).toThrow("target.schemaOut");
+ });
+});
diff --git a/src/render.ts b/src/render.ts
new file mode 100644
index 0000000..a362bb8
--- /dev/null
+++ b/src/render.ts
@@ -0,0 +1,131 @@
+import { resolve } from "node:path";
+import { z } from "zod";
+import { validateClosedSchema } from "./closed.ts";
+import type { ConfigModule } from "./define.ts";
+import { orderParsedValue } from "./order.ts";
+
+export interface RenderedFile {
+ path: string;
+ contents: string;
+}
+
+export class RenderError extends Error {
+ constructor(
+ message: string,
+ readonly at: string,
+ ) {
+ super(`${message} at ${at}`);
+ this.name = "RenderError";
+ }
+}
+
+function deepestIssuePath(issue: z.core.$ZodIssue): PropertyKey[] {
+ if (issue.code !== "invalid_union" || issue.errors.length === 0)
+ return issue.path;
+ let deepest = issue.path;
+ for (const branch of issue.errors) {
+ for (const nested of branch) {
+ const candidate = [...issue.path, ...deepestIssuePath(nested)];
+ if (candidate.length > deepest.length) deepest = candidate;
+ }
+ }
+ return deepest;
+}
+
+export function formatIssuePath(path: readonly PropertyKey[]): string {
+ return path.reduce((result, part) => {
+ if (typeof part === "number") return `${result}[${part}]`;
+ const key = String(part);
+ return /^[A-Za-z_$][\w$]*$/.test(key)
+ ? `${result}.${key}`
+ : `${result}[${JSON.stringify(key)}]`;
+ }, "$");
+}
+
+function validateJsonValue(value: unknown, path: readonly PropertyKey[]): void {
+ if (value === null || typeof value === "string" || typeof value === "boolean")
+ return;
+ if (typeof value === "number" && Number.isFinite(value)) return;
+ if (typeof value !== "object" || value === null) {
+ throw new RenderError(
+ `Value is not JSON: ${typeof value}`,
+ formatIssuePath(path),
+ );
+ }
+ if (Array.isArray(value)) {
+ for (let index = 0; index < value.length; index += 1) {
+ const child = value[index];
+ if (child === undefined) {
+ throw new RenderError(
+ "Undefined array elements are not JSON",
+ formatIssuePath([...path, index]),
+ );
+ }
+ validateJsonValue(child, [...path, index]);
+ }
+ return;
+ }
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) {
+ throw new RenderError(
+ "Value is not a plain JSON object",
+ formatIssuePath(path),
+ );
+ }
+ for (const [key, child] of Object.entries(value)) {
+ if (child !== undefined) validateJsonValue(child, [...path, key]);
+ }
+}
+
+function jsonContents(value: unknown): string {
+ return `${JSON.stringify(value, null, "\t")}\n`;
+}
+
+export function renderModule(
+ mod: ConfigModule,
+ moduleDir: string,
+): RenderedFile[] {
+ validateClosedSchema(mod.schema, "input");
+ const rawValue = mod.build();
+ const parsed = mod.schema.safeParse(rawValue);
+ if (!parsed.success) {
+ const issue = parsed.error.issues[0];
+ const at =
+ issue === undefined ? "$" : formatIssuePath(deepestIssuePath(issue));
+ throw new RenderError(z.prettifyError(parsed.error), at);
+ }
+ const value = orderParsedValue(parsed.data, mod.schema, rawValue);
+ if (
+ rawValue !== null &&
+ typeof rawValue === "object" &&
+ !Array.isArray(rawValue) &&
+ value !== null &&
+ typeof value === "object"
+ ) {
+ const protoKey = Object.getOwnPropertyDescriptor(rawValue, "__proto__");
+ if (protoKey?.enumerable && !Object.hasOwn(value, "__proto__")) {
+ Object.defineProperty(value, "__proto__", {
+ value: protoKey.value,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ });
+ }
+ }
+ validateJsonValue(value, []);
+ const files: RenderedFile[] = [
+ { path: resolve(moduleDir, mod.target.out), contents: jsonContents(value) },
+ ];
+ if (mod.target.schemaOut !== undefined) {
+ const schemaPath = resolve(moduleDir, mod.target.schemaOut);
+ if (files.some((file) => file.path === schemaPath)) {
+ throw new RenderError(
+ "Config and schema output paths must differ",
+ "target.schemaOut",
+ );
+ }
+ const outputSchema = validateClosedSchema(mod.schema, "output");
+ files.push({ path: schemaPath, contents: jsonContents(outputSchema) });
+ }
+ return files;
+}
diff --git a/src/testing.test.ts b/src/testing.test.ts
new file mode 100644
index 0000000..2b27f52
--- /dev/null
+++ b/src/testing.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
+import { join, relative } from "node:path";
+import { expectCommitted, renderForSnapshot } from "./testing.ts";
+
+const directories: string[] = [];
+afterEach(async () => {
+ await Promise.all(
+ directories
+ .splice(0)
+ .map((directory) => rm(directory, { recursive: true, force: true })),
+ );
+});
+
+async function configFixture() {
+ const directory = await mkdtemp(join(process.cwd(), ".tabit-testing-"));
+ directories.push(directory);
+ const modulePath = join(directory, "fixture.config.ts");
+ const source = `import { defineConfig } from '${process.cwd()}/src/index.ts';\nimport { z } from '${process.cwd()}/node_modules/zod/index.js';\nexport default defineConfig(z.strictObject({ name: z.string() }), () => ({ name: 'fixture' }), { out: './result.json' });\n`;
+ await writeFile(modulePath, source);
+ return { directory: await realpath(directory), modulePath };
+}
+
+describe("testing helpers", () => {
+ test("renders files for snapshots by absolute path", async () => {
+ const { directory, modulePath } = await configFixture();
+ expect(await renderForSnapshot(modulePath)).toEqual({
+ [join(directory, "result.json")]: '{\n\t"name": "fixture"\n}\n',
+ });
+ });
+
+ test("resolves relative paths from the caller's working directory", async () => {
+ const { directory } = await configFixture();
+ const relativePath = relative(process.cwd(), directory);
+ expect(
+ await renderForSnapshot(`${relativePath}/fixture.config.ts`),
+ ).toEqual({
+ [join(directory, "result.json")]: '{\n\t"name": "fixture"\n}\n',
+ });
+ });
+
+ test("rejects mismatched committed bytes and reports missing output", async () => {
+ const { directory, modulePath } = await configFixture();
+ await writeFile(join(directory, "result.json"), "wrong bytes\n");
+ await expect(expectCommitted(modulePath)).rejects.toThrow("does not match");
+ await rm(join(directory, "result.json"));
+ await expect(expectCommitted(modulePath)).rejects.toThrow(
+ "result.json is missing; run tabit to create it.",
+ );
+ });
+
+ test("checks committed file bytes", async () => {
+ const { directory, modulePath } = await configFixture();
+ await writeFile(
+ join(directory, "result.json"),
+ '{\n\t"name": "fixture"\n}\n',
+ );
+ await expect(expectCommitted(modulePath)).resolves.toBeUndefined();
+ });
+});
diff --git a/src/testing.ts b/src/testing.ts
new file mode 100644
index 0000000..5bc2aa2
--- /dev/null
+++ b/src/testing.ts
@@ -0,0 +1,63 @@
+import { readFile, realpath } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import type { z } from "zod";
+import type { ConfigModule } from "./define.ts";
+import { isConfigModule } from "./define.ts";
+import { renderModule } from "./render.ts";
+
+async function loadFixtureModule(
+ modulePath: string,
+): Promise> {
+ const url = pathToFileURL(modulePath).href;
+ // Config modules are runtime-selected from the caller's test fixture path.
+ const loaded: unknown = await import(url);
+ if (
+ loaded === null ||
+ typeof loaded !== "object" ||
+ !("default" in loaded) ||
+ !isConfigModule(loaded.default)
+ ) {
+ throw new TypeError(
+ `${modulePath} must default-export a value created by defineConfig`,
+ );
+ }
+ return loaded.default;
+}
+
+async function renderedFixture(modulePath: string) {
+ const absolutePath = await realpath(resolve(modulePath));
+ return renderModule(
+ await loadFixtureModule(absolutePath),
+ dirname(absolutePath),
+ );
+}
+
+export async function expectCommitted(modulePath: string): Promise {
+ for (const file of await renderedFixture(modulePath)) {
+ let actual: string;
+ try {
+ actual = await readFile(file.path, "utf8");
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ "code" in error &&
+ error.code === "ENOENT"
+ ) {
+ throw new Error(`${file.path} is missing; run tabit to create it.`);
+ }
+ throw error;
+ }
+ if (actual !== file.contents)
+ throw new Error(`${file.path} does not match its rendered config`);
+ }
+}
+
+export async function renderForSnapshot(
+ modulePath: string,
+): Promise> {
+ const result: Record = {};
+ for (const file of await renderedFixture(modulePath))
+ result[file.path] = file.contents;
+ return result;
+}
diff --git a/tools/renovate/bot-config.json5 b/tools/renovate/bot-config.json5
new file mode 100644
index 0000000..3b3cc92
--- /dev/null
+++ b/tools/renovate/bot-config.json5
@@ -0,0 +1,9 @@
+{
+ $schema: "https://docs.renovatebot.com/renovate-global-schema.json",
+ configFileNames: ["tools/renovate/config.json5"],
+ platform: "github",
+ repositories: ["RigelBuild/tabit"],
+ onboarding: false,
+ requireConfig: "required",
+ allowedCommands: ["^devenv update nixpkgs$", "^nix flake update nixpkgs$"],
+}
diff --git a/tools/renovate/config.json5 b/tools/renovate/config.json5
new file mode 100644
index 0000000..5ec89eb
--- /dev/null
+++ b/tools/renovate/config.json5
@@ -0,0 +1,52 @@
+{
+ $schema: "https://docs.renovatebot.com/renovate-schema.json",
+ extends: ["config:recommended"],
+ dependencyDashboard: true,
+ rebaseWhen: "behind-base-branch",
+ minimumReleaseAge: "5 days",
+ internalChecksFilter: "strict",
+ enabledManagers: ["custom.regex", "bun", "npm"],
+ customManagers: [
+ {
+ customType: "regex",
+ managerFilePatterns: ["/^devenv\\.lock$/"],
+ matchStrings: [
+ "\"repo\": \"devenv-nixpkgs\",\\s*\"rev\": \"(?[a-f0-9]{40})\"",
+ ],
+ depNameTemplate: "cachix/devenv-nixpkgs",
+ packageNameTemplate: "https://github.com/cachix/devenv-nixpkgs",
+ currentValueTemplate: "rolling",
+ datasourceTemplate: "git-refs",
+ },
+ ],
+ packageRules: [
+ {
+ matchPackageNames: ["@types/bun", "bun-types"],
+ minimumReleaseAge: null,
+ },
+ {
+ matchManagers: ["bun", "npm"],
+ matchPackageNames: ["typescript"],
+ allowedVersions: "<7",
+ },
+ {
+ matchPackageNames: ["@rigelbuild/ref-gate"],
+ minimumReleaseAge: null,
+ },
+ {
+ matchPackageNames: ["zod"],
+ allowedVersions: "^4.4.3",
+ },
+ {
+ matchManagers: ["custom.regex"],
+ matchDepNames: ["cachix/devenv-nixpkgs"],
+ groupName: "devenv nixpkgs",
+ minimumReleaseAge: null,
+ postUpgradeTasks: {
+ commands: ["devenv update nixpkgs", "nix flake update nixpkgs"],
+ fileFilters: ["devenv.lock", "flake.lock"],
+ executionMode: "branch",
+ },
+ },
+ ],
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..18957e8
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "moduleDetection": "force",
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+ "strict": true,
+ "exactOptionalPropertyTypes": true,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": true,
+ "noUncheckedIndexedAccess": true,
+ "skipLibCheck": true,
+ "types": ["bun"]
+ },
+ "include": ["src/**/*.ts", "*.test.ts"]
+}