From 5fa1dc4bd3fb0529e83cc66dc9904329e96fddbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Mon, 25 May 2026 08:29:32 +0200 Subject: [PATCH 1/4] chore: update dependencies --- package.json | 6 +- yarn.lock | 366 +++++++++++++++++++++++++-------------------------- 2 files changed, 186 insertions(+), 186 deletions(-) diff --git a/package.json b/package.json index 82b7999..9dc71a6 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,13 @@ "devDependencies": { "@changesets/cli": "^2.31.0", "@types/node": ">=24", - "@typescript/native-preview": "^7.0.0-dev.20260519.1", - "@vitest/coverage-v8": "^4.1.6", + "@typescript/native-preview": "^7.0.0-dev.20260522.1", + "@vitest/coverage-v8": "^4.1.7", "adio": "^3.0.0", "happy-dom": "^20.9.0", "oxfmt": "^0.51.0", "oxlint": "^1.66.0", - "vitest": "^4.1.6" + "vitest": "^4.1.7" }, "scripts": { "clean": "rm -rf dist", diff --git a/yarn.lock b/yarn.lock index 0b4a9df..7607385 100644 --- a/yarn.lock +++ b/yarn.lock @@ -522,10 +522,10 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.130.0": - version: 0.130.0 - resolution: "@oxc-project/types@npm:0.130.0" - checksum: 10c0/7ec8c03407b0bcb235b930c62859e6efcb3fe5cbaa5db98770d760df5c3e6b3e28a0ad22c2e35d1addede8065b40000c3822c5235dde2959af226639eb870000 +"@oxc-project/types@npm:=0.132.0": + version: 0.132.0 + resolution: "@oxc-project/types@npm:0.132.0" + checksum: 10c0/d0ca5e98be0b873d69e4f0f743eb35026833603dac11db9d55f2b5438251b381b886dc556fe3175a17b673f8e2073c49bde88d7e6e702aa09298c22b8b5504e1 languageName: node linkType: hard @@ -809,93 +809,93 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-android-arm64@npm:1.0.1" +"@rolldown/binding-android-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-android-arm64@npm:1.0.2" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.1" +"@rolldown/binding-darwin-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.1" +"@rolldown/binding-darwin-x64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.1" +"@rolldown/binding-freebsd-x64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.2" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.1" +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.2" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.1" +"@rolldown/binding-linux-arm64-gnu@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.2" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.1" +"@rolldown/binding-linux-arm64-musl@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.2" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.1" +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.2" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.1" +"@rolldown/binding-linux-s390x-gnu@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.2" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.1" +"@rolldown/binding-linux-x64-gnu@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.2" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.1" +"@rolldown/binding-linux-x64-musl@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.2" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.1" +"@rolldown/binding-openharmony-arm64@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.2" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.1" +"@rolldown/binding-wasm32-wasi@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.2" dependencies: "@emnapi/core": "npm:1.10.0" "@emnapi/runtime": "npm:1.10.0" @@ -904,16 +904,16 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.1" +"@rolldown/binding-win32-arm64-msvc@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.1": - version: 1.0.1 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.1" +"@rolldown/binding-win32-x64-msvc@npm:1.0.2": + version: 1.0.2 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -966,11 +966,11 @@ __metadata: linkType: hard "@types/node@npm:*, @types/node@npm:>=20.0.0, @types/node@npm:>=24": - version: 25.9.0 - resolution: "@types/node@npm:25.9.0" + version: 25.9.1 + resolution: "@types/node@npm:25.9.1" dependencies: undici-types: "npm:>=7.24.0 <7.24.7" - checksum: 10c0/1845f0ce5471213a4f0c887845c50993968ea863c03bb355282b59bdcc877d009a2785f0f506cfc40b8ce7548a21e33ac826e998be62d42b000bfd9ccdc6c910 + checksum: 10c0/9a04682842bebbcf21a1779dfeab9aa733d7bd7bbc0a0edb641ab3a9a3d43eac543225acf669c334f458f1956443ebc072bc3c72840c543b8b356cab5c82d456 languageName: node linkType: hard @@ -997,66 +997,66 @@ __metadata: languageName: node linkType: hard -"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260519.1": - version: 7.0.0-dev.20260519.1 - resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260519.1" +"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260522.1": + version: 7.0.0-dev.20260522.1 + resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260522.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260519.1": - version: 7.0.0-dev.20260519.1 - resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260519.1" +"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260522.1": + version: 7.0.0-dev.20260522.1 + resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260522.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260519.1": - version: 7.0.0-dev.20260519.1 - resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260519.1" +"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260522.1": + version: 7.0.0-dev.20260522.1 + resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260522.1" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260519.1": - version: 7.0.0-dev.20260519.1 - resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260519.1" +"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260522.1": + version: 7.0.0-dev.20260522.1 + resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260522.1" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260519.1": - version: 7.0.0-dev.20260519.1 - resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260519.1" +"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260522.1": + version: 7.0.0-dev.20260522.1 + resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260522.1" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260519.1": - version: 7.0.0-dev.20260519.1 - resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260519.1" +"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260522.1": + version: 7.0.0-dev.20260522.1 + resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260522.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260519.1": - version: 7.0.0-dev.20260519.1 - resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260519.1" +"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260522.1": + version: 7.0.0-dev.20260522.1 + resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260522.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@typescript/native-preview@npm:^7.0.0-dev.20260519.1": - version: 7.0.0-dev.20260519.1 - resolution: "@typescript/native-preview@npm:7.0.0-dev.20260519.1" +"@typescript/native-preview@npm:^7.0.0-dev.20260522.1": + version: 7.0.0-dev.20260522.1 + resolution: "@typescript/native-preview@npm:7.0.0-dev.20260522.1" dependencies: - "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20260519.1" - "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20260519.1" - "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20260519.1" - "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20260519.1" - "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20260519.1" - "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20260519.1" - "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20260519.1" + "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20260522.1" + "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20260522.1" + "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20260522.1" + "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20260522.1" + "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20260522.1" + "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20260522.1" + "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20260522.1" dependenciesMeta: "@typescript/native-preview-darwin-arm64": optional: true @@ -1074,16 +1074,16 @@ __metadata: optional: true bin: tsgo: bin/tsgo.js - checksum: 10c0/f1f220a404a00c68f1cfa97c6aa18a73ed241547125ad6f4b916107dbbfd3b524f564eafabc988b571ee0297278f5bfc4ab15bf5abe0051073c76a1a335a91c9 + checksum: 10c0/b4eaa6bce32ca1e77cc5ef467d4ce65589b076775e8444a4f358f89b6609eabd637369601027c2b5c3914b7b920b533cb88e4febfd6ca34d8dab4aceee8ab86d languageName: node linkType: hard -"@vitest/coverage-v8@npm:^4.1.6": - version: 4.1.6 - resolution: "@vitest/coverage-v8@npm:4.1.6" +"@vitest/coverage-v8@npm:^4.1.7": + version: 4.1.7 + resolution: "@vitest/coverage-v8@npm:4.1.7" dependencies: "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.1.6" + "@vitest/utils": "npm:4.1.7" ast-v8-to-istanbul: "npm:^1.0.0" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" @@ -1093,34 +1093,34 @@ __metadata: std-env: "npm:^4.0.0-rc.1" tinyrainbow: "npm:^3.1.0" peerDependencies: - "@vitest/browser": 4.1.6 - vitest: 4.1.6 + "@vitest/browser": 4.1.7 + vitest: 4.1.7 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10c0/5cc45784200a13ccab166c5d2ed0a4026ab0e28c395a654f046d91ff344a02d38db77b1022d7aa6d07973966c64844a6b62756c88c33609529535c12cc8b1af4 + checksum: 10c0/288fa77cfec00d84528154be90727ee0a868b91a32847b57e078fa4f3061711a53036a68d78bb4ea15e5c65b4644af6d2b7ad28b68b9301e9145426cdc27c0cd languageName: node linkType: hard -"@vitest/expect@npm:4.1.6": - version: 4.1.6 - resolution: "@vitest/expect@npm:4.1.6" +"@vitest/expect@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/expect@npm:4.1.7" dependencies: "@standard-schema/spec": "npm:^1.1.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.1.6" - "@vitest/utils": "npm:4.1.6" + "@vitest/spy": "npm:4.1.7" + "@vitest/utils": "npm:4.1.7" chai: "npm:^6.2.2" tinyrainbow: "npm:^3.1.0" - checksum: 10c0/a6767bdf586c82f64674998bf74987e99aa106ac5d0b5c4c2c1d3924e145b34fd80e138c65568a8fc2544aa71c85b1272f9607fe5ef6a7060ece1c232db46655 + checksum: 10c0/1a72387c6d3cac1e12cd4df382e666d96560b38001ea0133f1e0a22825f71ccf1640ccce13244296b0054c15cf04442f3adbd67dfc57fe542bd35a46cd805487 languageName: node linkType: hard -"@vitest/mocker@npm:4.1.6": - version: 4.1.6 - resolution: "@vitest/mocker@npm:4.1.6" +"@vitest/mocker@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/mocker@npm:4.1.7" dependencies: - "@vitest/spy": "npm:4.1.6" + "@vitest/spy": "npm:4.1.7" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.21" peerDependencies: @@ -1131,56 +1131,56 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/d9f3236940e160467edb7a2552fa014451347c4f08c13d26220fcfe7e12b385fd4975c6a81a6b174117650772cf3c45195b3a1838f8ee28fc8e6c37e07b99b2d + checksum: 10c0/e03dbbba435543e3cfa5e034ba8ade371de5e398255f75366ebc370ff8dd78d45f7d7cc9daa76eb1d399b31e659e47d3cbb710566e64ceeeba3f99b418e4b955 languageName: node linkType: hard -"@vitest/pretty-format@npm:4.1.6": - version: 4.1.6 - resolution: "@vitest/pretty-format@npm:4.1.6" +"@vitest/pretty-format@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/pretty-format@npm:4.1.7" dependencies: tinyrainbow: "npm:^3.1.0" - checksum: 10c0/f818a6abff9b7cf642edc2d0fe84d4f124911696bc7591f2af9ab6d88685b72133a1e9f87499e9b4dc2314dff85403ea66c64f7b408b2eb39f9880c6d3517ca0 + checksum: 10c0/49ef801171708e3a92214e8720efbedbd6e0e6baf17971aaf4feb7422e5c9eba82262c24a9e6dd4d41a31fae77bd31d5b37cf140d13e0ac4ce29a7457bdc692f languageName: node linkType: hard -"@vitest/runner@npm:4.1.6": - version: 4.1.6 - resolution: "@vitest/runner@npm:4.1.6" +"@vitest/runner@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/runner@npm:4.1.7" dependencies: - "@vitest/utils": "npm:4.1.6" + "@vitest/utils": "npm:4.1.7" pathe: "npm:^2.0.3" - checksum: 10c0/8047051d730de66b7cde8e6803ea718eaa8342ffbe55ff3d787fe7085a2b824a979689782d5303e464411fe67b556384b0c5af337e3e335cf140bf7adf5f6aa0 + checksum: 10c0/63474c6fc088d75b5d7fe735195504f923c694b83a22eb9caa53d6486c923974304c2e3ef4d5bcd808d88082174f38434be320fc4fe649a8cf33f0459a0576e3 languageName: node linkType: hard -"@vitest/snapshot@npm:4.1.6": - version: 4.1.6 - resolution: "@vitest/snapshot@npm:4.1.6" +"@vitest/snapshot@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/snapshot@npm:4.1.7" dependencies: - "@vitest/pretty-format": "npm:4.1.6" - "@vitest/utils": "npm:4.1.6" + "@vitest/pretty-format": "npm:4.1.7" + "@vitest/utils": "npm:4.1.7" magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/596d7cd2fe12b57516e983e550d238c324a3cefaac826e557b0903cfbb11f6ff79582bf2df6dc3163cf604c305ffe3840e47f03a95b8fb8d7bf6200462e8cfea + checksum: 10c0/6fa49c4242a4acc0557ee6a20552db41f4f4c9d2d4c05993181c3f5f19e66579e08f63d34f792b79400547ab791ef500a9955b77390c381e45c3bb8e33717793 languageName: node linkType: hard -"@vitest/spy@npm:4.1.6": - version: 4.1.6 - resolution: "@vitest/spy@npm:4.1.6" - checksum: 10c0/908034532fb10888f759603194b11058bdabdf9bb86ef7839feec98f809e4802cf8d74c279c521ef2df12fa9ab97d0aec7c886e1e6910c5c9dfb10ba00913d91 +"@vitest/spy@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/spy@npm:4.1.7" + checksum: 10c0/be2a95d5c5c438b57c9b33cef1289fb02659214754b5e946cb4b8183e2b5089e49e3fda6ca05981f3ea9872b207595db109e25072668c0a671203f69fddbbe99 languageName: node linkType: hard -"@vitest/utils@npm:4.1.6": - version: 4.1.6 - resolution: "@vitest/utils@npm:4.1.6" +"@vitest/utils@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/utils@npm:4.1.7" dependencies: - "@vitest/pretty-format": "npm:4.1.6" + "@vitest/pretty-format": "npm:4.1.7" convert-source-map: "npm:^2.0.0" tinyrainbow: "npm:^3.1.0" - checksum: 10c0/36437888088a1aae8565e62b9f145de9fb1599725574924477c655c7617ad677b575ac0eb3f2b3288854ed1aafff914a0417dffbb7f5244c821f157119701227 + checksum: 10c0/aa0079d8923506300527dc23ff68cf090ffcb2c6a9549e598ae22ba0eb8a6bb4448b10724b38bc6b077f9957333302a857d791ad2f7abd807bb6263c9a218833 languageName: node linkType: hard @@ -1199,8 +1199,8 @@ __metadata: dependencies: "@changesets/cli": "npm:^2.31.0" "@types/node": "npm:>=24" - "@typescript/native-preview": "npm:^7.0.0-dev.20260519.1" - "@vitest/coverage-v8": "npm:^4.1.6" + "@typescript/native-preview": "npm:^7.0.0-dev.20260522.1" + "@vitest/coverage-v8": "npm:^4.1.7" "@webiny/di": "npm:^1.0.1" adio: "npm:^3.0.0" dot-prop: "npm:^10.1.0" @@ -1211,7 +1211,7 @@ __metadata: pino: "npm:^10.3.1" pino-pretty: "npm:^13.1.3" type-fest: "npm:^5.6.0" - vitest: "npm:^4.1.6" + vitest: "npm:^4.1.7" zod: "npm:^4.4.3" languageName: unknown linkType: soft @@ -2052,9 +2052,9 @@ __metadata: linkType: hard "lru-cache@npm:^11.0.0": - version: 11.4.0 - resolution: "lru-cache@npm:11.4.0" - checksum: 10c0/ac59697b1bcc19494b4a12af5e2320bb6c353473654d73e6c84033deb22faf220c861b1e0bc1ac9f65a394d46653870e982598a046c5976f44c4b1deb5ad8197 + version: 11.5.0 + resolution: "lru-cache@npm:11.5.0" + checksum: 10c0/b92c2a7518128dec6b244bf3eb9fd79964d316cdeb12865ebfc2cebb4dfe9b24e3767a3923d71e6eb735f56b557fc55f08f150a53097d7805afb628c90158df4 languageName: node linkType: hard @@ -2597,7 +2597,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.5.14": +"postcss@npm:^8.5.15": version: 8.5.15 resolution: "postcss@npm:8.5.15" dependencies: @@ -2716,26 +2716,26 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:1.0.1": - version: 1.0.1 - resolution: "rolldown@npm:1.0.1" - dependencies: - "@oxc-project/types": "npm:=0.130.0" - "@rolldown/binding-android-arm64": "npm:1.0.1" - "@rolldown/binding-darwin-arm64": "npm:1.0.1" - "@rolldown/binding-darwin-x64": "npm:1.0.1" - "@rolldown/binding-freebsd-x64": "npm:1.0.1" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.1" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.1" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.1" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.1" - "@rolldown/binding-linux-s390x-gnu": "npm:1.0.1" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.1" - "@rolldown/binding-linux-x64-musl": "npm:1.0.1" - "@rolldown/binding-openharmony-arm64": "npm:1.0.1" - "@rolldown/binding-wasm32-wasi": "npm:1.0.1" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.1" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.1" +"rolldown@npm:1.0.2": + version: 1.0.2 + resolution: "rolldown@npm:1.0.2" + dependencies: + "@oxc-project/types": "npm:=0.132.0" + "@rolldown/binding-android-arm64": "npm:1.0.2" + "@rolldown/binding-darwin-arm64": "npm:1.0.2" + "@rolldown/binding-darwin-x64": "npm:1.0.2" + "@rolldown/binding-freebsd-x64": "npm:1.0.2" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.2" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.2" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.2" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.2" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.2" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.2" + "@rolldown/binding-linux-x64-musl": "npm:1.0.2" + "@rolldown/binding-openharmony-arm64": "npm:1.0.2" + "@rolldown/binding-wasm32-wasi": "npm:1.0.2" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.2" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.2" "@rolldown/pluginutils": "npm:^1.0.0" dependenciesMeta: "@rolldown/binding-android-arm64": @@ -2769,8 +2769,8 @@ __metadata: "@rolldown/binding-win32-x64-msvc": optional: true bin: - rolldown: bin/cli.mjs - checksum: 10c0/0631c071874e1471c33923905061fa514fce2bd43c2e741adcddcaa4d9beaa2ba7a5d14af130d53753d838823e15b59f5acef7d24fb83ffb7aef15933b78e7d3 + rolldown: ./bin/cli.mjs + checksum: 10c0/628327a6e3122c0b62880f1c87d54095394e5138a6af2e6e7b2f67ef4c4b11f1421db68c9a5bb4e1be161465a863ab4f68f15076ce895cd4bb3d0ba18a3b20b1 languageName: node linkType: hard @@ -2805,11 +2805,11 @@ __metadata: linkType: hard "semver@npm:^7.3.5, semver@npm:^7.5.3": - version: 7.8.0 - resolution: "semver@npm:7.8.0" + version: 7.8.1 + resolution: "semver@npm:7.8.1" bin: semver: bin/semver.js - checksum: 10c0/8f096ca9b80ffd47b308d03f9ce8c873e27e2983f36023c559cdc92c51e8433fc23ebbfe57ec9623fc155636a6961ee989501099841ae4bb1babc8d2b3f048cd + checksum: 10c0/92d6871d6347e1f99d0ba396a70f2545ccf2a032cda3d378fa0699edf7506b5c6d266aed55c8b88e72bd91a30d2351e4f39db479375374430fcdc4b58f4e3c1a languageName: node linkType: hard @@ -3057,14 +3057,14 @@ __metadata: linkType: hard "vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": - version: 8.0.13 - resolution: "vite@npm:8.0.13" + version: 8.0.14 + resolution: "vite@npm:8.0.14" dependencies: fsevents: "npm:~2.3.3" lightningcss: "npm:^1.32.0" picomatch: "npm:^4.0.4" - postcss: "npm:^8.5.14" - rolldown: "npm:1.0.1" + postcss: "npm:^8.5.15" + rolldown: "npm:1.0.2" tinyglobby: "npm:^0.2.16" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 @@ -3109,21 +3109,21 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/8f4d6fd30c3be710f76dba8ee7cd156902200e649884911cfa8e6e5f7ad4dd5b6933bdd4f0c46c0169c49ddce9ce1bfab6d395df9d176c0d959e3ba0e5ee54e4 + checksum: 10c0/1ff99b4daadc64aed5f9e40387ecf39fd3bca45c1a5c4fa4aa82197de901930f0507af8d75c54715e2744c99575913947efb625653a78ef6df3997c5613970bd languageName: node linkType: hard -"vitest@npm:^4.1.6": - version: 4.1.6 - resolution: "vitest@npm:4.1.6" +"vitest@npm:^4.1.7": + version: 4.1.7 + resolution: "vitest@npm:4.1.7" dependencies: - "@vitest/expect": "npm:4.1.6" - "@vitest/mocker": "npm:4.1.6" - "@vitest/pretty-format": "npm:4.1.6" - "@vitest/runner": "npm:4.1.6" - "@vitest/snapshot": "npm:4.1.6" - "@vitest/spy": "npm:4.1.6" - "@vitest/utils": "npm:4.1.6" + "@vitest/expect": "npm:4.1.7" + "@vitest/mocker": "npm:4.1.7" + "@vitest/pretty-format": "npm:4.1.7" + "@vitest/runner": "npm:4.1.7" + "@vitest/snapshot": "npm:4.1.7" + "@vitest/spy": "npm:4.1.7" + "@vitest/utils": "npm:4.1.7" es-module-lexer: "npm:^2.0.0" expect-type: "npm:^1.3.0" magic-string: "npm:^0.30.21" @@ -3141,12 +3141,12 @@ __metadata: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.6 - "@vitest/browser-preview": 4.1.6 - "@vitest/browser-webdriverio": 4.1.6 - "@vitest/coverage-istanbul": 4.1.6 - "@vitest/coverage-v8": 4.1.6 - "@vitest/ui": 4.1.6 + "@vitest/browser-playwright": 4.1.7 + "@vitest/browser-preview": 4.1.7 + "@vitest/browser-webdriverio": 4.1.7 + "@vitest/coverage-istanbul": 4.1.7 + "@vitest/coverage-v8": 4.1.7 + "@vitest/ui": 4.1.7 happy-dom: "*" jsdom: "*" vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3177,7 +3177,7 @@ __metadata: optional: false bin: vitest: vitest.mjs - checksum: 10c0/1da4c23f02cd39cb20a857d48462d1d6100eeb4644fc0defc7c6eef9d481f85d2598b72d39eb4a8d271fafa8328ac3ffcca11b27865385e88d9d65c8b29b8091 + checksum: 10c0/5328eab211161bdb854159154b02d7b2beab0cf1e26a1c13f6a64b0f1402029d41f19987cf60684051c09a6925030285195ecbe57271c2033e1d4f7a666590d0 languageName: node linkType: hard @@ -3230,8 +3230,8 @@ __metadata: linkType: hard "ws@npm:^8.18.3": - version: 8.20.1 - resolution: "ws@npm:8.20.1" + version: 8.21.0 + resolution: "ws@npm:8.21.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -3240,7 +3240,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/ce162433218399cdedeb76fd33363d4d86a7d910058d4e3c679dce08cea65d6da6b39f11baa4d7808d024cf46ed88f6a05c17611621aaad8fc5e62edacc30c5d + checksum: 10c0/ef4a243476283fc49bc7550966c4af4aa0eef56273837211e700de3b664e08604a760cdddcb5ba43c049140e74ccfec5b0ee0bb439e08c2adf9138902fdde5f9 languageName: node linkType: hard From 3d2a55a317a5c0b38312a41a30a67bbf43d69784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Mon, 25 May 2026 08:55:44 +0200 Subject: [PATCH 2/4] feat(node): add HashFolderTool for deterministic folder hashing Replaces the unmaintained folder-hash library with a zero-dependency implementation using node:crypto and node:fs. Walks a directory tree, hashes each file with SHA-256, sorts by relative path for deterministic ordering, then produces a single combined hex digest. Supports excludeFolders and excludeFiles options to skip build artifacts. Exports: DI abstraction (HashFolderTool/HashFolderToolFeature), factory (createHashFolderTool), and standalone function (hashFolder). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 1 + __tests__/node/HashFolderTool.test.ts | 226 ++++++++++++++++++ .../features/HashFolderTool/HashFolderTool.ts | 91 +++++++ src/node/features/HashFolderTool/README.md | 59 +++++ .../abstractions/HashFolderTool.ts | 27 +++ .../HashFolderTool/abstractions/index.ts | 1 + src/node/features/HashFolderTool/feature.ts | 9 + src/node/features/HashFolderTool/index.ts | 3 + src/node/index.ts | 7 + 9 files changed, 424 insertions(+) create mode 100644 __tests__/node/HashFolderTool.test.ts create mode 100644 src/node/features/HashFolderTool/HashFolderTool.ts create mode 100644 src/node/features/HashFolderTool/README.md create mode 100644 src/node/features/HashFolderTool/abstractions/HashFolderTool.ts create mode 100644 src/node/features/HashFolderTool/abstractions/index.ts create mode 100644 src/node/features/HashFolderTool/feature.ts create mode 100644 src/node/features/HashFolderTool/index.ts diff --git a/README.md b/README.md index b1757e5..63ad910 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ The package is ESM-only and ships three subpath exports. Because each is a separ | `NdJsonReaderTool` / `NdJsonReaderToolFeature` | Parse NDJSON from files, streams, or in-memory lines with checkpoint support — [docs](src/node/features/NdJsonReaderTool/README.md) | | `ReadStreamFactory` / `ReadStreamFactoryFeature` | Disposable `node:fs` read streams via `AsyncDisposable` — [docs](src/node/features/ReadStreamFactory/README.md) | | `PackageJsonFileTool` / `PackageJsonFileToolFeature` | Read, validate, mutate, and write `package.json` files — [docs](src/node/features/PackageJsonFileTool/README.md) | +| `HashFolderTool` / `HashFolderToolFeature` | Deterministic SHA-256 hash of a folder's contents — [docs](src/node/features/HashFolderTool/README.md) | --- diff --git a/__tests__/node/HashFolderTool.test.ts b/__tests__/node/HashFolderTool.test.ts new file mode 100644 index 0000000..3343ca2 --- /dev/null +++ b/__tests__/node/HashFolderTool.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Container } from "@webiny/di"; +import { + HashFolderTool, + HashFolderToolFeature, + createHashFolderTool, + hashFolder +} from "../../src/node/features/HashFolderTool/index.js"; + +function makeContainer(): Container { + const container = new Container(); + HashFolderToolFeature.register(container); + return container; +} + +describe("HashFolderTool", () => { + let tmpDir: string; + let tool: HashFolderTool.Interface; + + beforeEach(() => { + tmpDir = join(tmpdir(), `wby-hash-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + tool = makeContainer().resolve(HashFolderTool); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns a hex string for a folder with files", async () => { + writeFileSync(join(tmpDir, "a.txt"), "hello"); + writeFileSync(join(tmpDir, "b.txt"), "world"); + + const result = await tool.hash(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); + + it("returns a deterministic hash for the same content", async () => { + writeFileSync(join(tmpDir, "file.txt"), "content"); + + const hash1 = await tool.hash(tmpDir); + const hash2 = await tool.hash(tmpDir); + expect(hash1).toBe(hash2); + }); + + it("produces different hashes when file content changes", async () => { + writeFileSync(join(tmpDir, "file.txt"), "version1"); + const hash1 = await tool.hash(tmpDir); + + writeFileSync(join(tmpDir, "file.txt"), "version2"); + const hash2 = await tool.hash(tmpDir); + + expect(hash1).not.toBe(hash2); + }); + + it("includes files in nested subdirectories", async () => { + mkdirSync(join(tmpDir, "sub"), { recursive: true }); + writeFileSync(join(tmpDir, "sub", "nested.txt"), "deep"); + + const hashWithNested = await tool.hash(tmpDir); + + writeFileSync(join(tmpDir, "sub", "nested.txt"), "changed"); + const hashAfterChange = await tool.hash(tmpDir); + + expect(hashWithNested).not.toBe(hashAfterChange); + }); + + it("excludes specified folders", async () => { + writeFileSync(join(tmpDir, "keep.txt"), "kept"); + mkdirSync(join(tmpDir, "dist"), { recursive: true }); + writeFileSync(join(tmpDir, "dist", "bundle.js"), "compiled"); + + const hashWithExclude = await tool.hash(tmpDir, { excludeFolders: ["dist"] }); + + writeFileSync(join(tmpDir, "dist", "bundle.js"), "recompiled"); + const hashAfterDistChange = await tool.hash(tmpDir, { excludeFolders: ["dist"] }); + + expect(hashWithExclude).toBe(hashAfterDistChange); + }); + + it("excludes specified files", async () => { + writeFileSync(join(tmpDir, "source.ts"), "code"); + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); + + const hashWithExclude = await tool.hash(tmpDir, { + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "updated-info"); + const hashAfterInfoChange = await tool.hash(tmpDir, { + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + expect(hashWithExclude).toBe(hashAfterInfoChange); + }); + + it("excludes multiple folders and files together", async () => { + writeFileSync(join(tmpDir, "source.ts"), "code"); + mkdirSync(join(tmpDir, "dist"), { recursive: true }); + mkdirSync(join(tmpDir, "node_modules"), { recursive: true }); + writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); + writeFileSync(join(tmpDir, "node_modules", "dep.js"), "dep"); + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); + + const hash1 = await tool.hash(tmpDir, { + excludeFolders: ["dist", "node_modules"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); + writeFileSync(join(tmpDir, "node_modules", "dep.js"), "updated-dep"); + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info"); + + const hash2 = await tool.hash(tmpDir, { + excludeFolders: ["dist", "node_modules"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + expect(hash1).toBe(hash2); + }); + + it("is order-independent — same files in different creation order produce same hash", async () => { + const dir1 = join(tmpDir, "dir1"); + const dir2 = join(tmpDir, "dir2"); + mkdirSync(dir1, { recursive: true }); + mkdirSync(dir2, { recursive: true }); + + writeFileSync(join(dir1, "a.txt"), "alpha"); + writeFileSync(join(dir1, "b.txt"), "beta"); + + writeFileSync(join(dir2, "b.txt"), "beta"); + writeFileSync(join(dir2, "a.txt"), "alpha"); + + const hash1 = await tool.hash(dir1); + const hash2 = await tool.hash(dir2); + expect(hash1).toBe(hash2); + }); + + it("returns a hash for an empty folder", async () => { + const result = await tool.hash(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); + + it("includes relative path in the hash so renames are detected", async () => { + writeFileSync(join(tmpDir, "original.txt"), "content"); + const hash1 = await tool.hash(tmpDir); + + rmSync(join(tmpDir, "original.txt")); + writeFileSync(join(tmpDir, "renamed.txt"), "content"); + const hash2 = await tool.hash(tmpDir); + + expect(hash1).not.toBe(hash2); + }); +}); + +describe("createHashFolderTool", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(tmpdir(), `wby-hash-factory-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("creates a working tool without arguments", async () => { + const tool = createHashFolderTool(); + writeFileSync(join(tmpDir, "file.txt"), "content"); + const result = await tool.hash(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces a deterministic hash", async () => { + writeFileSync(join(tmpDir, "file.txt"), "content"); + const hash1 = await createHashFolderTool().hash(tmpDir); + const hash2 = await createHashFolderTool().hash(tmpDir); + expect(hash1).toBe(hash2); + }); +}); + +describe("hashFolder", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(tmpdir(), `wby-hash-standalone-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns a hash string directly", async () => { + writeFileSync(join(tmpDir, "file.txt"), "content"); + const result = await hashFolder(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); + + it("supports exclude options", async () => { + writeFileSync(join(tmpDir, "source.ts"), "code"); + mkdirSync(join(tmpDir, "dist"), { recursive: true }); + writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); + + const hash1 = await hashFolder(tmpDir, { excludeFolders: ["dist"] }); + + writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); + const hash2 = await hashFolder(tmpDir, { excludeFolders: ["dist"] }); + + expect(hash1).toBe(hash2); + }); + + it("produces the same hash as the DI tool", async () => { + writeFileSync(join(tmpDir, "file.txt"), "content"); + + const tool = createHashFolderTool(); + const diHash = await tool.hash(tmpDir); + const standaloneHash = await hashFolder(tmpDir); + + expect(standaloneHash).toBe(diHash); + }); +}); diff --git a/src/node/features/HashFolderTool/HashFolderTool.ts b/src/node/features/HashFolderTool/HashFolderTool.ts new file mode 100644 index 0000000..53f1980 --- /dev/null +++ b/src/node/features/HashFolderTool/HashFolderTool.ts @@ -0,0 +1,91 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { + HashFolderTool as HashFolderToolAbstraction, + type HashFolderOptions +} from "./abstractions/HashFolderTool.js"; + +class HashFolderToolImpl implements HashFolderToolAbstraction.Interface { + public async hash(folderPath: string, options?: HashFolderOptions): Promise { + const excludeFolders = new Set(options?.excludeFolders ?? []); + const excludeFiles = new Set(options?.excludeFiles ?? []); + + const entries = await this.collectFiles( + folderPath, + folderPath, + excludeFolders, + excludeFiles + ); + entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + + const combinedHash = createHash("sha256"); + for (const entry of entries) { + combinedHash.update(entry.relativePath); + combinedHash.update(entry.fileHash); + } + + return combinedHash.digest("hex"); + } + + private async collectFiles( + rootPath: string, + currentPath: string, + excludeFolders: Set, + excludeFiles: Set + ): Promise { + const entries: FileEntry[] = []; + const dirEntries = await readdir(currentPath, { withFileTypes: true }); + + for (const dirEntry of dirEntries) { + if (dirEntry.isDirectory()) { + if (excludeFolders.has(dirEntry.name)) { + continue; + } + const subEntries = await this.collectFiles( + rootPath, + join(currentPath, dirEntry.name), + excludeFolders, + excludeFiles + ); + entries.push(...subEntries); + } else if (dirEntry.isFile()) { + if (excludeFiles.has(dirEntry.name)) { + continue; + } + const filePath = join(currentPath, dirEntry.name); + const content = await readFile(filePath); + const fileHash = createHash("sha256").update(content).digest("hex"); + entries.push({ + relativePath: relative(rootPath, filePath), + fileHash + }); + } + } + + return entries; + } +} + +interface FileEntry { + relativePath: string; + fileHash: string; +} + +export const HashFolderTool = HashFolderToolAbstraction.createImplementation({ + implementation: HashFolderToolImpl, + dependencies: [] +}); + +export function createHashFolderTool(): HashFolderToolAbstraction.Interface { + return new HashFolderToolImpl(); +} + +/** + * Standalone convenience function — computes a SHA-256 hash of a folder's contents + * without requiring DI wiring. + */ +export async function hashFolder(folderPath: string, options?: HashFolderOptions): Promise { + const tool = createHashFolderTool(); + return tool.hash(folderPath, options); +} diff --git a/src/node/features/HashFolderTool/README.md b/src/node/features/HashFolderTool/README.md new file mode 100644 index 0000000..fd24f07 --- /dev/null +++ b/src/node/features/HashFolderTool/README.md @@ -0,0 +1,59 @@ +# HashFolderTool + +Computes a deterministic SHA-256 hash of a folder's contents. Walks the directory tree recursively, hashes each file individually, sorts entries by relative path for deterministic ordering, then produces a single combined hex digest. Use it to detect whether a folder's contents have changed — for example, to skip redundant builds when source files haven't been modified. + +## Interface + +```ts +interface IHashFolderTool { + /** Returns a hex-encoded SHA-256 hash representing the folder's contents. */ + hash(folderPath: string, options?: HashFolderOptions): Promise; +} + +interface HashFolderOptions { + /** Folder names to skip during traversal (e.g. "node_modules", "dist"). */ + excludeFolders?: string[]; + /** File names to skip (e.g. "tsconfig.build.tsbuildinfo"). */ + excludeFiles?: string[]; +} +``` + +## Usage + +### DI container wiring + +```ts +import { Container } from "@webiny/di"; +import { HashFolderTool, HashFolderToolFeature } from "@webiny/stdlib/node"; + +const container = new Container(); +HashFolderToolFeature.register(container); + +const tool = container.resolve(HashFolderTool); +const hash = await tool.hash("./packages/my-package", { + excludeFolders: ["dist", "lib", "node_modules"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] +}); +``` + +### Factory function + +```ts +import { createHashFolderTool } from "@webiny/stdlib/node"; + +const tool = createHashFolderTool(); +const hash = await tool.hash("./packages/my-package", { + excludeFolders: ["dist", "node_modules"] +}); +``` + +### Standalone function + +```ts +import { hashFolder } from "@webiny/stdlib/node"; + +const hash = await hashFolder("./packages/my-package", { + excludeFolders: ["dist", "lib", "node_modules"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] +}); +``` diff --git a/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts b/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts new file mode 100644 index 0000000..dbefc4a --- /dev/null +++ b/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts @@ -0,0 +1,27 @@ +import { createAbstraction } from "~/common/index.js"; + +/** + * Options for filtering which folders and files are included in the hash. + */ +export interface HashFolderOptions { + /** Folder names to skip during traversal (e.g. "node_modules", "dist"). */ + excludeFolders?: string[]; + /** File names to skip (e.g. "tsconfig.build.tsbuildinfo"). */ + excludeFiles?: string[]; +} + +/** + * Computes a deterministic SHA-256 hash of a folder's contents. + * Walks the directory recursively, hashes each file, sorts by relative path, + * then produces a single combined hash. + */ +export interface IHashFolderTool { + /** Returns a hex-encoded SHA-256 hash representing the folder's contents. */ + hash(folderPath: string, options?: HashFolderOptions): Promise; +} + +export const HashFolderTool = createAbstraction("Node/HashFolderTool"); + +export namespace HashFolderTool { + export type Interface = IHashFolderTool; +} diff --git a/src/node/features/HashFolderTool/abstractions/index.ts b/src/node/features/HashFolderTool/abstractions/index.ts new file mode 100644 index 0000000..0f27a38 --- /dev/null +++ b/src/node/features/HashFolderTool/abstractions/index.ts @@ -0,0 +1 @@ +export { HashFolderTool, type HashFolderOptions } from "./HashFolderTool.js"; diff --git a/src/node/features/HashFolderTool/feature.ts b/src/node/features/HashFolderTool/feature.ts new file mode 100644 index 0000000..9af4492 --- /dev/null +++ b/src/node/features/HashFolderTool/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "~/common/index.js"; +import { HashFolderTool } from "./HashFolderTool.js"; + +export const HashFolderToolFeature = createFeature({ + name: "Node/HashFolderToolFeature", + register(container) { + container.register(HashFolderTool).inSingletonScope(); + } +}); diff --git a/src/node/features/HashFolderTool/index.ts b/src/node/features/HashFolderTool/index.ts new file mode 100644 index 0000000..55e8a5b --- /dev/null +++ b/src/node/features/HashFolderTool/index.ts @@ -0,0 +1,3 @@ +export { HashFolderTool, type HashFolderOptions } from "./abstractions/index.js"; +export { HashFolderToolFeature } from "./feature.js"; +export { createHashFolderTool, hashFolder } from "./HashFolderTool.js"; diff --git a/src/node/index.ts b/src/node/index.ts index 50e8808..d1d2d8e 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -51,3 +51,10 @@ export { type CreatePackageJsonFileToolParams, PackageJsonFile } from "./features/PackageJsonFileTool/index.js"; +export { + HashFolderTool, + HashFolderToolFeature, + createHashFolderTool, + hashFolder, + type HashFolderOptions +} from "./features/HashFolderTool/index.js"; From 4528de0c47ea1dcb930c8880f30221878bd64596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Mon, 25 May 2026 09:00:19 +0200 Subject: [PATCH 3/4] feat(node): add sync hash and async parallel hashAsync to HashFolderTool hash() uses readdirSync/readFileSync for simple synchronous usage. hashAsync() reads files and recurses into subdirectories concurrently via Promise.all for better throughput on large directory trees. Both methods produce identical deterministic SHA-256 digests. Standalone exports: hashFolder (sync), hashFolderAsync (async). Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/node/HashFolderTool.test.ts | 309 ++++++++++++------ .../features/HashFolderTool/HashFolderTool.ts | 170 ++++++---- src/node/features/HashFolderTool/README.md | 33 +- .../abstractions/HashFolderTool.ts | 6 +- src/node/features/HashFolderTool/index.ts | 2 +- src/node/index.ts | 1 + 6 files changed, 352 insertions(+), 169 deletions(-) diff --git a/__tests__/node/HashFolderTool.test.ts b/__tests__/node/HashFolderTool.test.ts index 3343ca2..f1d0010 100644 --- a/__tests__/node/HashFolderTool.test.ts +++ b/__tests__/node/HashFolderTool.test.ts @@ -7,7 +7,8 @@ import { HashFolderTool, HashFolderToolFeature, createHashFolderTool, - hashFolder + hashFolder, + hashFolderAsync } from "../../src/node/features/HashFolderTool/index.js"; function makeContainer(): Container { @@ -30,129 +31,186 @@ describe("HashFolderTool", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("returns a hex string for a folder with files", async () => { - writeFileSync(join(tmpDir, "a.txt"), "hello"); - writeFileSync(join(tmpDir, "b.txt"), "world"); + describe("hash (sync)", () => { + it("returns a hex string for a folder with files", () => { + writeFileSync(join(tmpDir, "a.txt"), "hello"); + writeFileSync(join(tmpDir, "b.txt"), "world"); - const result = await tool.hash(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); - }); + const result = tool.hash(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); - it("returns a deterministic hash for the same content", async () => { - writeFileSync(join(tmpDir, "file.txt"), "content"); + it("returns a deterministic hash for the same content", () => { + writeFileSync(join(tmpDir, "file.txt"), "content"); - const hash1 = await tool.hash(tmpDir); - const hash2 = await tool.hash(tmpDir); - expect(hash1).toBe(hash2); - }); + const hash1 = tool.hash(tmpDir); + const hash2 = tool.hash(tmpDir); + expect(hash1).toBe(hash2); + }); - it("produces different hashes when file content changes", async () => { - writeFileSync(join(tmpDir, "file.txt"), "version1"); - const hash1 = await tool.hash(tmpDir); + it("produces different hashes when file content changes", () => { + writeFileSync(join(tmpDir, "file.txt"), "version1"); + const hash1 = tool.hash(tmpDir); - writeFileSync(join(tmpDir, "file.txt"), "version2"); - const hash2 = await tool.hash(tmpDir); + writeFileSync(join(tmpDir, "file.txt"), "version2"); + const hash2 = tool.hash(tmpDir); - expect(hash1).not.toBe(hash2); - }); + expect(hash1).not.toBe(hash2); + }); - it("includes files in nested subdirectories", async () => { - mkdirSync(join(tmpDir, "sub"), { recursive: true }); - writeFileSync(join(tmpDir, "sub", "nested.txt"), "deep"); + it("includes files in nested subdirectories", () => { + mkdirSync(join(tmpDir, "sub"), { recursive: true }); + writeFileSync(join(tmpDir, "sub", "nested.txt"), "deep"); - const hashWithNested = await tool.hash(tmpDir); + const hashWithNested = tool.hash(tmpDir); - writeFileSync(join(tmpDir, "sub", "nested.txt"), "changed"); - const hashAfterChange = await tool.hash(tmpDir); + writeFileSync(join(tmpDir, "sub", "nested.txt"), "changed"); + const hashAfterChange = tool.hash(tmpDir); - expect(hashWithNested).not.toBe(hashAfterChange); - }); + expect(hashWithNested).not.toBe(hashAfterChange); + }); - it("excludes specified folders", async () => { - writeFileSync(join(tmpDir, "keep.txt"), "kept"); - mkdirSync(join(tmpDir, "dist"), { recursive: true }); - writeFileSync(join(tmpDir, "dist", "bundle.js"), "compiled"); + it("excludes specified folders", () => { + writeFileSync(join(tmpDir, "keep.txt"), "kept"); + mkdirSync(join(tmpDir, "dist"), { recursive: true }); + writeFileSync(join(tmpDir, "dist", "bundle.js"), "compiled"); - const hashWithExclude = await tool.hash(tmpDir, { excludeFolders: ["dist"] }); + const hashWithExclude = tool.hash(tmpDir, { excludeFolders: ["dist"] }); - writeFileSync(join(tmpDir, "dist", "bundle.js"), "recompiled"); - const hashAfterDistChange = await tool.hash(tmpDir, { excludeFolders: ["dist"] }); + writeFileSync(join(tmpDir, "dist", "bundle.js"), "recompiled"); + const hashAfterDistChange = tool.hash(tmpDir, { excludeFolders: ["dist"] }); - expect(hashWithExclude).toBe(hashAfterDistChange); - }); + expect(hashWithExclude).toBe(hashAfterDistChange); + }); - it("excludes specified files", async () => { - writeFileSync(join(tmpDir, "source.ts"), "code"); - writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); + it("excludes specified files", () => { + writeFileSync(join(tmpDir, "source.ts"), "code"); + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); + + const hashWithExclude = tool.hash(tmpDir, { + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "updated-info"); + const hashAfterInfoChange = tool.hash(tmpDir, { + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); - const hashWithExclude = await tool.hash(tmpDir, { - excludeFiles: ["tsconfig.build.tsbuildinfo"] + expect(hashWithExclude).toBe(hashAfterInfoChange); }); - writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "updated-info"); - const hashAfterInfoChange = await tool.hash(tmpDir, { - excludeFiles: ["tsconfig.build.tsbuildinfo"] + it("excludes multiple folders and files together", () => { + writeFileSync(join(tmpDir, "source.ts"), "code"); + mkdirSync(join(tmpDir, "dist"), { recursive: true }); + mkdirSync(join(tmpDir, "node_modules"), { recursive: true }); + writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); + writeFileSync(join(tmpDir, "node_modules", "dep.js"), "dep"); + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); + + const hash1 = tool.hash(tmpDir, { + excludeFolders: ["dist", "node_modules"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); + writeFileSync(join(tmpDir, "node_modules", "dep.js"), "updated-dep"); + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info"); + + const hash2 = tool.hash(tmpDir, { + excludeFolders: ["dist", "node_modules"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + expect(hash1).toBe(hash2); }); - expect(hashWithExclude).toBe(hashAfterInfoChange); - }); + it("is order-independent — same files in different creation order produce same hash", () => { + const dir1 = join(tmpDir, "dir1"); + const dir2 = join(tmpDir, "dir2"); + mkdirSync(dir1, { recursive: true }); + mkdirSync(dir2, { recursive: true }); - it("excludes multiple folders and files together", async () => { - writeFileSync(join(tmpDir, "source.ts"), "code"); - mkdirSync(join(tmpDir, "dist"), { recursive: true }); - mkdirSync(join(tmpDir, "node_modules"), { recursive: true }); - writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); - writeFileSync(join(tmpDir, "node_modules", "dep.js"), "dep"); - writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); + writeFileSync(join(dir1, "a.txt"), "alpha"); + writeFileSync(join(dir1, "b.txt"), "beta"); - const hash1 = await tool.hash(tmpDir, { - excludeFolders: ["dist", "node_modules"], - excludeFiles: ["tsconfig.build.tsbuildinfo"] - }); + writeFileSync(join(dir2, "b.txt"), "beta"); + writeFileSync(join(dir2, "a.txt"), "alpha"); - writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); - writeFileSync(join(tmpDir, "node_modules", "dep.js"), "updated-dep"); - writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info"); + const hash1 = tool.hash(dir1); + const hash2 = tool.hash(dir2); + expect(hash1).toBe(hash2); + }); - const hash2 = await tool.hash(tmpDir, { - excludeFolders: ["dist", "node_modules"], - excludeFiles: ["tsconfig.build.tsbuildinfo"] + it("returns a hash for an empty folder", () => { + const result = tool.hash(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); }); - expect(hash1).toBe(hash2); + it("includes relative path in the hash so renames are detected", () => { + writeFileSync(join(tmpDir, "original.txt"), "content"); + const hash1 = tool.hash(tmpDir); + + rmSync(join(tmpDir, "original.txt")); + writeFileSync(join(tmpDir, "renamed.txt"), "content"); + const hash2 = tool.hash(tmpDir); + + expect(hash1).not.toBe(hash2); + }); }); - it("is order-independent — same files in different creation order produce same hash", async () => { - const dir1 = join(tmpDir, "dir1"); - const dir2 = join(tmpDir, "dir2"); - mkdirSync(dir1, { recursive: true }); - mkdirSync(dir2, { recursive: true }); + describe("hashAsync (parallel)", () => { + it("returns a hex string for a folder with files", async () => { + writeFileSync(join(tmpDir, "a.txt"), "hello"); + writeFileSync(join(tmpDir, "b.txt"), "world"); - writeFileSync(join(dir1, "a.txt"), "alpha"); - writeFileSync(join(dir1, "b.txt"), "beta"); + const result = await tool.hashAsync(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); - writeFileSync(join(dir2, "b.txt"), "beta"); - writeFileSync(join(dir2, "a.txt"), "alpha"); + it("returns a deterministic hash for the same content", async () => { + writeFileSync(join(tmpDir, "file.txt"), "content"); - const hash1 = await tool.hash(dir1); - const hash2 = await tool.hash(dir2); - expect(hash1).toBe(hash2); - }); + const hash1 = await tool.hashAsync(tmpDir); + const hash2 = await tool.hashAsync(tmpDir); + expect(hash1).toBe(hash2); + }); - it("returns a hash for an empty folder", async () => { - const result = await tool.hash(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); - }); + it("excludes specified folders and files", async () => { + writeFileSync(join(tmpDir, "source.ts"), "code"); + mkdirSync(join(tmpDir, "dist"), { recursive: true }); + writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); + + const hash1 = await tool.hashAsync(tmpDir, { + excludeFolders: ["dist"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); + writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info"); + + const hash2 = await tool.hashAsync(tmpDir, { + excludeFolders: ["dist"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] + }); + + expect(hash1).toBe(hash2); + }); - it("includes relative path in the hash so renames are detected", async () => { - writeFileSync(join(tmpDir, "original.txt"), "content"); - const hash1 = await tool.hash(tmpDir); + it("produces the same hash as the sync method", async () => { + writeFileSync(join(tmpDir, "a.txt"), "alpha"); + mkdirSync(join(tmpDir, "sub"), { recursive: true }); + writeFileSync(join(tmpDir, "sub", "b.txt"), "beta"); - rmSync(join(tmpDir, "original.txt")); - writeFileSync(join(tmpDir, "renamed.txt"), "content"); - const hash2 = await tool.hash(tmpDir); + const syncHash = tool.hash(tmpDir); + const asyncHash = await tool.hashAsync(tmpDir); + expect(asyncHash).toBe(syncHash); + }); - expect(hash1).not.toBe(hash2); + it("returns a hash for an empty folder", async () => { + const result = await tool.hashAsync(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); }); }); @@ -168,22 +226,22 @@ describe("createHashFolderTool", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("creates a working tool without arguments", async () => { + it("creates a working tool (sync)", () => { const tool = createHashFolderTool(); writeFileSync(join(tmpDir, "file.txt"), "content"); - const result = await tool.hash(tmpDir); + const result = tool.hash(tmpDir); expect(result).toMatch(/^[a-f0-9]{64}$/); }); - it("produces a deterministic hash", async () => { + it("creates a working tool (async)", async () => { + const tool = createHashFolderTool(); writeFileSync(join(tmpDir, "file.txt"), "content"); - const hash1 = await createHashFolderTool().hash(tmpDir); - const hash2 = await createHashFolderTool().hash(tmpDir); - expect(hash1).toBe(hash2); + const result = await tool.hashAsync(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); }); }); -describe("hashFolder", () => { +describe("hashFolder (sync standalone)", () => { let tmpDir: string; beforeEach(() => { @@ -195,32 +253,75 @@ describe("hashFolder", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("returns a hash string directly", async () => { + it("returns a hash string directly", () => { writeFileSync(join(tmpDir, "file.txt"), "content"); - const result = await hashFolder(tmpDir); + const result = hashFolder(tmpDir); expect(result).toMatch(/^[a-f0-9]{64}$/); }); - it("supports exclude options", async () => { + it("supports exclude options", () => { writeFileSync(join(tmpDir, "source.ts"), "code"); mkdirSync(join(tmpDir, "dist"), { recursive: true }); writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); - const hash1 = await hashFolder(tmpDir, { excludeFolders: ["dist"] }); + const hash1 = hashFolder(tmpDir, { excludeFolders: ["dist"] }); writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); - const hash2 = await hashFolder(tmpDir, { excludeFolders: ["dist"] }); + const hash2 = hashFolder(tmpDir, { excludeFolders: ["dist"] }); expect(hash1).toBe(hash2); }); - it("produces the same hash as the DI tool", async () => { + it("produces the same hash as the DI tool", () => { writeFileSync(join(tmpDir, "file.txt"), "content"); const tool = createHashFolderTool(); - const diHash = await tool.hash(tmpDir); - const standaloneHash = await hashFolder(tmpDir); + const diHash = tool.hash(tmpDir); + const standaloneHash = hashFolder(tmpDir); expect(standaloneHash).toBe(diHash); }); }); + +describe("hashFolderAsync (async standalone)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(tmpdir(), `wby-hash-async-standalone-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns a hash string", async () => { + writeFileSync(join(tmpDir, "file.txt"), "content"); + const result = await hashFolderAsync(tmpDir); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); + + it("supports exclude options", async () => { + writeFileSync(join(tmpDir, "source.ts"), "code"); + mkdirSync(join(tmpDir, "dist"), { recursive: true }); + writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); + + const hash1 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] }); + + writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); + const hash2 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] }); + + expect(hash1).toBe(hash2); + }); + + it("produces the same hash as sync standalone", async () => { + writeFileSync(join(tmpDir, "file.txt"), "content"); + mkdirSync(join(tmpDir, "sub"), { recursive: true }); + writeFileSync(join(tmpDir, "sub", "nested.txt"), "nested"); + + const syncHash = hashFolder(tmpDir); + const asyncHash = await hashFolderAsync(tmpDir); + + expect(asyncHash).toBe(syncHash); + }); +}); diff --git a/src/node/features/HashFolderTool/HashFolderTool.ts b/src/node/features/HashFolderTool/HashFolderTool.ts index 53f1980..4870b2d 100644 --- a/src/node/features/HashFolderTool/HashFolderTool.ts +++ b/src/node/features/HashFolderTool/HashFolderTool.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { @@ -6,70 +7,121 @@ import { type HashFolderOptions } from "./abstractions/HashFolderTool.js"; -class HashFolderToolImpl implements HashFolderToolAbstraction.Interface { - public async hash(folderPath: string, options?: HashFolderOptions): Promise { - const excludeFolders = new Set(options?.excludeFolders ?? []); - const excludeFiles = new Set(options?.excludeFiles ?? []); +interface FileEntry { + relativePath: string; + fileHash: string; +} - const entries = await this.collectFiles( - folderPath, - folderPath, - excludeFolders, - excludeFiles - ); - entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +function combineEntries(entries: FileEntry[]): string { + entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + const combined = createHash("sha256"); + for (const entry of entries) { + combined.update(entry.relativePath); + combined.update(entry.fileHash); + } + return combined.digest("hex"); +} - const combinedHash = createHash("sha256"); - for (const entry of entries) { - combinedHash.update(entry.relativePath); - combinedHash.update(entry.fileHash); - } +function collectFilesSync( + rootPath: string, + currentPath: string, + excludeFolders: Set, + excludeFiles: Set +): FileEntry[] { + const entries: FileEntry[] = []; + const dirEntries = readdirSync(currentPath, { withFileTypes: true }); - return combinedHash.digest("hex"); + for (const dirEntry of dirEntries) { + if (dirEntry.isDirectory()) { + if (excludeFolders.has(dirEntry.name)) { + continue; + } + const subEntries = collectFilesSync( + rootPath, + join(currentPath, dirEntry.name), + excludeFolders, + excludeFiles + ); + entries.push(...subEntries); + } else if (dirEntry.isFile()) { + if (excludeFiles.has(dirEntry.name)) { + continue; + } + const filePath = join(currentPath, dirEntry.name); + const content = readFileSync(filePath); + const fileHash = createHash("sha256").update(content).digest("hex"); + entries.push({ relativePath: relative(rootPath, filePath), fileHash }); + } } - private async collectFiles( - rootPath: string, - currentPath: string, - excludeFolders: Set, - excludeFiles: Set - ): Promise { - const entries: FileEntry[] = []; - const dirEntries = await readdir(currentPath, { withFileTypes: true }); - - for (const dirEntry of dirEntries) { - if (dirEntry.isDirectory()) { - if (excludeFolders.has(dirEntry.name)) { - continue; - } - const subEntries = await this.collectFiles( + return entries; +} + +async function collectFilesAsync( + rootPath: string, + currentPath: string, + excludeFolders: Set, + excludeFiles: Set +): Promise { + const dirEntries = await readdir(currentPath, { withFileTypes: true }); + + const tasks: Promise[] = []; + const fileEntries: Promise[] = []; + + for (const dirEntry of dirEntries) { + if (dirEntry.isDirectory()) { + if (excludeFolders.has(dirEntry.name)) { + continue; + } + tasks.push( + collectFilesAsync( rootPath, join(currentPath, dirEntry.name), excludeFolders, excludeFiles - ); - entries.push(...subEntries); - } else if (dirEntry.isFile()) { - if (excludeFiles.has(dirEntry.name)) { - continue; - } - const filePath = join(currentPath, dirEntry.name); - const content = await readFile(filePath); - const fileHash = createHash("sha256").update(content).digest("hex"); - entries.push({ - relativePath: relative(rootPath, filePath), - fileHash - }); + ) + ); + } else if (dirEntry.isFile()) { + if (excludeFiles.has(dirEntry.name)) { + continue; } + const filePath = join(currentPath, dirEntry.name); + fileEntries.push( + readFile(filePath).then(content => ({ + relativePath: relative(rootPath, filePath), + fileHash: createHash("sha256").update(content).digest("hex") + })) + ); } - - return entries; } + + const [subResults, localFiles] = await Promise.all([ + Promise.all(tasks), + Promise.all(fileEntries) + ]); + + return subResults.flat().concat(localFiles); } -interface FileEntry { - relativePath: string; - fileHash: string; +class HashFolderToolImpl implements HashFolderToolAbstraction.Interface { + public hash(folderPath: string, options?: HashFolderOptions): string { + const excludeFolders = new Set(options?.excludeFolders ?? []); + const excludeFiles = new Set(options?.excludeFiles ?? []); + const entries = collectFilesSync(folderPath, folderPath, excludeFolders, excludeFiles); + return combineEntries(entries); + } + + public async hashAsync(folderPath: string, options?: HashFolderOptions): Promise { + const excludeFolders = new Set(options?.excludeFolders ?? []); + const excludeFiles = new Set(options?.excludeFiles ?? []); + const entries = await collectFilesAsync( + folderPath, + folderPath, + excludeFolders, + excludeFiles + ); + return combineEntries(entries); + } } export const HashFolderTool = HashFolderToolAbstraction.createImplementation({ @@ -82,10 +134,18 @@ export function createHashFolderTool(): HashFolderToolAbstraction.Interface { } /** - * Standalone convenience function — computes a SHA-256 hash of a folder's contents - * without requiring DI wiring. + * Standalone sync — computes a SHA-256 hash of a folder's contents. + */ +export function hashFolder(folderPath: string, options?: HashFolderOptions): string { + return new HashFolderToolImpl().hash(folderPath, options); +} + +/** + * Standalone async — reads files and subdirectories in parallel. */ -export async function hashFolder(folderPath: string, options?: HashFolderOptions): Promise { - const tool = createHashFolderTool(); - return tool.hash(folderPath, options); +export async function hashFolderAsync( + folderPath: string, + options?: HashFolderOptions +): Promise { + return new HashFolderToolImpl().hashAsync(folderPath, options); } diff --git a/src/node/features/HashFolderTool/README.md b/src/node/features/HashFolderTool/README.md index fd24f07..6ca0b18 100644 --- a/src/node/features/HashFolderTool/README.md +++ b/src/node/features/HashFolderTool/README.md @@ -2,12 +2,16 @@ Computes a deterministic SHA-256 hash of a folder's contents. Walks the directory tree recursively, hashes each file individually, sorts entries by relative path for deterministic ordering, then produces a single combined hex digest. Use it to detect whether a folder's contents have changed — for example, to skip redundant builds when source files haven't been modified. +Two methods: `hash` (synchronous) for small-to-medium folders, and `hashAsync` (parallel I/O) for large directory trees where concurrent reads improve throughput. + ## Interface ```ts interface IHashFolderTool { - /** Returns a hex-encoded SHA-256 hash representing the folder's contents. */ - hash(folderPath: string, options?: HashFolderOptions): Promise; + /** Returns a hex-encoded SHA-256 hash representing the folder's contents (synchronous). */ + hash(folderPath: string, options?: HashFolderOptions): string; + /** Parallel variant — reads files and subdirectories concurrently. */ + hashAsync(folderPath: string, options?: HashFolderOptions): Promise; } interface HashFolderOptions { @@ -30,7 +34,15 @@ const container = new Container(); HashFolderToolFeature.register(container); const tool = container.resolve(HashFolderTool); -const hash = await tool.hash("./packages/my-package", { + +// Sync +const hash = tool.hash("./packages/my-package", { + excludeFolders: ["dist", "lib", "node_modules"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] +}); + +// Async (parallel I/O) +const hash = await tool.hashAsync("./packages/my-package", { excludeFolders: ["dist", "lib", "node_modules"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); @@ -42,17 +54,24 @@ const hash = await tool.hash("./packages/my-package", { import { createHashFolderTool } from "@webiny/stdlib/node"; const tool = createHashFolderTool(); -const hash = await tool.hash("./packages/my-package", { +const hash = tool.hash("./packages/my-package", { excludeFolders: ["dist", "node_modules"] }); ``` -### Standalone function +### Standalone functions ```ts -import { hashFolder } from "@webiny/stdlib/node"; +import { hashFolder, hashFolderAsync } from "@webiny/stdlib/node"; + +// Sync +const hash = hashFolder("./packages/my-package", { + excludeFolders: ["dist", "lib", "node_modules"], + excludeFiles: ["tsconfig.build.tsbuildinfo"] +}); -const hash = await hashFolder("./packages/my-package", { +// Async (parallel I/O) +const hash = await hashFolderAsync("./packages/my-package", { excludeFolders: ["dist", "lib", "node_modules"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); diff --git a/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts b/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts index dbefc4a..dfe5bbe 100644 --- a/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts +++ b/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts @@ -16,8 +16,10 @@ export interface HashFolderOptions { * then produces a single combined hash. */ export interface IHashFolderTool { - /** Returns a hex-encoded SHA-256 hash representing the folder's contents. */ - hash(folderPath: string, options?: HashFolderOptions): Promise; + /** Returns a hex-encoded SHA-256 hash representing the folder's contents (synchronous). */ + hash(folderPath: string, options?: HashFolderOptions): string; + /** Parallel variant — reads files and subdirectories concurrently. */ + hashAsync(folderPath: string, options?: HashFolderOptions): Promise; } export const HashFolderTool = createAbstraction("Node/HashFolderTool"); diff --git a/src/node/features/HashFolderTool/index.ts b/src/node/features/HashFolderTool/index.ts index 55e8a5b..27067a4 100644 --- a/src/node/features/HashFolderTool/index.ts +++ b/src/node/features/HashFolderTool/index.ts @@ -1,3 +1,3 @@ export { HashFolderTool, type HashFolderOptions } from "./abstractions/index.js"; export { HashFolderToolFeature } from "./feature.js"; -export { createHashFolderTool, hashFolder } from "./HashFolderTool.js"; +export { createHashFolderTool, hashFolder, hashFolderAsync } from "./HashFolderTool.js"; diff --git a/src/node/index.ts b/src/node/index.ts index d1d2d8e..2b7bdbc 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -56,5 +56,6 @@ export { HashFolderToolFeature, createHashFolderTool, hashFolder, + hashFolderAsync, type HashFolderOptions } from "./features/HashFolderTool/index.js"; From c850c2589d07238aeb7628f65fd7ab839d1efe95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Mon, 25 May 2026 09:08:15 +0200 Subject: [PATCH 4/4] refactor(node): return HashFolderResult object from hash methods Methods now return { hash: string } instead of a bare string, making the return type extensible for future metadata (file count, total size, etc.) without a breaking API change. Also adds changeset for the patch release. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/wet-hornets-study.md | 5 + __tests__/node/HashFolderTool.test.ts | 120 +++++++++--------- .../features/HashFolderTool/HashFolderTool.ts | 18 ++- src/node/features/HashFolderTool/README.md | 23 ++-- .../abstractions/HashFolderTool.ts | 14 +- .../HashFolderTool/abstractions/index.ts | 2 +- src/node/features/HashFolderTool/index.ts | 6 +- src/node/index.ts | 3 +- 8 files changed, 109 insertions(+), 82 deletions(-) create mode 100644 .changeset/wet-hornets-study.md diff --git a/.changeset/wet-hornets-study.md b/.changeset/wet-hornets-study.md new file mode 100644 index 0000000..99cbdd5 --- /dev/null +++ b/.changeset/wet-hornets-study.md @@ -0,0 +1,5 @@ +--- +"@webiny/stdlib": patch +--- + +feat: add HashFolderTool — deterministic SHA-256 folder hashing with sync and async (parallel I/O) methods, replacing the unmaintained folder-hash library diff --git a/__tests__/node/HashFolderTool.test.ts b/__tests__/node/HashFolderTool.test.ts index f1d0010..b64e6ff 100644 --- a/__tests__/node/HashFolderTool.test.ts +++ b/__tests__/node/HashFolderTool.test.ts @@ -32,42 +32,42 @@ describe("HashFolderTool", () => { }); describe("hash (sync)", () => { - it("returns a hex string for a folder with files", () => { + it("returns a result with a hex hash", () => { writeFileSync(join(tmpDir, "a.txt"), "hello"); writeFileSync(join(tmpDir, "b.txt"), "world"); const result = tool.hash(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); + expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); it("returns a deterministic hash for the same content", () => { writeFileSync(join(tmpDir, "file.txt"), "content"); - const hash1 = tool.hash(tmpDir); - const hash2 = tool.hash(tmpDir); - expect(hash1).toBe(hash2); + const result1 = tool.hash(tmpDir); + const result2 = tool.hash(tmpDir); + expect(result1).toEqual(result2); }); it("produces different hashes when file content changes", () => { writeFileSync(join(tmpDir, "file.txt"), "version1"); - const hash1 = tool.hash(tmpDir); + const result1 = tool.hash(tmpDir); writeFileSync(join(tmpDir, "file.txt"), "version2"); - const hash2 = tool.hash(tmpDir); + const result2 = tool.hash(tmpDir); - expect(hash1).not.toBe(hash2); + expect(result1).not.toEqual(result2); }); it("includes files in nested subdirectories", () => { mkdirSync(join(tmpDir, "sub"), { recursive: true }); writeFileSync(join(tmpDir, "sub", "nested.txt"), "deep"); - const hashWithNested = tool.hash(tmpDir); + const result1 = tool.hash(tmpDir); writeFileSync(join(tmpDir, "sub", "nested.txt"), "changed"); - const hashAfterChange = tool.hash(tmpDir); + const result2 = tool.hash(tmpDir); - expect(hashWithNested).not.toBe(hashAfterChange); + expect(result1).not.toEqual(result2); }); it("excludes specified folders", () => { @@ -75,28 +75,28 @@ describe("HashFolderTool", () => { mkdirSync(join(tmpDir, "dist"), { recursive: true }); writeFileSync(join(tmpDir, "dist", "bundle.js"), "compiled"); - const hashWithExclude = tool.hash(tmpDir, { excludeFolders: ["dist"] }); + const result1 = tool.hash(tmpDir, { excludeFolders: ["dist"] }); writeFileSync(join(tmpDir, "dist", "bundle.js"), "recompiled"); - const hashAfterDistChange = tool.hash(tmpDir, { excludeFolders: ["dist"] }); + const result2 = tool.hash(tmpDir, { excludeFolders: ["dist"] }); - expect(hashWithExclude).toBe(hashAfterDistChange); + expect(result1).toEqual(result2); }); it("excludes specified files", () => { writeFileSync(join(tmpDir, "source.ts"), "code"); writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); - const hashWithExclude = tool.hash(tmpDir, { + const result1 = tool.hash(tmpDir, { excludeFiles: ["tsconfig.build.tsbuildinfo"] }); writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "updated-info"); - const hashAfterInfoChange = tool.hash(tmpDir, { + const result2 = tool.hash(tmpDir, { excludeFiles: ["tsconfig.build.tsbuildinfo"] }); - expect(hashWithExclude).toBe(hashAfterInfoChange); + expect(result1).toEqual(result2); }); it("excludes multiple folders and files together", () => { @@ -107,7 +107,7 @@ describe("HashFolderTool", () => { writeFileSync(join(tmpDir, "node_modules", "dep.js"), "dep"); writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); - const hash1 = tool.hash(tmpDir, { + const result1 = tool.hash(tmpDir, { excludeFolders: ["dist", "node_modules"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); @@ -116,12 +116,12 @@ describe("HashFolderTool", () => { writeFileSync(join(tmpDir, "node_modules", "dep.js"), "updated-dep"); writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info"); - const hash2 = tool.hash(tmpDir, { + const result2 = tool.hash(tmpDir, { excludeFolders: ["dist", "node_modules"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); - expect(hash1).toBe(hash2); + expect(result1).toEqual(result2); }); it("is order-independent — same files in different creation order produce same hash", () => { @@ -136,43 +136,43 @@ describe("HashFolderTool", () => { writeFileSync(join(dir2, "b.txt"), "beta"); writeFileSync(join(dir2, "a.txt"), "alpha"); - const hash1 = tool.hash(dir1); - const hash2 = tool.hash(dir2); - expect(hash1).toBe(hash2); + const result1 = tool.hash(dir1); + const result2 = tool.hash(dir2); + expect(result1).toEqual(result2); }); it("returns a hash for an empty folder", () => { const result = tool.hash(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); + expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); it("includes relative path in the hash so renames are detected", () => { writeFileSync(join(tmpDir, "original.txt"), "content"); - const hash1 = tool.hash(tmpDir); + const result1 = tool.hash(tmpDir); rmSync(join(tmpDir, "original.txt")); writeFileSync(join(tmpDir, "renamed.txt"), "content"); - const hash2 = tool.hash(tmpDir); + const result2 = tool.hash(tmpDir); - expect(hash1).not.toBe(hash2); + expect(result1).not.toEqual(result2); }); }); describe("hashAsync (parallel)", () => { - it("returns a hex string for a folder with files", async () => { + it("returns a result with a hex hash", async () => { writeFileSync(join(tmpDir, "a.txt"), "hello"); writeFileSync(join(tmpDir, "b.txt"), "world"); const result = await tool.hashAsync(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); + expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); it("returns a deterministic hash for the same content", async () => { writeFileSync(join(tmpDir, "file.txt"), "content"); - const hash1 = await tool.hashAsync(tmpDir); - const hash2 = await tool.hashAsync(tmpDir); - expect(hash1).toBe(hash2); + const result1 = await tool.hashAsync(tmpDir); + const result2 = await tool.hashAsync(tmpDir); + expect(result1).toEqual(result2); }); it("excludes specified folders and files", async () => { @@ -181,7 +181,7 @@ describe("HashFolderTool", () => { writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info"); - const hash1 = await tool.hashAsync(tmpDir, { + const result1 = await tool.hashAsync(tmpDir, { excludeFolders: ["dist"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); @@ -189,27 +189,27 @@ describe("HashFolderTool", () => { writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info"); - const hash2 = await tool.hashAsync(tmpDir, { + const result2 = await tool.hashAsync(tmpDir, { excludeFolders: ["dist"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); - expect(hash1).toBe(hash2); + expect(result1).toEqual(result2); }); - it("produces the same hash as the sync method", async () => { + it("produces the same result as the sync method", async () => { writeFileSync(join(tmpDir, "a.txt"), "alpha"); mkdirSync(join(tmpDir, "sub"), { recursive: true }); writeFileSync(join(tmpDir, "sub", "b.txt"), "beta"); - const syncHash = tool.hash(tmpDir); - const asyncHash = await tool.hashAsync(tmpDir); - expect(asyncHash).toBe(syncHash); + const syncResult = tool.hash(tmpDir); + const asyncResult = await tool.hashAsync(tmpDir); + expect(asyncResult).toEqual(syncResult); }); it("returns a hash for an empty folder", async () => { const result = await tool.hashAsync(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); + expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); }); }); @@ -230,14 +230,14 @@ describe("createHashFolderTool", () => { const tool = createHashFolderTool(); writeFileSync(join(tmpDir, "file.txt"), "content"); const result = tool.hash(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); + expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); it("creates a working tool (async)", async () => { const tool = createHashFolderTool(); writeFileSync(join(tmpDir, "file.txt"), "content"); const result = await tool.hashAsync(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); + expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); }); @@ -253,10 +253,10 @@ describe("hashFolder (sync standalone)", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("returns a hash string directly", () => { + it("returns a result object", () => { writeFileSync(join(tmpDir, "file.txt"), "content"); const result = hashFolder(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); + expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); it("supports exclude options", () => { @@ -264,22 +264,22 @@ describe("hashFolder (sync standalone)", () => { mkdirSync(join(tmpDir, "dist"), { recursive: true }); writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); - const hash1 = hashFolder(tmpDir, { excludeFolders: ["dist"] }); + const result1 = hashFolder(tmpDir, { excludeFolders: ["dist"] }); writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); - const hash2 = hashFolder(tmpDir, { excludeFolders: ["dist"] }); + const result2 = hashFolder(tmpDir, { excludeFolders: ["dist"] }); - expect(hash1).toBe(hash2); + expect(result1).toEqual(result2); }); - it("produces the same hash as the DI tool", () => { + it("produces the same result as the DI tool", () => { writeFileSync(join(tmpDir, "file.txt"), "content"); const tool = createHashFolderTool(); - const diHash = tool.hash(tmpDir); - const standaloneHash = hashFolder(tmpDir); + const diResult = tool.hash(tmpDir); + const standaloneResult = hashFolder(tmpDir); - expect(standaloneHash).toBe(diHash); + expect(standaloneResult).toEqual(diResult); }); }); @@ -295,10 +295,10 @@ describe("hashFolderAsync (async standalone)", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("returns a hash string", async () => { + it("returns a result object", async () => { writeFileSync(join(tmpDir, "file.txt"), "content"); const result = await hashFolderAsync(tmpDir); - expect(result).toMatch(/^[a-f0-9]{64}$/); + expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) }); }); it("supports exclude options", async () => { @@ -306,22 +306,22 @@ describe("hashFolderAsync (async standalone)", () => { mkdirSync(join(tmpDir, "dist"), { recursive: true }); writeFileSync(join(tmpDir, "dist", "out.js"), "compiled"); - const hash1 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] }); + const result1 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] }); writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled"); - const hash2 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] }); + const result2 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] }); - expect(hash1).toBe(hash2); + expect(result1).toEqual(result2); }); - it("produces the same hash as sync standalone", async () => { + it("produces the same result as sync standalone", async () => { writeFileSync(join(tmpDir, "file.txt"), "content"); mkdirSync(join(tmpDir, "sub"), { recursive: true }); writeFileSync(join(tmpDir, "sub", "nested.txt"), "nested"); - const syncHash = hashFolder(tmpDir); - const asyncHash = await hashFolderAsync(tmpDir); + const syncResult = hashFolder(tmpDir); + const asyncResult = await hashFolderAsync(tmpDir); - expect(asyncHash).toBe(syncHash); + expect(asyncResult).toEqual(syncResult); }); }); diff --git a/src/node/features/HashFolderTool/HashFolderTool.ts b/src/node/features/HashFolderTool/HashFolderTool.ts index 4870b2d..72f560b 100644 --- a/src/node/features/HashFolderTool/HashFolderTool.ts +++ b/src/node/features/HashFolderTool/HashFolderTool.ts @@ -4,7 +4,8 @@ import { readdir, readFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { HashFolderTool as HashFolderToolAbstraction, - type HashFolderOptions + type HashFolderOptions, + type HashFolderResult } from "./abstractions/HashFolderTool.js"; interface FileEntry { @@ -104,14 +105,17 @@ async function collectFilesAsync( } class HashFolderToolImpl implements HashFolderToolAbstraction.Interface { - public hash(folderPath: string, options?: HashFolderOptions): string { + public hash(folderPath: string, options?: HashFolderOptions): HashFolderResult { const excludeFolders = new Set(options?.excludeFolders ?? []); const excludeFiles = new Set(options?.excludeFiles ?? []); const entries = collectFilesSync(folderPath, folderPath, excludeFolders, excludeFiles); - return combineEntries(entries); + return { hash: combineEntries(entries) }; } - public async hashAsync(folderPath: string, options?: HashFolderOptions): Promise { + public async hashAsync( + folderPath: string, + options?: HashFolderOptions + ): Promise { const excludeFolders = new Set(options?.excludeFolders ?? []); const excludeFiles = new Set(options?.excludeFiles ?? []); const entries = await collectFilesAsync( @@ -120,7 +124,7 @@ class HashFolderToolImpl implements HashFolderToolAbstraction.Interface { excludeFolders, excludeFiles ); - return combineEntries(entries); + return { hash: combineEntries(entries) }; } } @@ -136,7 +140,7 @@ export function createHashFolderTool(): HashFolderToolAbstraction.Interface { /** * Standalone sync — computes a SHA-256 hash of a folder's contents. */ -export function hashFolder(folderPath: string, options?: HashFolderOptions): string { +export function hashFolder(folderPath: string, options?: HashFolderOptions): HashFolderResult { return new HashFolderToolImpl().hash(folderPath, options); } @@ -146,6 +150,6 @@ export function hashFolder(folderPath: string, options?: HashFolderOptions): str export async function hashFolderAsync( folderPath: string, options?: HashFolderOptions -): Promise { +): Promise { return new HashFolderToolImpl().hashAsync(folderPath, options); } diff --git a/src/node/features/HashFolderTool/README.md b/src/node/features/HashFolderTool/README.md index 6ca0b18..1cf891c 100644 --- a/src/node/features/HashFolderTool/README.md +++ b/src/node/features/HashFolderTool/README.md @@ -2,16 +2,21 @@ Computes a deterministic SHA-256 hash of a folder's contents. Walks the directory tree recursively, hashes each file individually, sorts entries by relative path for deterministic ordering, then produces a single combined hex digest. Use it to detect whether a folder's contents have changed — for example, to skip redundant builds when source files haven't been modified. -Two methods: `hash` (synchronous) for small-to-medium folders, and `hashAsync` (parallel I/O) for large directory trees where concurrent reads improve throughput. +Two methods: `hash` (synchronous) for small-to-medium folders, and `hashAsync` (parallel I/O) for large directory trees where concurrent reads improve throughput. Both return a `HashFolderResult` object. ## Interface ```ts interface IHashFolderTool { - /** Returns a hex-encoded SHA-256 hash representing the folder's contents (synchronous). */ - hash(folderPath: string, options?: HashFolderOptions): string; + /** Returns a result containing the hex-encoded SHA-256 hash (synchronous). */ + hash(folderPath: string, options?: HashFolderOptions): HashFolderResult; /** Parallel variant — reads files and subdirectories concurrently. */ - hashAsync(folderPath: string, options?: HashFolderOptions): Promise; + hashAsync(folderPath: string, options?: HashFolderOptions): Promise; +} + +interface HashFolderResult { + /** Hex-encoded SHA-256 digest. */ + hash: string; } interface HashFolderOptions { @@ -36,13 +41,13 @@ HashFolderToolFeature.register(container); const tool = container.resolve(HashFolderTool); // Sync -const hash = tool.hash("./packages/my-package", { +const { hash } = tool.hash("./packages/my-package", { excludeFolders: ["dist", "lib", "node_modules"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); // Async (parallel I/O) -const hash = await tool.hashAsync("./packages/my-package", { +const { hash } = await tool.hashAsync("./packages/my-package", { excludeFolders: ["dist", "lib", "node_modules"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); @@ -54,7 +59,7 @@ const hash = await tool.hashAsync("./packages/my-package", { import { createHashFolderTool } from "@webiny/stdlib/node"; const tool = createHashFolderTool(); -const hash = tool.hash("./packages/my-package", { +const { hash } = tool.hash("./packages/my-package", { excludeFolders: ["dist", "node_modules"] }); ``` @@ -65,13 +70,13 @@ const hash = tool.hash("./packages/my-package", { import { hashFolder, hashFolderAsync } from "@webiny/stdlib/node"; // Sync -const hash = hashFolder("./packages/my-package", { +const { hash } = hashFolder("./packages/my-package", { excludeFolders: ["dist", "lib", "node_modules"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); // Async (parallel I/O) -const hash = await hashFolderAsync("./packages/my-package", { +const { hash } = await hashFolderAsync("./packages/my-package", { excludeFolders: ["dist", "lib", "node_modules"], excludeFiles: ["tsconfig.build.tsbuildinfo"] }); diff --git a/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts b/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts index dfe5bbe..d6733c2 100644 --- a/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts +++ b/src/node/features/HashFolderTool/abstractions/HashFolderTool.ts @@ -10,16 +10,24 @@ export interface HashFolderOptions { excludeFiles?: string[]; } +/** + * Result of hashing a folder's contents. + */ +export interface HashFolderResult { + /** Hex-encoded SHA-256 digest. */ + hash: string; +} + /** * Computes a deterministic SHA-256 hash of a folder's contents. * Walks the directory recursively, hashes each file, sorts by relative path, * then produces a single combined hash. */ export interface IHashFolderTool { - /** Returns a hex-encoded SHA-256 hash representing the folder's contents (synchronous). */ - hash(folderPath: string, options?: HashFolderOptions): string; + /** Returns a result containing the hex-encoded SHA-256 hash (synchronous). */ + hash(folderPath: string, options?: HashFolderOptions): HashFolderResult; /** Parallel variant — reads files and subdirectories concurrently. */ - hashAsync(folderPath: string, options?: HashFolderOptions): Promise; + hashAsync(folderPath: string, options?: HashFolderOptions): Promise; } export const HashFolderTool = createAbstraction("Node/HashFolderTool"); diff --git a/src/node/features/HashFolderTool/abstractions/index.ts b/src/node/features/HashFolderTool/abstractions/index.ts index 0f27a38..d0ab691 100644 --- a/src/node/features/HashFolderTool/abstractions/index.ts +++ b/src/node/features/HashFolderTool/abstractions/index.ts @@ -1 +1 @@ -export { HashFolderTool, type HashFolderOptions } from "./HashFolderTool.js"; +export { HashFolderTool, type HashFolderOptions, type HashFolderResult } from "./HashFolderTool.js"; diff --git a/src/node/features/HashFolderTool/index.ts b/src/node/features/HashFolderTool/index.ts index 27067a4..c22b9b5 100644 --- a/src/node/features/HashFolderTool/index.ts +++ b/src/node/features/HashFolderTool/index.ts @@ -1,3 +1,7 @@ -export { HashFolderTool, type HashFolderOptions } from "./abstractions/index.js"; +export { + HashFolderTool, + type HashFolderOptions, + type HashFolderResult +} from "./abstractions/index.js"; export { HashFolderToolFeature } from "./feature.js"; export { createHashFolderTool, hashFolder, hashFolderAsync } from "./HashFolderTool.js"; diff --git a/src/node/index.ts b/src/node/index.ts index 2b7bdbc..9ad3d6f 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -57,5 +57,6 @@ export { createHashFolderTool, hashFolder, hashFolderAsync, - type HashFolderOptions + type HashFolderOptions, + type HashFolderResult } from "./features/HashFolderTool/index.js";