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