From 96276fe50437851f9d19ed6afa084b0bdfe9a8e7 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 10:54:53 -0400 Subject: [PATCH 1/2] feat: tabit library MVP defineConfig, closed-schema rendering to deterministic JSON, the tabit CLI with --check, and a test helper. CI runs biome, tsc, bun test and rumdl. Co-authored-by: Matt Wilkinson --- .envrc | 4 + .github/workflows/ci.yml | 52 +++++ .github/workflows/renovate.yml | 56 ++++++ .gitignore | 8 + .moon/workspace.yml | 10 + .rumdl.toml | 1 + CODEOWNERS | 1 + LICENSE-APACHE | 202 +++++++++++++++++++ LICENSE-MIT | 19 ++ README.md | 37 +++- biome.json | 19 ++ bun.lock | 33 ++++ bunfig.toml | 3 + devenv-module.nix | 9 + devenv.lock | 65 +++++++ devenv.nix | 10 + devenv.yaml | 3 + flake.lock | 47 +++++ flake.nix | 54 ++++++ moon.yml | 47 +++++ package.json | 36 ++++ ref-gate.config.json | 30 +++ rumdl-base-config.nix | 5 + rumdl/base.toml | 21 ++ src/cli.test.ts | 107 +++++++++++ src/cli.ts | 82 ++++++++ src/closed.test.ts | 60 ++++++ src/closed.ts | 103 ++++++++++ src/define.ts | 64 ++++++ src/discover.test.ts | 142 ++++++++++++++ src/discover.ts | 143 ++++++++++++++ src/fixture.rendered.json | 6 + src/index.ts | 7 + src/order.ts | 245 +++++++++++++++++++++++ src/render.test.ts | 331 ++++++++++++++++++++++++++++++++ src/render.ts | 131 +++++++++++++ src/testing.test.ts | 60 ++++++ src/testing.ts | 63 ++++++ tools/renovate/bot-config.json5 | 9 + tools/renovate/config.json5 | 52 +++++ tsconfig.json | 20 ++ 41 files changed, 2395 insertions(+), 2 deletions(-) create mode 100644 .envrc create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/renovate.yml create mode 100644 .gitignore create mode 100644 .moon/workspace.yml create mode 100644 .rumdl.toml create mode 100644 CODEOWNERS create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 biome.json create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 devenv-module.nix create mode 100644 devenv.lock create mode 100644 devenv.nix create mode 100644 devenv.yaml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 moon.yml create mode 100644 package.json create mode 100644 ref-gate.config.json create mode 100644 rumdl-base-config.nix create mode 100644 rumdl/base.toml create mode 100644 src/cli.test.ts create mode 100644 src/cli.ts create mode 100644 src/closed.test.ts create mode 100644 src/closed.ts create mode 100644 src/define.ts create mode 100644 src/discover.test.ts create mode 100644 src/discover.ts create mode 100644 src/fixture.rendered.json create mode 100644 src/index.ts create mode 100644 src/order.ts create mode 100644 src/render.test.ts create mode 100644 src/render.ts create mode 100644 src/testing.test.ts create mode 100644 src/testing.ts create mode 100644 tools/renovate/bot-config.json5 create mode 100644 tools/renovate/config.json5 create mode 100644 tsconfig.json 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..032ab34 --- /dev/null +++ b/ref-gate.config.json @@ -0,0 +1,30 @@ +{ + "prefilter": { + "ere": "orion|sealed|SEA-[0-9]|infra/pulumi|personal/matt|tools/saiph|ci/workflows|oss/petrel", + "ignoreCase": true + }, + "patterns": [ + { "source": "\\borion\\b", "flags": "i" }, + { "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": "" }, + { + "source": "\\b(?:infra/pulumi|personal/matt|tools/saiph|ci/workflows|oss/petrel)/", + "flags": "i" + } + ], + "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..9d6f918 --- /dev/null +++ b/src/closed.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +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("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..b421283 --- /dev/null +++ b/src/closed.ts @@ -0,0 +1,103 @@ +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); +} + +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: string): void => { + if (!isRecord(node)) return; + if (node.type === "object" && !("additionalProperties" in node)) + paths.push(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 || "$"} -> ${node.$ref}`); + } + } + } + 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", + `/${path.join("/")}`, + ); + } + throw new RenderError(message, `/${path.join("/")}`); + }, + override: ({ zodSchema, jsonSchema: current, path }) => { + const type = zodSchema._zod.def.type; + if (type === "intersection") { + throw new RenderError( + "Intersection schemas are not supported", + `/${path.join("/")}`, + ); + } + if ( + type === "transform" || + (type === "pipe" && + zodSchema._zod.def.out._zod.def.type === "transform") + ) { + throw new RenderError( + "Transform schemas are not supported", + `/${path.join("/")}`, + ); + } + 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..939d8b9 --- /dev/null +++ b/src/order.ts @@ -0,0 +1,245 @@ +import { z } from "zod"; +import { RenderError } from "./render.ts"; + +type InternalSchema = z.ZodType & { _zod: { def: { type: string } } }; + +function orderValue( + value: unknown, + schema: z.ZodType, + path: string, + 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", 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", 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..d3743be --- /dev/null +++ b/src/render.test.ts @@ -0,0 +1,331 @@ +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("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..7d4b6f1 --- /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; +} + +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"] +} From 6c399311e3d70e7c97be462e84c0798a20eefc4c Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 16 Oct 2026 23:42:22 -0400 Subject: [PATCH 2/2] fix: address review round 4 Drop private names from the public ref-gate config. Format circular-value paths with bracket notation and escape JSON Pointer segments in schema errors. Co-authored-by: Matt Wilkinson --- ref-gate.config.json | 9 ++------- src/closed.test.ts | 23 ++++++++++++++++++++++- src/closed.ts | 30 +++++++++++++++++++----------- src/order.ts | 21 ++++++++++++--------- src/render.test.ts | 6 ++++++ src/render.ts | 2 +- 6 files changed, 62 insertions(+), 29 deletions(-) diff --git a/ref-gate.config.json b/ref-gate.config.json index 032ab34..4d2b48a 100644 --- a/ref-gate.config.json +++ b/ref-gate.config.json @@ -1,10 +1,9 @@ { "prefilter": { - "ere": "orion|sealed|SEA-[0-9]|infra/pulumi|personal/matt|tools/saiph|ci/workflows|oss/petrel", + "ere": "sealed|SEA-[0-9]", "ignoreCase": true }, "patterns": [ - { "source": "\\borion\\b", "flags": "i" }, { "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", @@ -16,11 +15,7 @@ "source": "\\bsealed (repo|monorepo|design corpus|platform|convention|PR #)", "flags": "i" }, - { "source": "\\bSEA-\\d+\\b", "flags": "" }, - { - "source": "\\b(?:infra/pulumi|personal/matt|tools/saiph|ci/workflows|oss/petrel)/", - "flags": "i" - } + { "source": "\\bSEA-\\d+\\b", "flags": "" } ], "ignore": [], "carveOutPaths": ["ref-gate.config.json", "bun.lock"], diff --git a/src/closed.test.ts b/src/closed.test.ts index 9d6f918..ffb71c0 100644 --- a/src/closed.test.ts +++ b/src/closed.test.ts @@ -1,5 +1,6 @@ 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"; @@ -43,6 +44,26 @@ describe("closed schemas", () => { 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; @@ -55,6 +76,6 @@ describe("closed schemas", () => { root: { inner: { value: "x" } }, other: { inner: { value: "y" } }, }), - ).toThrow("$defs/__schema0/properties/inner"); + ).toThrow("/$defs/__schema0/properties/inner"); }); }); diff --git a/src/closed.ts b/src/closed.ts index b421283..797d417 100644 --- a/src/closed.ts +++ b/src/closed.ts @@ -5,15 +5,23 @@ 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: string): void => { + const visit = (node: unknown, path: readonly PropertyKey[]): void => { if (!isRecord(node)) return; if (node.type === "object" && !("additionalProperties" in node)) - paths.push(path || "$"); + paths.push(path.length === 0 ? "$" : formatPointer(path)); if (typeof node.$ref === "string") { const prefix = "#/$defs/"; if (node.$ref.startsWith(prefix)) { @@ -23,7 +31,7 @@ export function openObjectPaths(jsonSchema: unknown): string[] { .replaceAll("~0", "~"); if (!seenReferences.has(key) && key in definitions) { seenReferences.add(key); - visit(definitions[key], `${path || "$"} -> ${node.$ref}`); + visit(definitions[key], [...path, "$defs", key]); } } } @@ -31,23 +39,23 @@ export function openObjectPaths(jsonSchema: unknown): string[] { const children = node[key]; if (isRecord(children)) { for (const [name, child] of Object.entries(children)) - visit(child, `${path}/${key}/${name}`); + visit(child, [...path, key, name]); } } for (const key of ["additionalProperties", "items"]) { const child = node[key]; - if (isRecord(child)) visit(child, `${path}/${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(child, [...path, key, index]); }); } } }; - visit(jsonSchema, ""); + visit(jsonSchema, []); return paths; } @@ -66,17 +74,17 @@ export function validateClosedSchema( ) { throw new RenderError( "Transform schemas are not supported", - `/${path.join("/")}`, + formatPointer(path), ); } - throw new RenderError(message, `/${path.join("/")}`); + 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", - `/${path.join("/")}`, + formatPointer(path), ); } if ( @@ -86,7 +94,7 @@ export function validateClosedSchema( ) { throw new RenderError( "Transform schemas are not supported", - `/${path.join("/")}`, + formatPointer(path), ); } return current; diff --git a/src/order.ts b/src/order.ts index 939d8b9..27be58e 100644 --- a/src/order.ts +++ b/src/order.ts @@ -1,12 +1,12 @@ import { z } from "zod"; -import { RenderError } from "./render.ts"; +import { formatIssuePath, RenderError } from "./render.ts"; type InternalSchema = z.ZodType & { _zod: { def: { type: string } } }; function orderValue( value: unknown, schema: z.ZodType, - path: string, + path: readonly PropertyKey[], active: WeakSet, unionInput: unknown, objectInput: unknown, @@ -100,7 +100,10 @@ function orderValue( } if (Array.isArray(value)) { if (active.has(value)) - throw new RenderError("Circular value is not JSON", path); + throw new RenderError( + "Circular value is not JSON", + formatIssuePath(path), + ); active.add(value); try { const originalItems = Array.isArray(objectInput) ? objectInput : value; @@ -110,7 +113,7 @@ function orderValue( orderValue( item, array.element, - `${path}[${index}]`, + [...path, index], active, originalItems[index], originalItems[index], @@ -131,7 +134,7 @@ function orderValue( : orderValue( item, itemSchema, - `${path}[${index}]`, + [...path, index], active, originalItems[index], originalItems[index], @@ -143,7 +146,7 @@ function orderValue( orderValue( item, z.unknown(), - `${path}[${index}]`, + [...path, index], active, originalItems[index], originalItems[index], @@ -161,7 +164,7 @@ function orderValue( ) return value; if (active.has(value)) - throw new RenderError("Circular value is not JSON", path); + throw new RenderError("Circular value is not JSON", formatIssuePath(path)); active.add(value); try { const object = value as Record; @@ -176,7 +179,7 @@ function orderValue( value: orderValue( child, childSchema, - `${path}.${key}`, + [...path, key], active, originalChild, originalChild, @@ -237,7 +240,7 @@ export function orderParsedValue( schema: z.ZodType, inputValue: unknown, ): unknown { - return orderValue(value, schema, "$", new WeakSet(), inputValue, inputValue); + return orderValue(value, schema, [], new WeakSet(), inputValue, inputValue); } export function orderUnnamedKeys(value: unknown, schema: z.ZodType): unknown { diff --git a/src/render.test.ts b/src/render.test.ts index d3743be..c809b09 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -146,6 +146,12 @@ describe("render paths and JSON boundaries", () => { ); }); + 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; diff --git a/src/render.ts b/src/render.ts index 7d4b6f1..a362bb8 100644 --- a/src/render.ts +++ b/src/render.ts @@ -32,7 +32,7 @@ function deepestIssuePath(issue: z.core.$ZodIssue): PropertyKey[] { return deepest; } -function formatIssuePath(path: readonly PropertyKey[]): string { +export function formatIssuePath(path: readonly PropertyKey[]): string { return path.reduce((result, part) => { if (typeof part === "number") return `${result}[${part}]`; const key = String(part);