From 0412027ea469ae46c97c889d3fef7ef17a5c518f Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 1 Jul 2026 13:36:21 +0200 Subject: [PATCH 01/15] Surface sampler report URL and quiet CI output Always log the hosted result_url on completion so it can be captured in CI (e.g. echoed into $GITHUB_STEP_SUMMARY) without downloading anything. Only download+open the report locally when not in CI; add --no-open. Use a static poll message instead of the animated spinner in CI to avoid flooding the captured log with thousands of frames. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/cli.js | 9 +++- lib/liquidSamplerRunner.js | 32 +++++++++--- tests/lib/liquidSamplerRunner.test.js | 71 +++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 tests/lib/liquidSamplerRunner.test.js diff --git a/bin/cli.js b/bin/cli.js index 046aaa4..26ca78c 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -532,14 +532,19 @@ program "firmIds", ]) ) + .option("--no-open", "Do not download/open the report locally; only print its URL (default in CI)") .action(async (options) => { + // Commander sets options.open = false when --no-open is passed. + // In CI, never open regardless of the flag. + const runnerOptions = { openReport: options.open && !process.env.CI }; + // If an existing sampler ID is provided, fetch and display results if (options.id) { if (!/^\d+$/.test(options.id)) { consola.error("Invalid sampler ID: must be a numeric value"); process.exit(1); } - await new LiquidSamplerRunner(options.partner).checkStatus(options.id); + await new LiquidSamplerRunner(options.partner, runnerOptions).checkStatus(options.id); return; } @@ -561,7 +566,7 @@ program const firmIds = options.firmIds || []; - await new LiquidSamplerRunner(options.partner).run(templateHandles, firmIds); + await new LiquidSamplerRunner(options.partner, runnerOptions).run(templateHandles, firmIds); }); // Create Liquid Test diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index c34e336..7e9e30d 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -13,8 +13,15 @@ const { SharedPart } = require("./templates/sharedPart"); * Class to run liquid samplers for partner templates */ class LiquidSamplerRunner { - constructor(partnerId) { + /** + * @param {string|number} partnerId - The partner environment id + * @param {Object} [options] + * @param {boolean} [options.openReport] - Whether to download and open the report locally. + * Defaults to false in CI (process.env.CI), true otherwise. The report URL is always logged. + */ + constructor(partnerId, options = {}) { this.partnerId = partnerId; + this.openReport = options.openReport ?? !process.env.CI; } /** @@ -190,7 +197,15 @@ class LiquidSamplerRunner { const pollingDelay = 15000; // 15 seconds const waitingLimit = 3600000; // 1 hour - spinner.spin("Running sampler..."); + // The animated spinner writes a frame on every tick; without a TTY (e.g. CI) + // that floods the captured log with thousands of lines. Use a single static + // line there instead. + const useSpinner = !process.env.CI; + if (useSpinner) { + spinner.spin("Running sampler..."); + } else { + consola.info("Running sampler... (polling for completion)"); + } let waitingTime = 0; try { @@ -240,11 +255,14 @@ class LiquidSamplerRunner { consola.success("Sampler run completed successfully"); if (response && response.result_url) { - await new UrlHandler(response.result_url).openFile(); - } else { - consola.error("Sampler completed but no result URL was returned"); - process.exit(1); - break; // eslint-disable-line no-unreachable + // Always surface the hosted report URL so it can be captured in CI + // (e.g. echoed into $GITHUB_STEP_SUMMARY) without downloading anything. + consola.success(`Sampler report: ${response.result_url}`); + if (this.openReport) { + await new UrlHandler(response.result_url).openFile(); + } + } else { + consola.warn("No URL returned"); } break; diff --git a/tests/lib/liquidSamplerRunner.test.js b/tests/lib/liquidSamplerRunner.test.js new file mode 100644 index 0000000..e2c63b7 --- /dev/null +++ b/tests/lib/liquidSamplerRunner.test.js @@ -0,0 +1,71 @@ +jest.mock("consola"); +jest.mock("../../lib/api/sfApi"); +jest.mock("../../lib/cli/spinner", () => ({ spinner: { spin: jest.fn(), stop: jest.fn() } })); +jest.mock("../../lib/utils/errorUtils", () => ({ + errorHandler: jest.fn(), +})); + +const mockOpenFile = jest.fn(); +jest.mock("../../lib/utils/urlHandler", () => ({ + UrlHandler: jest.fn().mockImplementation(() => ({ openFile: mockOpenFile })), +})); + +const SF = require("../../lib/api/sfApi"); +const { consola } = require("consola"); +const { UrlHandler } = require("../../lib/utils/urlHandler"); +const { LiquidSamplerRunner } = require("../../lib/liquidSamplerRunner"); + +const REPORT_URL = "https://reports.example.com/sampler/abc123.html"; + +describe("LiquidSamplerRunner - surfacing results", () => { + const originalCI = process.env.CI; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.CI; + SF.readSamplerRun.mockResolvedValue({ + data: { status: "completed", result_url: REPORT_URL }, + }); + }); + + afterEach(() => { + if (originalCI === undefined) delete process.env.CI; + else process.env.CI = originalCI; + }); + + it("always logs the report URL on completion", async () => { + await new LiquidSamplerRunner("1").checkStatus("run-1"); + + expect(consola.success).toHaveBeenCalledWith(`Sampler report: ${REPORT_URL}`); + }); + + it("opens the report locally by default (not CI)", async () => { + await new LiquidSamplerRunner("1").checkStatus("run-1"); + + expect(UrlHandler).toHaveBeenCalledWith(REPORT_URL); + expect(mockOpenFile).toHaveBeenCalledTimes(1); + }); + + it("does NOT open the report when running in CI", async () => { + process.env.CI = "true"; + + await new LiquidSamplerRunner("1").checkStatus("run-1"); + + // URL still logged, but nothing is downloaded/opened + expect(consola.success).toHaveBeenCalledWith(`Sampler report: ${REPORT_URL}`); + expect(mockOpenFile).not.toHaveBeenCalled(); + }); + + it("does NOT open the report when openReport is false (--no-open)", async () => { + await new LiquidSamplerRunner("1", { openReport: false }).checkStatus("run-1"); + + expect(consola.success).toHaveBeenCalledWith(`Sampler report: ${REPORT_URL}`); + expect(mockOpenFile).not.toHaveBeenCalled(); + }); + + it("still opens when explicitly requested even outside CI", async () => { + await new LiquidSamplerRunner("1", { openReport: true }).checkStatus("run-1"); + + expect(mockOpenFile).toHaveBeenCalledTimes(1); + }); +}); From a6149cd27fe4d6525bc99f19238764da716bbc59 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Fri, 10 Jul 2026 11:29:03 +0200 Subject: [PATCH 02/15] Add run-sampler --compact: named_results diff extractor The sampler result zip is huge (rendered HTML alone is ~470K tokens; raw per-entry output is ~150 MB), which makes it unusable as a review artifact pasted into a chat/PR. --compact downloads the result, selectively extracts only sample_entry_ids.yml + every registers.json, and prints a compact named_results diff grouped by template handle and deduped by identical change, wrapped in markers for CI extraction. On a 228-entry run this is ~7 KB instead of ~150 MB, and a broken template surfaces plainly as `value -> undefined`. - lib/liquidSamplerCompact.js: pure extractCompact()/formatCompact() - liquidSamplerRunner: compact option; download+extract is best-effort and never fails the run; temp dir cleaned up after - bin/cli.js: --compact flag (works with --id, independent of CI) - adm-zip dependency for selective zip extraction - unit + E2E tests against an in-memory zip of a committed fixture Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/cli.js | 3 +- lib/liquidSamplerCompact.js | Bin 0 -> 7598 bytes lib/liquidSamplerRunner.js | 65 ++++++++++++ package-lock.json | 10 ++ package.json | 1 + .../after/registers.json | 1 + .../before/registers.json | 1 + .../1_100_1000_5000/after/registers.json | 1 + .../1_100_1000_5000/before/registers.json | 1 + .../1_101_1001_5000/after/registers.json | 1 + .../1_101_1001_5000/before/registers.json | 1 + .../1_102_1002_6000/after/registers.json | 1 + .../1_102_1002_6000/before/registers.json | 1 + .../sampler-results/sample_entry_ids.yml | 15 +++ tests/lib/liquidSamplerCompact.test.js | 98 ++++++++++++++++++ tests/lib/liquidSamplerRunner.test.js | 62 +++++++++++ 16 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 lib/liquidSamplerCompact.js create mode 100644 tests/fixtures/sampler-results/output/account_entries/1_103_1003_490000.000/after/registers.json create mode 100644 tests/fixtures/sampler-results/output/account_entries/1_103_1003_490000.000/before/registers.json create mode 100644 tests/fixtures/sampler-results/output/reconciliation_entries/1_100_1000_5000/after/registers.json create mode 100644 tests/fixtures/sampler-results/output/reconciliation_entries/1_100_1000_5000/before/registers.json create mode 100644 tests/fixtures/sampler-results/output/reconciliation_entries/1_101_1001_5000/after/registers.json create mode 100644 tests/fixtures/sampler-results/output/reconciliation_entries/1_101_1001_5000/before/registers.json create mode 100644 tests/fixtures/sampler-results/output/reconciliation_entries/1_102_1002_6000/after/registers.json create mode 100644 tests/fixtures/sampler-results/output/reconciliation_entries/1_102_1002_6000/before/registers.json create mode 100644 tests/fixtures/sampler-results/sample_entry_ids.yml create mode 100644 tests/lib/liquidSamplerCompact.test.js diff --git a/bin/cli.js b/bin/cli.js index 26ca78c..cdcc004 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -533,10 +533,11 @@ program ]) ) .option("--no-open", "Do not download/open the report locally; only print its URL (default in CI)") + .option("--compact", "Download the result and print a compact named_results diff (grouped by template) to stdout - review-friendly and safe in CI") .action(async (options) => { // Commander sets options.open = false when --no-open is passed. // In CI, never open regardless of the flag. - const runnerOptions = { openReport: options.open && !process.env.CI }; + const runnerOptions = { openReport: options.open && !process.env.CI, compact: options.compact || false }; // If an existing sampler ID is provided, fetch and display results if (options.id) { diff --git a/lib/liquidSamplerCompact.js b/lib/liquidSamplerCompact.js new file mode 100644 index 0000000000000000000000000000000000000000..b36dc5e61030b1842e72f9a439231fe83e5ed135 GIT binary patch literal 7598 zcmb7J+iu&~745UXVn;KWQXZLhfEI0?*c~VFpy?!;fo-%M*hWZ+M;2p>RC&nQ8jXrR z^#h6mee7d_{zm^~zM!Abwf8=U7s>7=FA>jWU)H`KPm0`DG_`a@rTKlGmL?cX?O^z9 zXCga`xSDnM_`BWx_T=rG?(Q<4XS}OhJ?=J`64qVM6z{Og&@V$PRP3TNH=WwUAl-UwKS`53WQMNxu(R^*1y{oyA+JNSyK;@0GrNyZh~Nt~Gx#d$(YEf5m^XX3y=v-G2}l;#BWZ&F*C z(uN;wkw-KwN-`e}1XhF+67fegHrO)z@f3$Wj2AP=GcS@lW3*yO8RtNVe=heogOfeJ`OH;`MyZ<~*j`vq;zLA)3jj-uj{Q)8{RFO~8ERCzQ z$p1fD`#Ub;Pv2dfef$2a*Y94PBeX9E8O88e1Y9p`7q~2Tw2%?M?2@9(Vb5XOtRu|N)SS6KB*vwm(ScUvUHMG$}Y<5EQ>;i z%gKv#Km$n5m-BIv1q0>TO?PMFJ4gt?utc;kLwY5=Z|c;t-}Mw=y9Pcj zu)4@yH*ivh1kqm&x20t{*;VNaC?C&hwYyfPvgYG)Wvdd9uCchkGp(UHoCJ=``SLd` zVjzMJX|+R?PDwxM=;(-4_aP2*Xw#rEz~C9qx>Yt&s65i?(h+;Mvu>)vi#p8`pfisb z9ElPVglxZeOe@ObvB^eMml>^9!-pbAw2b7eWQJvO41=yM3yXZr{S{$qW-Wv)0m<m3Kv7Rg=8DBXIw%##QOQnWpPcKxZkZx5>5{W1IHPA zUq2@)THmvL>ql9QI4JoD+qcfyS2#8|6IqaKXu zP-wJN1j%mbgT-;U$@TpuSL~k6)GBM&K8nQv4_6kyBP~halqY2wFQ2bK%V#g&f41Lp z&3|qsH|~w!bxr8{ACha`1gDKLPc4_|I=nf+nwN4+6X&K1F2gWXc|+a_l-h7a8|zB_ zTCz+x1E85eJTn!(ejQqj5PBPmb3D+Ue0`TYE+fWt2#YGyOMG3!u3AXdFO)#M zML@)9?#tIh*SSJ(l-%)&D5Wdqk2=S2C z0@GIdaD=<%{<%dL&kArRg3_DI1~*I3#DPBU&u-0))dik^3FmseD2DQvDV{|AnJ zmtFBEk*(+RxP;rTd@o^N>g08Zv@;)@awI{6eE!(_xlE>sKfgw@Wy%dbm|nxfC3Rox zU76S->+o!@!BReKT8nui&QktUCW%Ipw(Hi3j zXPlAC6tXrF0M^IS5x6j2UbsOUl5u_jxj;Ob`xIvcXG5GgkQSz!3m)$VlmXZGO}!g5 z5X{(ifx~gT(Mh?>h*J>5Oi~-+-|I9SIGmAOyOgA*bW81RxU>9Ik>tjvOzyDy^YXP9 zs%`LW6<0#mUmu341r>b*;PltZsN6Qt`}~NWI`Ms8R^vD)>LvKrNjy6gT0O3VX1BL+ zjUaPX4aM63GgBZ@v<=`pVF>mM~=>H?hwae#H?(etEa7UHG~N~oM&5JJ|*b88e* ztNWW>@d$-dUj;(RU!;MgF$VMqCPMfYJL&ZLL*v}2OiC_D-H zqK=;$UrqIbhTH~q9Kr>CFuazF;T-uixO6U}Xj~8J=$HbZlyuKUjJy8QSJ_QF+w?o* z=M@x*w}q|t@Rn=vQkpp1>f7pwhX#JWT+A>_$ul9|bMT-Dlgglmd^X05Sj7#l{8KpK zyrR(qc9T~S3(d6QbJvK1`w^j~gHB6lp*^~HzmO&F*^9!g;OAtis(4CY^4icV;(lNN zRLrEG>{9Kcf~Mx)kC6^29{cV!thYD`zC0>}cv}G4mIXF!?1^zZsV61~(D?vCQ3^ll zbE-Btj*yOoq?=;Ynr*}6&}k?)7Hw8sYV#oUb=+8W6qxQKd07KPi02zDzSM24R=(>5{;oB#2yzq;9OO)+Hq7wM*^W8# zrOo@`lTYYhfBXACn!lAm8U7246dwv?y7Yfb80s5N_qJ=mxZ*YJH;Ry&x^pRdSI||| zqkI+lOiS^kEUcwZSI#-SBRQL{dw-gE?35J1rx=?C%K^rv0o-@64kMR7oe!!=@_IG6 z%2hXb1s+ToEQLp0C)(X@88vD;7hUh}HpmASHCJOgNfF*7U<4|I%IU%a5WX z+Ie2yram2UyRvC552QC*M|_32E4(XCjSk-Jt8^}IuQyzhJr-WkWmBlN&;RFNuVD{3 z%Yeg%|8@Kz+GFjQ+WIPLE^@uUiVoq;&5|qnaOfZY^ylt|7XPcr>%)~&yzNnM#IF8% k4X>sA=SPlTbWJ)2yL>R>eAK3g(N@pMk2bxDi*w(<0RmFZWdHyG literal 0 HcmV?d00001 diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index 7e9e30d..27552a5 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -1,10 +1,21 @@ +const os = require("os"); +const fs = require("fs"); +const path = require("path"); +const axios = require("axios"); +const AdmZip = require("adm-zip"); const { UrlHandler } = require("./utils/urlHandler"); const errorUtils = require("./utils/errorUtils"); const { spinner } = require("./cli/spinner"); const SF = require("./api/sfApi"); const fsUtils = require("./utils/fsUtils"); +const { extractCompact, formatCompact } = require("./liquidSamplerCompact"); const { consola } = require("consola"); +// Markers wrapping the compact diff on stdout so a CI workflow can extract just +// that section (e.g. to post it as a PR comment) regardless of surrounding logs. +const COMPACT_START = ""; +const COMPACT_END = ""; + const { ReconciliationText } = require("./templates/reconciliationText"); const { AccountTemplate } = require("./templates/accountTemplate"); const { SharedPart } = require("./templates/sharedPart"); @@ -18,10 +29,14 @@ class LiquidSamplerRunner { * @param {Object} [options] * @param {boolean} [options.openReport] - Whether to download and open the report locally. * Defaults to false in CI (process.env.CI), true otherwise. The report URL is always logged. + * @param {boolean} [options.compact] - Whether to download the result, extract the + * `named_results` diff, and print a compact review-friendly summary to stdout. + * Works regardless of CI (unlike openReport). Defaults to false. */ constructor(partnerId, options = {}) { this.partnerId = partnerId; this.openReport = options.openReport ?? !process.env.CI; + this.compact = options.compact ?? false; } /** @@ -261,6 +276,9 @@ class LiquidSamplerRunner { if (this.openReport) { await new UrlHandler(response.result_url).openFile(); } + if (this.compact) { + await this.#printCompactDiff(response.result_url); + } } else { consola.warn("No URL returned"); } @@ -276,6 +294,53 @@ class LiquidSamplerRunner { process.exit(1); } } + + /** + * Download the result zip, extract the named_results diff, and print a compact + * review-friendly summary to stdout (wrapped in markers for CI extraction). + * A failure here never fails the run - the run itself already succeeded and the + * report URL was logged; the compact diff is a best-effort convenience. + * @param {string} resultUrl - Presigned URL to the results.zip + */ + async #printCompactDiff(resultUrl) { + let resultsDir; + try { + resultsDir = await this.#downloadAndExtractResults(resultUrl); + const data = extractCompact(resultsDir); + // Plain stdout (not consola) so the markdown is captured verbatim in CI. + console.log(`\n${COMPACT_START}\n${formatCompact(data)}\n${COMPACT_END}`); + } catch (error) { + consola.warn(`Could not build compact diff (report is still available at the URL above): ${error.message}`); + } finally { + if (resultsDir) { + fs.rmSync(resultsDir, { recursive: true, force: true }); + } + } + } + + /** + * Download the results.zip and selectively extract only the files needed for + * the named_results diff (sample_entry_ids.yml + every registers.json). The + * full archive is ~150 MB; extracting selectively keeps disk/IO minimal. + * @param {string} resultUrl - Presigned URL to the results.zip + * @returns {Promise} Path to a temp directory holding the extracted files + */ + async #downloadAndExtractResults(resultUrl) { + const response = await axios.get(resultUrl, { responseType: "arraybuffer" }); + const zip = new AdmZip(Buffer.from(response.data)); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "silverfin-sampler-")); + for (const entry of zip.getEntries()) { + if (entry.isDirectory) continue; + const name = entry.entryName; + if (name === "sample_entry_ids.yml" || name.endsWith("/registers.json")) { + const dest = path.join(tempDir, name); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, entry.getData()); + } + } + return tempDir; + } } module.exports = { LiquidSamplerRunner }; diff --git a/package-lock.json b/package-lock.json index 6d01319..a74b033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.57.0", "license": "MIT", "dependencies": { + "adm-zip": "^0.5.18", "axios": "^1.6.2", "chalk": "4.1.2", "chokidar": "^3.6.0", @@ -1328,6 +1329,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", diff --git a/package.json b/package.json index f6822db..6453530 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "silverfin": "./node_modules/silverfin-cli/bin/cli.js" }, "dependencies": { + "adm-zip": "^0.5.18", "axios": "^1.6.2", "chalk": "4.1.2", "chokidar": "^3.6.0", diff --git a/tests/fixtures/sampler-results/output/account_entries/1_103_1003_490000.000/after/registers.json b/tests/fixtures/sampler-results/output/account_entries/1_103_1003_490000.000/after/registers.json new file mode 100644 index 0000000..11314e9 --- /dev/null +++ b/tests/fixtures/sampler-results/output/account_entries/1_103_1003_490000.000/after/registers.json @@ -0,0 +1 @@ +{ "firm_id": 1, "named_results": { "balance": "42.0" } } diff --git a/tests/fixtures/sampler-results/output/account_entries/1_103_1003_490000.000/before/registers.json b/tests/fixtures/sampler-results/output/account_entries/1_103_1003_490000.000/before/registers.json new file mode 100644 index 0000000..11314e9 --- /dev/null +++ b/tests/fixtures/sampler-results/output/account_entries/1_103_1003_490000.000/before/registers.json @@ -0,0 +1 @@ +{ "firm_id": 1, "named_results": { "balance": "42.0" } } diff --git a/tests/fixtures/sampler-results/output/reconciliation_entries/1_100_1000_5000/after/registers.json b/tests/fixtures/sampler-results/output/reconciliation_entries/1_100_1000_5000/after/registers.json new file mode 100644 index 0000000..4fea51f --- /dev/null +++ b/tests/fixtures/sampler-results/output/reconciliation_entries/1_100_1000_5000/after/registers.json @@ -0,0 +1 @@ +{ "firm_id": 1, "named_results": { "street_var": null, "zipcode_var": 8200, "ok_var": "keep" } } diff --git a/tests/fixtures/sampler-results/output/reconciliation_entries/1_100_1000_5000/before/registers.json b/tests/fixtures/sampler-results/output/reconciliation_entries/1_100_1000_5000/before/registers.json new file mode 100644 index 0000000..282ad2a --- /dev/null +++ b/tests/fixtures/sampler-results/output/reconciliation_entries/1_100_1000_5000/before/registers.json @@ -0,0 +1 @@ +{ "firm_id": 1, "named_results": { "street_var": "", "zipcode_var": 8200, "ok_var": "keep" } } diff --git a/tests/fixtures/sampler-results/output/reconciliation_entries/1_101_1001_5000/after/registers.json b/tests/fixtures/sampler-results/output/reconciliation_entries/1_101_1001_5000/after/registers.json new file mode 100644 index 0000000..fc85395 --- /dev/null +++ b/tests/fixtures/sampler-results/output/reconciliation_entries/1_101_1001_5000/after/registers.json @@ -0,0 +1 @@ +{ "firm_id": 1, "named_results": { "street_var": null, "zipcode_var": 3000, "ok_var": "keep" } } diff --git a/tests/fixtures/sampler-results/output/reconciliation_entries/1_101_1001_5000/before/registers.json b/tests/fixtures/sampler-results/output/reconciliation_entries/1_101_1001_5000/before/registers.json new file mode 100644 index 0000000..9259288 --- /dev/null +++ b/tests/fixtures/sampler-results/output/reconciliation_entries/1_101_1001_5000/before/registers.json @@ -0,0 +1 @@ +{ "firm_id": 1, "named_results": { "street_var": "", "zipcode_var": 3000, "ok_var": "keep" } } diff --git a/tests/fixtures/sampler-results/output/reconciliation_entries/1_102_1002_6000/after/registers.json b/tests/fixtures/sampler-results/output/reconciliation_entries/1_102_1002_6000/after/registers.json new file mode 100644 index 0000000..0f52b85 --- /dev/null +++ b/tests/fixtures/sampler-results/output/reconciliation_entries/1_102_1002_6000/after/registers.json @@ -0,0 +1 @@ +{ "firm_id": 1, "named_results": { "reserve_total": "100.0" } } diff --git a/tests/fixtures/sampler-results/output/reconciliation_entries/1_102_1002_6000/before/registers.json b/tests/fixtures/sampler-results/output/reconciliation_entries/1_102_1002_6000/before/registers.json new file mode 100644 index 0000000..9bd9d8b --- /dev/null +++ b/tests/fixtures/sampler-results/output/reconciliation_entries/1_102_1002_6000/before/registers.json @@ -0,0 +1 @@ +{ "firm_id": 1, "named_results": { "distributable_at_5": "20615.89", "reserve_total": "100.0" } } diff --git a/tests/fixtures/sampler-results/sample_entry_ids.yml b/tests/fixtures/sampler-results/sample_entry_ids.yml new file mode 100644 index 0000000..194be73 --- /dev/null +++ b/tests/fixtures/sampler-results/sample_entry_ids.yml @@ -0,0 +1,15 @@ +--- +reconciliation_entries: + '1_100_1000_5000': + label: vkt_1 + url: https://example.staging.getsilverfin.com/f/1/100/ledgers/1000/reconciliation_texts/5000 + '1_101_1001_5000': + label: vkt_1 + url: https://example.staging.getsilverfin.com/f/1/101/ledgers/1001/reconciliation_texts/5000 + '1_102_1002_6000': + label: liquidation_reserve + url: https://example.staging.getsilverfin.com/f/1/102/ledgers/1002/reconciliation_texts/6000 +account_entries: + '1_103_1003_490000.000': + label: some_account_template + url: https://example.staging.getsilverfin.com/f/1/103/ledgers/1003/account_templates/490000.000 diff --git a/tests/lib/liquidSamplerCompact.test.js b/tests/lib/liquidSamplerCompact.test.js new file mode 100644 index 0000000..91d1b6c --- /dev/null +++ b/tests/lib/liquidSamplerCompact.test.js @@ -0,0 +1,98 @@ +const path = require("path"); +const { extractCompact, formatCompact, diffNamedResults, readEntryLabels } = require("../../lib/liquidSamplerCompact"); + +const FIXTURE_DIR = path.join(__dirname, "..", "fixtures", "sampler-results"); + +describe("liquidSamplerCompact - diffNamedResults", () => { + it("reports a changed value", () => { + expect(diffNamedResults({ a: "" }, { a: null })).toEqual([{ key: "a", before: '""', after: "null" }]); + }); + + it("distinguishes an explicit null from an absent (removed) key", () => { + // key present with null -> key removed entirely (broken template) + expect(diffNamedResults({ a: null }, {})).toEqual([{ key: "a", before: "null", after: "undefined" }]); + // an added key + expect(diffNamedResults({}, { a: 1 })).toEqual([{ key: "a", before: "undefined", after: "1" }]); + }); + + it("returns nothing when unchanged", () => { + expect(diffNamedResults({ a: "42.0", b: null }, { a: "42.0", b: null })).toEqual([]); + }); + + it("sorts changes by key", () => { + const changes = diffNamedResults({ z: 1, a: 1 }, { z: 2, a: 2 }); + expect(changes.map((c) => c.key)).toEqual(["a", "z"]); + }); +}); + +describe("liquidSamplerCompact - readEntryLabels", () => { + it("maps entry ids to template labels for both entry kinds", () => { + const labels = readEntryLabels(FIXTURE_DIR); + expect(labels["1_100_1000_5000"].label).toBe("vkt_1"); + expect(labels["1_103_1003_490000.000"].label).toBe("some_account_template"); + }); + + it("returns an empty map when the yml is missing", () => { + expect(readEntryLabels(path.join(__dirname, "does-not-exist"))).toEqual({}); + }); +}); + +describe("liquidSamplerCompact - extractCompact", () => { + let data; + beforeAll(() => { + data = extractCompact(FIXTURE_DIR); + }); + + it("counts all sampled entries, including unchanged ones", () => { + expect(data.summary.entriesSampled).toBe(4); + }); + + it("only reports templates with named_results changes", () => { + const labels = data.templates.map((t) => t.label); + expect(labels).toContain("vkt_1"); + expect(labels).toContain("liquidation_reserve"); + // the account template was unchanged - it must not appear + expect(labels).not.toContain("some_account_template"); + expect(data.summary.templatesChanged).toBe(2); + }); + + it("orders templates by number of changed entries (desc)", () => { + expect(data.templates[0].label).toBe("vkt_1"); // 2 entries + expect(data.templates[0].entriesChanged).toBe(2); + }); + + it("dedupes identical changes across entries with a count", () => { + const vkt = data.templates.find((t) => t.label === "vkt_1"); + const streetChange = vkt.changes.find((c) => c.key === "street_var"); + expect(streetChange).toEqual({ key: "street_var", before: '""', after: "null", count: 2 }); + }); + + it("surfaces a broken template (value -> removed) as undefined", () => { + const liq = data.templates.find((t) => t.label === "liquidation_reserve"); + const change = liq.changes.find((c) => c.key === "distributable_at_5"); + expect(change.before).toBe('"20615.89"'); + expect(change.after).toBe("undefined"); + }); + + it("falls back to raw entry id when labels are missing", () => { + // point at a dir with entries but no sample_entry_ids.yml -> label = entry id. + // Reuse the fixture output dir but from a path without the yml alongside. + const noLabels = extractCompact(path.join(FIXTURE_DIR, "..", "sampler-results-no-such")); + expect(noLabels.templates).toEqual([]); + }); +}); + +describe("liquidSamplerCompact - formatCompact", () => { + it("renders a markdown summary with per-template sections", () => { + const md = formatCompact(extractCompact(FIXTURE_DIR)); + expect(md).toContain("Sampler compact diff (named_results)"); + expect(md).toContain("### vkt_1"); + expect(md).toContain("[2ร—] `street_var`: `\"\"` โ†’ `null`"); + expect(md).toContain("### liquidation_reserve"); + }); + + it("renders a clear message when nothing changed", () => { + const md = formatCompact({ summary: { templatesChanged: 0, entriesChanged: 0, entriesSampled: 5 }, templates: [] }); + expect(md).toContain("No `named_results` changes across 5 sampled entries"); + }); +}); diff --git a/tests/lib/liquidSamplerRunner.test.js b/tests/lib/liquidSamplerRunner.test.js index e2c63b7..12b9961 100644 --- a/tests/lib/liquidSamplerRunner.test.js +++ b/tests/lib/liquidSamplerRunner.test.js @@ -1,5 +1,6 @@ jest.mock("consola"); jest.mock("../../lib/api/sfApi"); +jest.mock("axios"); jest.mock("../../lib/cli/spinner", () => ({ spinner: { spin: jest.fn(), stop: jest.fn() } })); jest.mock("../../lib/utils/errorUtils", () => ({ errorHandler: jest.fn(), @@ -10,6 +11,9 @@ jest.mock("../../lib/utils/urlHandler", () => ({ UrlHandler: jest.fn().mockImplementation(() => ({ openFile: mockOpenFile })), })); +const path = require("path"); +const AdmZip = require("adm-zip"); +const axios = require("axios"); const SF = require("../../lib/api/sfApi"); const { consola } = require("consola"); const { UrlHandler } = require("../../lib/utils/urlHandler"); @@ -17,6 +21,16 @@ const { LiquidSamplerRunner } = require("../../lib/liquidSamplerRunner"); const REPORT_URL = "https://reports.example.com/sampler/abc123.html"; +// Build an in-memory results.zip from the committed fixture so the compact path +// can be exercised end-to-end without hitting the backend or shipping a 150 MB zip. +function fixtureZipBuffer() { + const zip = new AdmZip(); + const fixtureDir = path.join(__dirname, "..", "fixtures", "sampler-results"); + zip.addLocalFile(path.join(fixtureDir, "sample_entry_ids.yml")); + zip.addLocalFolder(path.join(fixtureDir, "output"), "output"); + return zip.toBuffer(); +} + describe("LiquidSamplerRunner - surfacing results", () => { const originalCI = process.env.CI; @@ -69,3 +83,51 @@ describe("LiquidSamplerRunner - surfacing results", () => { expect(mockOpenFile).toHaveBeenCalledTimes(1); }); }); + +describe("LiquidSamplerRunner - compact diff", () => { + let logSpy; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.CI = "true"; // compact must work in CI + SF.readSamplerRun.mockResolvedValue({ + data: { status: "completed", result_url: REPORT_URL }, + }); + axios.get.mockResolvedValue({ data: fixtureZipBuffer() }); + logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + delete process.env.CI; + }); + + it("downloads the result and prints the compact diff between markers when compact is set", async () => { + await new LiquidSamplerRunner("1", { compact: true }).checkStatus("run-1"); + + expect(axios.get).toHaveBeenCalledWith(REPORT_URL, { responseType: "arraybuffer" }); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain("### vkt_1"); + expect(output).toContain("[2ร—] `street_var`: `\"\"` โ†’ `null`"); + }); + + it("does NOT download or print a compact diff by default", async () => { + await new LiquidSamplerRunner("1", { openReport: false }).checkStatus("run-1"); + + expect(axios.get).not.toHaveBeenCalled(); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).not.toContain("SAMPLER_COMPACT_START"); + }); + + it("does not fail the run if the compact download fails", async () => { + axios.get.mockRejectedValue(new Error("network down")); + + await new LiquidSamplerRunner("1", { compact: true }).checkStatus("run-1"); + + // URL still surfaced, warning logged, no throw + expect(consola.success).toHaveBeenCalledWith(`Sampler report: ${REPORT_URL}`); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Could not build compact diff")); + }); +}); From b726065d078298a26de245210d171065e4b3695a Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 10:57:49 +0200 Subject: [PATCH 03/15] fix: exit non-zero when a completed sampler run has no result_url The rebase in 58e7747 accidentally reverted this branch from consola.error + process.exit(1) (introduced in cba14f8) back to a bare warn. A completed-but-urlless run now exits 0 and produces no "Sampler report:" line, indistinguishable from a quiet success in CI. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerRunner.js | 3 ++- tests/lib/liquidSamplerRunner.test.js | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index 27552a5..f9e89f5 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -280,7 +280,8 @@ class LiquidSamplerRunner { await this.#printCompactDiff(response.result_url); } } else { - consola.warn("No URL returned"); + consola.error("Sampler completed but no result URL was returned"); + process.exit(1); } break; diff --git a/tests/lib/liquidSamplerRunner.test.js b/tests/lib/liquidSamplerRunner.test.js index 12b9961..be22fcd 100644 --- a/tests/lib/liquidSamplerRunner.test.js +++ b/tests/lib/liquidSamplerRunner.test.js @@ -33,6 +33,7 @@ function fixtureZipBuffer() { describe("LiquidSamplerRunner - surfacing results", () => { const originalCI = process.env.CI; + let originalExit; beforeEach(() => { jest.clearAllMocks(); @@ -40,11 +41,14 @@ describe("LiquidSamplerRunner - surfacing results", () => { SF.readSamplerRun.mockResolvedValue({ data: { status: "completed", result_url: REPORT_URL }, }); + originalExit = process.exit; + process.exit = jest.fn(); }); afterEach(() => { if (originalCI === undefined) delete process.env.CI; else process.env.CI = originalCI; + process.exit = originalExit; }); it("always logs the report URL on completion", async () => { @@ -82,6 +86,16 @@ describe("LiquidSamplerRunner - surfacing results", () => { expect(mockOpenFile).toHaveBeenCalledTimes(1); }); + + it("errors and exits non-zero when completed with no result_url", async () => { + SF.readSamplerRun.mockResolvedValue({ data: { status: "completed" } }); + + await new LiquidSamplerRunner("1").checkStatus("run-1"); + + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining("no result URL")); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockOpenFile).not.toHaveBeenCalled(); + }); }); describe("LiquidSamplerRunner - compact diff", () => { From 790c281bc8b221616db89262f76c613c706c511f Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 10:59:48 +0200 Subject: [PATCH 04/15] fix: reject zip entries that escape the extraction temp dir (zip-slip) An entry name like "../../evil/registers.json" still ends with "/registers.json" and passes the filename filter, but path.join(tempDir, name) resolves outside tempDir. Resolve each destination and skip any entry whose resolved path isn't inside tempDir before writing. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerRunner.js | 14 +++++++++----- tests/lib/liquidSamplerRunner.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index f9e89f5..ac1d878 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -334,11 +334,15 @@ class LiquidSamplerRunner { for (const entry of zip.getEntries()) { if (entry.isDirectory) continue; const name = entry.entryName; - if (name === "sample_entry_ids.yml" || name.endsWith("/registers.json")) { - const dest = path.join(tempDir, name); - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.writeFileSync(dest, entry.getData()); - } + if (name !== "sample_entry_ids.yml" && !name.endsWith("/registers.json")) continue; + + const dest = path.resolve(tempDir, name); + // Reject entries whose name (e.g. "../../etc/passwd") resolves outside + // tempDir - a zip-slip path traversal via a malicious/corrupted archive. + if (dest !== tempDir && !dest.startsWith(tempDir + path.sep)) continue; + + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, entry.getData()); } return tempDir; } diff --git a/tests/lib/liquidSamplerRunner.test.js b/tests/lib/liquidSamplerRunner.test.js index be22fcd..e734237 100644 --- a/tests/lib/liquidSamplerRunner.test.js +++ b/tests/lib/liquidSamplerRunner.test.js @@ -11,6 +11,8 @@ jest.mock("../../lib/utils/urlHandler", () => ({ UrlHandler: jest.fn().mockImplementation(() => ({ openFile: mockOpenFile })), })); +const os = require("os"); +const fs = require("fs"); const path = require("path"); const AdmZip = require("adm-zip"); const axios = require("axios"); @@ -144,4 +146,26 @@ describe("LiquidSamplerRunner - compact diff", () => { expect(consola.success).toHaveBeenCalledWith(`Sampler report: ${REPORT_URL}`); expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Could not build compact diff")); }); + + it("never writes outside the temp dir for a zip-slip entry name", async () => { + const zip = new AdmZip(); + zip.addFile("sample_entry_ids.yml", Buffer.from("")); + // AdmZip normalizes "../" out of entryName on add, so smuggle a raw + // traversal name straight into the entry to simulate a maliciously + // crafted archive that tries to escape the extraction temp dir into a + // sibling directory under the OS temp root (still writable, unlike + // escaping all the way to "/"). + const evilEntry = zip.addFile("registers.json", Buffer.from("{}")); + evilEntry.entryName = "../evil-zip-slip/registers.json"; + axios.get.mockResolvedValue({ data: zip.toBuffer() }); + + const escapedDir = path.join(os.tmpdir(), "evil-zip-slip"); + try { + await new LiquidSamplerRunner("1", { compact: true }).checkStatus("run-1"); + + expect(fs.existsSync(escapedDir)).toBe(false); + } finally { + fs.rmSync(escapedDir, { recursive: true, force: true }); + } + }); }); From f8225f7e82dae7e2bc08c4ff39bf8014246b8991 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:00:28 +0200 Subject: [PATCH 05/15] fix: add a request timeout to the sampler results download axios has no default timeout, so a stalled connection could leave the best-effort compact-diff path (#printCompactDiff) hanging indefinitely. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerRunner.js | 10 +++++++++- tests/lib/liquidSamplerRunner.test.js | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index ac1d878..a702199 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -16,6 +16,11 @@ const { consola } = require("consola"); const COMPACT_START = ""; const COMPACT_END = ""; +// The compact diff download is a best-effort convenience path (see +// #printCompactDiff) - it must fail fast rather than hang indefinitely on a +// stalled connection, since axios has no timeout by default. +const RESULTS_DOWNLOAD_TIMEOUT_MS = 120000; // 2 minutes + const { ReconciliationText } = require("./templates/reconciliationText"); const { AccountTemplate } = require("./templates/accountTemplate"); const { SharedPart } = require("./templates/sharedPart"); @@ -327,7 +332,10 @@ class LiquidSamplerRunner { * @returns {Promise} Path to a temp directory holding the extracted files */ async #downloadAndExtractResults(resultUrl) { - const response = await axios.get(resultUrl, { responseType: "arraybuffer" }); + const response = await axios.get(resultUrl, { + responseType: "arraybuffer", + timeout: RESULTS_DOWNLOAD_TIMEOUT_MS, + }); const zip = new AdmZip(Buffer.from(response.data)); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "silverfin-sampler-")); diff --git a/tests/lib/liquidSamplerRunner.test.js b/tests/lib/liquidSamplerRunner.test.js index e734237..cca494f 100644 --- a/tests/lib/liquidSamplerRunner.test.js +++ b/tests/lib/liquidSamplerRunner.test.js @@ -121,7 +121,10 @@ describe("LiquidSamplerRunner - compact diff", () => { it("downloads the result and prints the compact diff between markers when compact is set", async () => { await new LiquidSamplerRunner("1", { compact: true }).checkStatus("run-1"); - expect(axios.get).toHaveBeenCalledWith(REPORT_URL, { responseType: "arraybuffer" }); + expect(axios.get).toHaveBeenCalledWith(REPORT_URL, { + responseType: "arraybuffer", + timeout: expect.any(Number), + }); const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); expect(output).toContain(""); expect(output).toContain(""); From 001352483315740b2c4288e5d73b25d4e09f9e02 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:01:03 +0200 Subject: [PATCH 06/15] fix: use \x00 escape sequence instead of a literal NUL byte in dedup key lib/liquidSamplerCompact.js:145 embedded a literal raw 0x00 byte between template-literal segments instead of the two-character escape sequence. Functionally harmless (a valid dedup separator), but it makes git/GitHub treat the entire file as binary - `file` reports "data" and `gh pr diff` renders the whole 213-line file as a binary diff, so it can't be reviewed line-by-line and any future change to it would render the same way. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerCompact.js | Bin 7598 -> 7604 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/lib/liquidSamplerCompact.js b/lib/liquidSamplerCompact.js index b36dc5e61030b1842e72f9a439231fe83e5ed135..a0f68eff85c07571a4cb9777d0490df6f1cd4c2e 100644 GIT binary patch delta 37 qcmZ2yy~TP1m#9Qcg@J)eb#g{xUV5rtQfgX$QEDwnY_p8$Bn|-hqzuUb delta 31 mcmdmDz0P_Am#7efN_BEZVqSWxUQ%jWeo<;I!)7JXNgM#GX9{5e From b0399acafa05e0df08b0dac27d66d8982a8ac2e9 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:02:23 +0200 Subject: [PATCH 07/15] fix: key entry label map by kind to avoid account/reconciliation id collisions readEntryLabels keyed its map by raw entry id alone, shared across both account_entries and reconciliation_entries. Their id spaces aren't guaranteed disjoint, so a shared numeric id let the second kind processed silently overwrite the first's label/url, misattributing a changed entry to the wrong template. Key by "${kind}/${entryId}" in both the map and extractCompact's lookup/grouping. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerCompact.js | 11 ++++-- tests/lib/liquidSamplerCompact.test.js | 55 ++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/lib/liquidSamplerCompact.js b/lib/liquidSamplerCompact.js index a0f68ef..c2b35d3 100644 --- a/lib/liquidSamplerCompact.js +++ b/lib/liquidSamplerCompact.js @@ -39,9 +39,11 @@ function renderValue(value) { } /** - * Build a map of entry id -> { label, url } from sample_entry_ids.yml. + * Build a map of "kind/entry id" -> { label, url } from sample_entry_ids.yml. * `label` is the template handle (e.g. "vkt_1"). Returns an empty map if the * file is missing or unparseable - callers fall back to the raw entry id. + * Keyed by kind as well as entry id because account_entries and + * reconciliation_entries id spaces aren't guaranteed disjoint. * @param {string} resultsDir * @returns {Object} */ @@ -60,7 +62,7 @@ function readEntryLabels(resultsDir) { for (const kind of ENTRY_KINDS) { for (const [entryId, meta] of Object.entries(parsed[kind] || {})) { - labelMap[entryId] = { + labelMap[`${kind}/${entryId}`] = { label: (meta && meta.label) || entryId, url: (meta && meta.url) || null, }; @@ -135,12 +137,13 @@ function extractCompact(resultsDir) { const changes = diffNamedResults(before, after); if (changes.length === 0) continue; - const label = (labelMap[entryId] && labelMap[entryId].label) || entryId; + const entryKey = `${kind}/${entryId}`; + const label = (labelMap[entryKey] && labelMap[entryKey].label) || entryId; if (!byTemplate.has(label)) { byTemplate.set(label, { entryIds: new Set(), changeCounts: new Map() }); } const bucket = byTemplate.get(label); - bucket.entryIds.add(entryId); + bucket.entryIds.add(entryKey); for (const change of changes) { const dedupKey = `${change.key}\x00${change.before}\x00${change.after}`; const existing = bucket.changeCounts.get(dedupKey); diff --git a/tests/lib/liquidSamplerCompact.test.js b/tests/lib/liquidSamplerCompact.test.js index 91d1b6c..6eac9c7 100644 --- a/tests/lib/liquidSamplerCompact.test.js +++ b/tests/lib/liquidSamplerCompact.test.js @@ -1,8 +1,34 @@ +const fs = require("fs"); +const os = require("os"); const path = require("path"); const { extractCompact, formatCompact, diffNamedResults, readEntryLabels } = require("../../lib/liquidSamplerCompact"); const FIXTURE_DIR = path.join(__dirname, "..", "fixtures", "sampler-results"); +/** + * Build a minimal results directory (sample_entry_ids.yml + output/) under a + * fresh temp dir, from a plain description of entries per kind. + * @param {Object>} byKind + * @returns {string} path to the built results directory + */ +function buildResultsDir(byKind) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sampler-compact-test-")); + const yml = {}; + for (const [kind, entries] of Object.entries(byKind)) { + yml[kind] = {}; + for (const entry of entries) { + yml[kind][entry.id] = { label: entry.label, url: null }; + for (const phase of ["before", "after"]) { + const entryDir = path.join(dir, "output", kind, entry.id, phase); + fs.mkdirSync(entryDir, { recursive: true }); + fs.writeFileSync(path.join(entryDir, "registers.json"), JSON.stringify({ named_results: entry[phase] })); + } + } + } + fs.writeFileSync(path.join(dir, "sample_entry_ids.yml"), JSON.stringify(yml)); + return dir; +} + describe("liquidSamplerCompact - diffNamedResults", () => { it("reports a changed value", () => { expect(diffNamedResults({ a: "" }, { a: null })).toEqual([{ key: "a", before: '""', after: "null" }]); @@ -26,10 +52,18 @@ describe("liquidSamplerCompact - diffNamedResults", () => { }); describe("liquidSamplerCompact - readEntryLabels", () => { - it("maps entry ids to template labels for both entry kinds", () => { + it("maps kind/entry id to template labels for both entry kinds", () => { + const labels = readEntryLabels(FIXTURE_DIR); + expect(labels["reconciliation_entries/1_100_1000_5000"].label).toBe("vkt_1"); + expect(labels["account_entries/1_103_1003_490000.000"].label).toBe("some_account_template"); + }); + + it("keeps account and reconciliation entries separate when their raw ids match", () => { const labels = readEntryLabels(FIXTURE_DIR); - expect(labels["1_100_1000_5000"].label).toBe("vkt_1"); - expect(labels["1_103_1003_490000.000"].label).toBe("some_account_template"); + // The fixture ids don't collide, but the map must be keyed by kind too so + // that a shared raw id between kinds doesn't overwrite one label with + // the other's. + expect(Object.keys(labels).every((key) => key.includes("/"))).toBe(true); }); it("returns an empty map when the yml is missing", () => { @@ -80,6 +114,21 @@ describe("liquidSamplerCompact - extractCompact", () => { const noLabels = extractCompact(path.join(FIXTURE_DIR, "..", "sampler-results-no-such")); expect(noLabels.templates).toEqual([]); }); + + it("keeps an account entry and a reconciliation entry with the same raw id separate", () => { + const dir = buildResultsDir({ + account_entries: [{ id: "5000", label: "account_tpl", before: { a: "1" }, after: { a: "2" } }], + reconciliation_entries: [{ id: "5000", label: "reco_tpl", before: { b: "1" }, after: { b: "2" } }], + }); + try { + const data = extractCompact(dir); + const labels = data.templates.map((t) => t.label).sort(); + expect(labels).toEqual(["account_tpl", "reco_tpl"]); + expect(data.summary.entriesChanged).toBe(2); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("liquidSamplerCompact - formatCompact", () => { From 74a1f85fe64d8f612941cca10ae68b8bf8730d8b Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:03:50 +0200 Subject: [PATCH 08/15] fix: don't count entries with unreadable registers.json as sampled entriesSampled incremented before checking whether before/after actually parsed, so an entry whose registers.json was missing or malformed still counted as "sampled" despite being silently skipped from the diff - overstating how many entries formatCompact's summary line claims were analyzed. Only count entries that were actually compared, and track/ disclose the skipped count separately. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerCompact.js | 24 +++++++++++++++----- tests/lib/liquidSamplerCompact.test.js | 31 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/liquidSamplerCompact.js b/lib/liquidSamplerCompact.js index c2b35d3..837e7a2 100644 --- a/lib/liquidSamplerCompact.js +++ b/lib/liquidSamplerCompact.js @@ -109,7 +109,7 @@ function diffNamedResults(before, after) { * Walk an extracted results directory and build the compact named_results diff. * @param {string} resultsDir - Path to the extracted results directory * @returns {{ - * summary: {templatesChanged: number, entriesChanged: number, entriesSampled: number}, + * summary: {templatesChanged: number, entriesChanged: number, entriesSampled: number, entriesSkipped: number}, * templates: Array<{label: string, entriesChanged: number, changes: Array<{key: string, before: string, after: string, count: number}>}> * }} */ @@ -120,6 +120,7 @@ function extractCompact(resultsDir) { // label -> { entryIds: Set, changeCounts: Map<"key\0before\0after", {key,before,after,count}> } const byTemplate = new Map(); let entriesSampled = 0; + let entriesSkipped = 0; for (const kind of ENTRY_KINDS) { const kindDir = path.join(outputDir, kind); @@ -128,11 +129,16 @@ function extractCompact(resultsDir) { for (const entryId of fs.readdirSync(kindDir)) { const entryDir = path.join(kindDir, entryId); if (!fs.statSync(entryDir).isDirectory()) continue; - entriesSampled += 1; const before = readNamedResults(path.join(entryDir, "before", "registers.json")); const after = readNamedResults(path.join(entryDir, "after", "registers.json")); - if (before === null || after === null) continue; + if (before === null || after === null) { + // registers.json missing/malformed - this entry was never actually + // compared, so it shouldn't count toward "sampled". + entriesSkipped += 1; + continue; + } + entriesSampled += 1; const changes = diffNamedResults(before, after); if (changes.length === 0) continue; @@ -174,6 +180,7 @@ function extractCompact(resultsDir) { templatesChanged: templates.length, entriesChanged, entriesSampled, + entriesSkipped, }, templates, }; @@ -190,14 +197,21 @@ function formatCompact(data) { lines.push("## ๐Ÿงช Sampler compact diff (named_results)"); lines.push(""); + const skippedNote = + summary.entriesSkipped > 0 + ? `, ${summary.entriesSkipped} skipped (unreadable registers.json)` + : ""; + if (templates.length === 0) { - lines.push(`No \`named_results\` changes across ${summary.entriesSampled} sampled entr${summary.entriesSampled === 1 ? "y" : "ies"}.`); + lines.push( + `No \`named_results\` changes across ${summary.entriesSampled} sampled entr${summary.entriesSampled === 1 ? "y" : "ies"}${skippedNote}.`, + ); return lines.join("\n"); } lines.push( `**${summary.templatesChanged}** template(s) changed across **${summary.entriesChanged}** ` + - `entr${summary.entriesChanged === 1 ? "y" : "ies"} (${summary.entriesSampled} sampled).`, + `entr${summary.entriesChanged === 1 ? "y" : "ies"} (${summary.entriesSampled} sampled${skippedNote}).`, ); for (const template of templates) { diff --git a/tests/lib/liquidSamplerCompact.test.js b/tests/lib/liquidSamplerCompact.test.js index 6eac9c7..80e3eea 100644 --- a/tests/lib/liquidSamplerCompact.test.js +++ b/tests/lib/liquidSamplerCompact.test.js @@ -129,6 +129,29 @@ describe("liquidSamplerCompact - extractCompact", () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + + it("doesn't count an entry with unreadable registers.json as sampled", () => { + const dir = buildResultsDir({ + reconciliation_entries: [{ id: "5000", label: "vkt_1", before: { a: "1" }, after: { a: "2" } }], + }); + // Corrupt the "after" file for a second entry that was never given valid content. + const brokenEntryDir = path.join(dir, "output", "reconciliation_entries", "5001", "after"); + fs.mkdirSync(brokenEntryDir, { recursive: true }); + fs.mkdirSync(path.join(dir, "output", "reconciliation_entries", "5001", "before"), { recursive: true }); + fs.writeFileSync(path.join(brokenEntryDir, "registers.json"), "not json"); + fs.writeFileSync( + path.join(dir, "output", "reconciliation_entries", "5001", "before", "registers.json"), + JSON.stringify({ named_results: {} }), + ); + + try { + const data = extractCompact(dir); + expect(data.summary.entriesSampled).toBe(1); + expect(data.summary.entriesSkipped).toBe(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("liquidSamplerCompact - formatCompact", () => { @@ -144,4 +167,12 @@ describe("liquidSamplerCompact - formatCompact", () => { const md = formatCompact({ summary: { templatesChanged: 0, entriesChanged: 0, entriesSampled: 5 }, templates: [] }); expect(md).toContain("No `named_results` changes across 5 sampled entries"); }); + + it("discloses skipped entries when some registers.json were unreadable", () => { + const md = formatCompact({ + summary: { templatesChanged: 0, entriesChanged: 0, entriesSampled: 5, entriesSkipped: 2 }, + templates: [], + }); + expect(md).toContain("2 skipped (unreadable registers.json)"); + }); }); From f881c72e7444c84e616501b9736b4926f30a2dfa Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:04:53 +0200 Subject: [PATCH 09/15] fix: order-insensitive deep comparison for named_results diff diffNamedResults compared values via JSON.stringify, which is sensitive to object property insertion order - a before/after pair that's semantically identical but was rebuilt through a different code path (e.g. a hash/map rebuild) could report a false-positive change. Compare via a stable stringify that sorts object keys recursively while still treating array element order as significant. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerCompact.js | 22 +++++++++++++++++++++- tests/lib/liquidSamplerCompact.test.js | 15 +++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/liquidSamplerCompact.js b/lib/liquidSamplerCompact.js index 837e7a2..52e85c2 100644 --- a/lib/liquidSamplerCompact.js +++ b/lib/liquidSamplerCompact.js @@ -86,6 +86,26 @@ function readNamedResults(filePath) { } } +/** + * Serialize a value such that two objects with the same keys/values but a + * different property insertion order produce the same string. Array element + * order still matters (a real reordering of a list is a real change). + * @param {*} value + * @returns {string} + */ +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + /** * Diff two named_results objects, returning one entry per changed key. * @param {Object} before @@ -98,7 +118,7 @@ function diffNamedResults(before, after) { for (const key of [...keys].sort()) { const b = Object.hasOwn(before, key) ? before[key] : ABSENT; const a = Object.hasOwn(after, key) ? after[key] : ABSENT; - if (JSON.stringify(b) !== JSON.stringify(a)) { + if (stableStringify(b) !== stableStringify(a)) { changes.push({ key, before: renderValue(b), after: renderValue(a) }); } } diff --git a/tests/lib/liquidSamplerCompact.test.js b/tests/lib/liquidSamplerCompact.test.js index 80e3eea..500ecce 100644 --- a/tests/lib/liquidSamplerCompact.test.js +++ b/tests/lib/liquidSamplerCompact.test.js @@ -49,6 +49,21 @@ describe("liquidSamplerCompact - diffNamedResults", () => { const changes = diffNamedResults({ z: 1, a: 1 }, { z: 2, a: 2 }); expect(changes.map((c) => c.key)).toEqual(["a", "z"]); }); + + it("ignores object key-order differences that don't change content", () => { + const changes = diffNamedResults({ a: { x: 1, y: 2 } }, { a: { y: 2, x: 1 } }); + expect(changes).toEqual([]); + }); + + it("still reports a real change to a nested object value", () => { + const changes = diffNamedResults({ a: { x: 1, y: 2 } }, { a: { y: 2, x: 99 } }); + expect(changes.map((c) => c.key)).toEqual(["a"]); + }); + + it("still treats array element reordering as a real change", () => { + const changes = diffNamedResults({ a: [1, 2] }, { a: [2, 1] }); + expect(changes.map((c) => c.key)).toEqual(["a"]); + }); }); describe("liquidSamplerCompact - readEntryLabels", () => { From ac68517a3bf630055c0575d0c1a6c1a6e517d736 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:26:15 +0200 Subject: [PATCH 10/15] fix: clean up the extraction temp dir when a zip entry write fails tempDir was only ever removed via the caller's finally block, which reads the resultsDir return value - a failure partway through the extraction loop (mkdirSync/getData/writeFileSync) propagates the exception without returning, so the directory is orphaned under the OS temp dir. Wrap the loop and clean up locally before rethrowing. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerRunner.js | 33 ++++++++++++++++----------- tests/lib/liquidSamplerRunner.test.js | 25 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index a702199..9931c71 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -339,20 +339,27 @@ class LiquidSamplerRunner { const zip = new AdmZip(Buffer.from(response.data)); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "silverfin-sampler-")); - for (const entry of zip.getEntries()) { - if (entry.isDirectory) continue; - const name = entry.entryName; - if (name !== "sample_entry_ids.yml" && !name.endsWith("/registers.json")) continue; - - const dest = path.resolve(tempDir, name); - // Reject entries whose name (e.g. "../../etc/passwd") resolves outside - // tempDir - a zip-slip path traversal via a malicious/corrupted archive. - if (dest !== tempDir && !dest.startsWith(tempDir + path.sep)) continue; - - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.writeFileSync(dest, entry.getData()); + try { + for (const entry of zip.getEntries()) { + if (entry.isDirectory) continue; + const name = entry.entryName; + if (name !== "sample_entry_ids.yml" && !name.endsWith("/registers.json")) continue; + + const dest = path.resolve(tempDir, name); + // Reject entries whose name (e.g. "../../etc/passwd") resolves outside + // tempDir - a zip-slip path traversal via a malicious/corrupted archive. + if (dest !== tempDir && !dest.startsWith(tempDir + path.sep)) continue; + + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, entry.getData()); + } + return tempDir; + } catch (error) { + // The caller's finally block only cleans up via the return value, which + // a mid-loop failure never produces - clean up locally before rethrowing. + fs.rmSync(tempDir, { recursive: true, force: true }); + throw error; } - return tempDir; } } diff --git a/tests/lib/liquidSamplerRunner.test.js b/tests/lib/liquidSamplerRunner.test.js index cca494f..303c779 100644 --- a/tests/lib/liquidSamplerRunner.test.js +++ b/tests/lib/liquidSamplerRunner.test.js @@ -171,4 +171,29 @@ describe("LiquidSamplerRunner - compact diff", () => { fs.rmSync(escapedDir, { recursive: true, force: true }); } }); + + it("cleans up the temp dir if extraction fails partway through", async () => { + const zip = new AdmZip(); + zip.addFile("sample_entry_ids.yml", Buffer.from("")); + zip.addFile("output/reconciliation_entries/1/before/registers.json", Buffer.from("{}")); + axios.get.mockResolvedValue({ data: zip.toBuffer() }); + + const realWriteFileSync = fs.writeFileSync; + const mkdtempSpy = jest.spyOn(fs, "mkdtempSync"); + const writeSpy = jest.spyOn(fs, "writeFileSync").mockImplementation((dest, data) => { + if (String(dest).endsWith("registers.json")) throw new Error("disk full"); + return realWriteFileSync(dest, data); + }); + + try { + await new LiquidSamplerRunner("1", { compact: true }).checkStatus("run-1"); + + const tempDir = mkdtempSpy.mock.results[0].value; + expect(fs.existsSync(tempDir)).toBe(false); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Could not build compact diff")); + } finally { + writeSpy.mockRestore(); + mkdtempSpy.mockRestore(); + } + }); }); From 5a68465a40c50f1a14fbfa403404337cf2e69f06 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:27:29 +0200 Subject: [PATCH 11/15] fix: don't merge cross-kind templates that share the same label byTemplate was keyed only by label, so an account template and a reconciliation template with the same label (whether via matching metadata or both falling back to the same raw id when labels are missing) were merged into one bucket, producing an incorrect templatesChanged count and combined changes. Key by kind + label while keeping the label for display, and replace the weak "every key includes a slash" assertion with a real cross-kind id collision test. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerCompact.js | 16 +++++++---- tests/lib/liquidSamplerCompact.test.js | 40 ++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/lib/liquidSamplerCompact.js b/lib/liquidSamplerCompact.js index 52e85c2..8de608b 100644 --- a/lib/liquidSamplerCompact.js +++ b/lib/liquidSamplerCompact.js @@ -137,7 +137,10 @@ function extractCompact(resultsDir) { const labelMap = readEntryLabels(resultsDir); const outputDir = path.join(resultsDir, "output"); - // label -> { entryIds: Set, changeCounts: Map<"key\0before\0after", {key,before,after,count}> } + // JSON.stringify([kind, label]) -> { label, entryIds: Set, changeCounts: Map<"key\0before\0after", {key,before,after,count}> } + // Keyed by kind as well as label because the same label string could + // (however unlikely) apply to both an account and a reconciliation + // template - merging those would combine unrelated entries/changes. const byTemplate = new Map(); let entriesSampled = 0; let entriesSkipped = 0; @@ -165,10 +168,11 @@ function extractCompact(resultsDir) { const entryKey = `${kind}/${entryId}`; const label = (labelMap[entryKey] && labelMap[entryKey].label) || entryId; - if (!byTemplate.has(label)) { - byTemplate.set(label, { entryIds: new Set(), changeCounts: new Map() }); + const templateKey = JSON.stringify([kind, label]); + if (!byTemplate.has(templateKey)) { + byTemplate.set(templateKey, { label, entryIds: new Set(), changeCounts: new Map() }); } - const bucket = byTemplate.get(label); + const bucket = byTemplate.get(templateKey); bucket.entryIds.add(entryKey); for (const change of changes) { const dedupKey = `${change.key}\x00${change.before}\x00${change.after}`; @@ -183,8 +187,8 @@ function extractCompact(resultsDir) { } const templates = [...byTemplate.entries()] - .map(([label, bucket]) => ({ - label, + .map(([, bucket]) => ({ + label: bucket.label, entriesChanged: bucket.entryIds.size, // Most-repeated change first, then alphabetically by key for stability. changes: [...bucket.changeCounts.values()].sort( diff --git a/tests/lib/liquidSamplerCompact.test.js b/tests/lib/liquidSamplerCompact.test.js index 500ecce..4ca6120 100644 --- a/tests/lib/liquidSamplerCompact.test.js +++ b/tests/lib/liquidSamplerCompact.test.js @@ -74,11 +74,21 @@ describe("liquidSamplerCompact - readEntryLabels", () => { }); it("keeps account and reconciliation entries separate when their raw ids match", () => { - const labels = readEntryLabels(FIXTURE_DIR); - // The fixture ids don't collide, but the map must be keyed by kind too so - // that a shared raw id between kinds doesn't overwrite one label with - // the other's. - expect(Object.keys(labels).every((key) => key.includes("/"))).toBe(true); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sampler-compact-test-")); + fs.writeFileSync( + path.join(dir, "sample_entry_ids.yml"), + JSON.stringify({ + account_entries: { 5000: { label: "account_tpl", url: null } }, + reconciliation_entries: { 5000: { label: "reco_tpl", url: null } }, + }), + ); + try { + const labels = readEntryLabels(dir); + expect(labels["account_entries/5000"].label).toBe("account_tpl"); + expect(labels["reconciliation_entries/5000"].label).toBe("reco_tpl"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); it("returns an empty map when the yml is missing", () => { @@ -145,6 +155,26 @@ describe("liquidSamplerCompact - extractCompact", () => { } }); + it("doesn't merge an account entry and a reconciliation entry that share the same label", () => { + const dir = buildResultsDir({ + account_entries: [{ id: "5000", label: "shared_label", before: { a: "1" }, after: { a: "2" } }], + reconciliation_entries: [{ id: "6000", label: "shared_label", before: { b: "1" }, after: { b: "2" } }], + }); + try { + const data = extractCompact(dir); + // Two distinct templates (one per kind), not one merged "shared_label" + // entry combining both entries/changes. + expect(data.templates).toHaveLength(2); + expect(data.summary.templatesChanged).toBe(2); + for (const template of data.templates) { + expect(template.label).toBe("shared_label"); + expect(template.entriesChanged).toBe(1); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("doesn't count an entry with unreadable registers.json as sampled", () => { const dir = buildResultsDir({ reconciliation_entries: [{ id: "5000", label: "vkt_1", before: { a: "1" }, after: { a: "2" } }], From b9f0a9e7fc070e416da8845d5a8908ded7a98572 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:53:18 +0200 Subject: [PATCH 12/15] fix: treat a non-object registers.json as unreadable, not an empty result readNamedResults only returned null (counted as skipped) when JSON.parse threw or the parsed value was null/undefined. A registers.json that parsed fine but wasn't an object (a truncated-mid-write bare string, number, or array) fell through to {} instead, silently counting as sampled with an empty before/after - so real keys on the other side of the pair were reported as spurious ABSENT<->value changes rather than the entry being flagged as unreadable. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerCompact.js | 5 +++++ tests/lib/liquidSamplerCompact.test.js | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/lib/liquidSamplerCompact.js b/lib/liquidSamplerCompact.js index 8de608b..f9c9cd8 100644 --- a/lib/liquidSamplerCompact.js +++ b/lib/liquidSamplerCompact.js @@ -79,6 +79,11 @@ function readEntryLabels(resultsDir) { function readNamedResults(filePath) { try { const registers = JSON.parse(fs.readFileSync(filePath, "utf8")); + // A valid registers.json is always an object. Anything else (a bare + // string/number, an array, or a truncated-to-null file) means the file + // is unreadable, not that named_results happens to be absent. + if (!registers || typeof registers !== "object" || Array.isArray(registers)) return null; + const named = registers.named_results; return named && typeof named === "object" ? named : {}; } catch { diff --git a/tests/lib/liquidSamplerCompact.test.js b/tests/lib/liquidSamplerCompact.test.js index 4ca6120..ce75ad1 100644 --- a/tests/lib/liquidSamplerCompact.test.js +++ b/tests/lib/liquidSamplerCompact.test.js @@ -197,6 +197,27 @@ describe("liquidSamplerCompact - extractCompact", () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + + it("treats a registers.json that parses to a non-object as unreadable, not an empty result", () => { + const dir = buildResultsDir({}); + const entryDir = path.join(dir, "output", "reconciliation_entries", "5002"); + fs.mkdirSync(path.join(entryDir, "before"), { recursive: true }); + fs.mkdirSync(path.join(entryDir, "after"), { recursive: true }); + // Valid JSON, but not an object - e.g. a truncated-mid-write file. + fs.writeFileSync(path.join(entryDir, "before", "registers.json"), "42"); + fs.writeFileSync(path.join(entryDir, "after", "registers.json"), JSON.stringify({ named_results: { a: "1" } })); + + try { + const data = extractCompact(dir); + // Must be counted as skipped (unreadable), not as a sampled entry with + // a spurious ABSENT -> "1" change for key "a". + expect(data.summary.entriesSampled).toBe(0); + expect(data.summary.entriesSkipped).toBe(1); + expect(data.templates).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("liquidSamplerCompact - formatCompact", () => { From 91f754f09b153b2e0bac13d64de7d245c1d1d2d7 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:54:33 +0200 Subject: [PATCH 13/15] fix: neutralize embedded marker text in the compact diff output named_results values are template-controlled and get rendered verbatim between the fixed SAMPLER_COMPACT_START/END markers. A value whose rendered form happens to contain the literal marker text could fool a downstream naive marker-delimited extractor (the markers' stated purpose, e.g. a CI job posting this section as a PR comment) into truncating or misparsing the real section. Escape any literal occurrence of the markers within the diff body before wrapping it. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerRunner.js | 17 ++++++++++++++++- tests/lib/liquidSamplerRunner.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index 9931c71..0dfa4bc 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -16,6 +16,20 @@ const { consola } = require("consola"); const COMPACT_START = ""; const COMPACT_END = ""; +/** + * Neutralize any literal occurrence of our own markers inside arbitrary + * (template-controlled) content. named_results values are rendered verbatim + * into the diff body, so a value that happens to contain the exact marker + * text could otherwise fool a naive marker-delimited extractor (the markers' + * stated purpose) into truncating or misparsing the real section. + * @param {string} text + * @returns {string} + */ +function escapeMarkers(text) { + const escape = (marker) => marker.replace(" injected" } })), + ); + axios.get.mockResolvedValue({ data: zip.toBuffer() }); + + await new LiquidSamplerRunner("1", { compact: true }).checkStatus("run-1"); + + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + const startCount = (output.match(//g) || []).length; + const endCount = (output.match(//g) || []).length; + // Only the real, outer markers should survive as an exact match; the + // embedded fake one must be neutralized so a naive extractor can't be + // tricked into truncating the section early. + expect(startCount).toBe(1); + expect(endCount).toBe(1); + expect(output).toContain("injected"); + }); + it("does NOT download or print a compact diff by default", async () => { await new LiquidSamplerRunner("1", { openReport: false }).checkStatus("run-1"); From bea5ab8d71c6f2c66140f8a6c7f8522f806a0f27 Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 11:56:10 +0200 Subject: [PATCH 14/15] fix: key the poll spinner off stdout.isTTY and add a polling heartbeat useSpinner was based on !process.env.CI, diverging from spinner.js's own !stdout.isTTY guard (spin() silently no-ops when stdout isn't a TTY). In a non-CI, non-TTY invocation (piped output, cron, nohup) this left zero polling feedback: the spinner call no-ops and the consola.info fallback never fires since useSpinner was true. Key off stdout.isTTY instead, matching spinner.js, and log a heartbeat at most once a minute during non-interactive polling so a run can't go silent for up to the full hour-long waitingLimit. Co-Authored-By: Claude Sonnet 5 --- lib/liquidSamplerRunner.js | 21 +++++++-- tests/lib/liquidSamplerRunner.test.js | 66 +++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js index 0dfa4bc..f1400b0 100644 --- a/lib/liquidSamplerRunner.js +++ b/lib/liquidSamplerRunner.js @@ -230,11 +230,15 @@ class LiquidSamplerRunner { let samplerRun = { status: "pending" }; const pollingDelay = 15000; // 15 seconds const waitingLimit = 3600000; // 1 hour - - // The animated spinner writes a frame on every tick; without a TTY (e.g. CI) - // that floods the captured log with thousands of lines. Use a single static - // line there instead. - const useSpinner = !process.env.CI; + const heartbeatInterval = 60000; // log a status line at most once a minute + + // The animated spinner relies on cursor/line control that only works on + // an interactive terminal - it silently no-ops otherwise (see + // lib/cli/spinner.js). Key off the same stdout.isTTY check here (rather + // than process.env.CI) so a non-interactive invocation (piped output, + // cron, nohup - not just CI) always gets a status line instead of + // falling through both the spinner and a one-off log. + const useSpinner = process.stdout.isTTY; if (useSpinner) { spinner.spin("Running sampler..."); } else { @@ -260,6 +264,13 @@ class LiquidSamplerRunner { waitingTime += pollingDelay; + // Without the spinner, a long run would otherwise go completely + // silent for up to waitingLimit - some CI runners' inactivity + // timeouts could interpret that as a hung job. + if (!useSpinner && waitingTime % heartbeatInterval === 0) { + consola.info(`Still running sampler... (${Math.round(waitingTime / 1000)}s elapsed)`); + } + if (waitingTime >= waitingLimit) { // process.exit() bypasses the finally block, so stop the spinner explicitly here. spinner.stop(); diff --git a/tests/lib/liquidSamplerRunner.test.js b/tests/lib/liquidSamplerRunner.test.js index 03fdb3f..95542ea 100644 --- a/tests/lib/liquidSamplerRunner.test.js +++ b/tests/lib/liquidSamplerRunner.test.js @@ -18,6 +18,7 @@ const AdmZip = require("adm-zip"); const axios = require("axios"); const SF = require("../../lib/api/sfApi"); const { consola } = require("consola"); +const { spinner } = require("../../lib/cli/spinner"); const { UrlHandler } = require("../../lib/utils/urlHandler"); const { LiquidSamplerRunner } = require("../../lib/liquidSamplerRunner"); @@ -223,3 +224,68 @@ describe("LiquidSamplerRunner - compact diff", () => { } }); }); + +describe("LiquidSamplerRunner - polling status output", () => { + const originalIsTTY = process.stdout.isTTY; + const originalCI = process.env.CI; + + beforeEach(() => { + jest.clearAllMocks(); + SF.createSamplerRun.mockResolvedValue({ data: { id: "run-1" } }); + }); + + afterEach(() => { + process.stdout.isTTY = originalIsTTY; + if (originalCI === undefined) delete process.env.CI; + else process.env.CI = originalCI; + jest.useRealTimers(); + }); + + it("uses the spinner when stdout is a TTY, regardless of CI", async () => { + process.stdout.isTTY = true; + process.env.CI = "true"; + jest.useFakeTimers(); + SF.readSamplerRun.mockResolvedValue({ data: { status: "completed", result_url: REPORT_URL } }); + + const runPromise = new LiquidSamplerRunner("1").run(); + await jest.advanceTimersByTimeAsync(15000); + await runPromise; + + expect(spinner.spin).toHaveBeenCalledWith("Running sampler..."); + }); + + it("falls back to a log line when stdout is not a TTY, even outside CI", async () => { + process.stdout.isTTY = false; + delete process.env.CI; + jest.useFakeTimers(); + SF.readSamplerRun.mockResolvedValue({ data: { status: "completed", result_url: REPORT_URL } }); + + const runPromise = new LiquidSamplerRunner("1").run(); + await jest.advanceTimersByTimeAsync(15000); + await runPromise; + + expect(spinner.spin).not.toHaveBeenCalled(); + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining("Running sampler...")); + }); + + it("logs a heartbeat during a long non-interactive poll instead of staying silent", async () => { + process.stdout.isTTY = false; + delete process.env.CI; + jest.useFakeTimers(); + SF.readSamplerRun + .mockResolvedValueOnce({ data: { status: "running" } }) + .mockResolvedValueOnce({ data: { status: "running" } }) + .mockResolvedValueOnce({ data: { status: "running" } }) + .mockResolvedValueOnce({ data: { status: "running" } }) + .mockResolvedValueOnce({ data: { status: "completed", result_url: REPORT_URL } }); + + const runPromise = new LiquidSamplerRunner("1").run(); + for (let i = 0; i < 5; i++) { + await jest.advanceTimersByTimeAsync(15000); + } + await runPromise; + + const heartbeats = consola.info.mock.calls.filter(([msg]) => msg.includes("elapsed")); + expect(heartbeats.length).toBeGreaterThanOrEqual(1); + }); +}); From 68e0bc1782747e316338c6c770dda8ab7a4ff98d Mon Sep 17 00:00:00 2001 From: Michiel Degezelle Date: Wed, 15 Jul 2026 13:13:40 +0200 Subject: [PATCH 15/15] Bump version --- CHANGELOG.md | 3 ++ package-lock.json | 132 +++++++++++++++++++++++----------------------- package.json | 2 +- 3 files changed, 70 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d90e317..5a20a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. +## [1.57.1] (15/07/2026) +Improve `silverfin run-sampler` by adding a compact output mode. + ## [1.57.0] (14/07/2026) Added a new `silverfin run-sampler` command to run the Liquid Sampler for partner templates. Specify a partner with `-p` and one or more reconciliation text handles (`-h`), account detail template names (`-at`), and/or shared part names (`-s`), optionally scoping the run to specific firms with `--firm-ids`. Pass `--id ` to fetch and display the results of an existing sampler run instead. diff --git a/package-lock.json b/package-lock.json index a74b033..d6e27f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "silverfin-cli", - "version": "1.57.0", + "version": "1.57.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "silverfin-cli", - "version": "1.57.0", + "version": "1.57.1", "license": "MIT", "dependencies": { "adm-zip": "^0.5.18", @@ -609,9 +609,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -701,9 +701,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -1266,13 +1266,13 @@ } }, "node_modules/@types/node": { - "version": "25.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", - "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@types/stack-utils": { @@ -1300,16 +1300,16 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, "license": "ISC" }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -1435,9 +1435,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", - "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -1584,9 +1584,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.34", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", - "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1609,9 +1609,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1632,9 +1632,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "dev": true, "funding": [ { @@ -1652,10 +1652,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1716,9 +1716,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", "dev": true, "funding": [ { @@ -2056,9 +2056,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.368", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", - "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", "dev": true, "license": "ISC" }, @@ -2553,16 +2553,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3099,9 +3099,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -3638,9 +3638,9 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -3759,9 +3759,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -3936,9 +3936,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -4053,9 +4053,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { @@ -4925,9 +4925,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -5095,9 +5095,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6453530..4d216bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "silverfin-cli", - "version": "1.57.0", + "version": "1.57.1", "description": "Command line tool for Silverfin template development", "main": "index.js", "license": "MIT",