From eb36c16420a50b48864661b69974b129be8c813e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 31 May 2026 08:18:15 +0000 Subject: [PATCH 1/3] fix: detect missing root hash in render command and exit with error When rendering a non-existent hash, the CLI now exits with code 1 and displays an error message instead of silently outputting "cas:" with exit code 0. Changes: - Updated CasNodeNotFoundError constructor signature to store hash - Added root hash existence check in render() and renderAsync() - Updated CLI error handling to catch CasNodeNotFoundError - Added comprehensive test suite for missing hash error handling - Updated existing incorrect tests (R2 and test 1.5) The fix distinguishes between: - Root hash (user-requested): Must exist or throw error - Nested hash (during traversal): Renders as cas: reference (existing behavior) - Resolution below epsilon: Renders as cas: reference (existing behavior) Fixes #53 Co-Authored-By: Claude Opus 4.6 --- packages/cli-json-cas/src/cli.test.ts | 129 +++++++++++++++++++++++- packages/cli-json-cas/src/index.ts | 3 + packages/json-cas/src/render.test.ts | 102 ++++++++++++++++++- packages/json-cas/src/render.ts | 12 +++ packages/json-cas/src/variable-store.ts | 7 +- 5 files changed, 242 insertions(+), 11 deletions(-) diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts index 90146d5..508ebf7 100644 --- a/packages/cli-json-cas/src/cli.test.ts +++ b/packages/cli-json-cas/src/cli.test.ts @@ -23,6 +23,25 @@ async function runCli( return { stdout, stderr, exitCode }; } +async function runCliWithStdin( + args: string[], + storePath: string, + stdin: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const finalArgs = ["bun", entrypoint, "--store", storePath, ...args]; + const proc = Bun.spawn(finalArgs, { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }); + proc.stdin.write(stdin); + proc.stdin.end(); + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + return { stdout, stderr, exitCode }; +} + describe("ucas command alias", () => { test("T1: ucas bin entry exists in package.json", async () => { const pkg = await Bun.file(pkgPath).json(); @@ -287,13 +306,14 @@ describe("ucas render command", () => { const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); try { await runCli(["init"], tmpStore); - const { exitCode, stdout } = await runCli( + const { exitCode, stderr } = await runCli( ["render", "ZZZZZZZZZZZZZ"], tmpStore, ); - // Missing hash renders as cas: reference - expect(exitCode).toBe(0); - expect(stdout).toContain("cas:ZZZZZZZZZZZZZ"); + // Missing hash should exit with error + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Node not found"); + expect(stderr).toContain("ZZZZZZZZZZZZZ"); } finally { rmSync(tmpStore, { recursive: true, force: true }); } @@ -596,4 +616,105 @@ describe("Suite 6: CLI Integration with Templates", () => { rmSync(tmpStore, { recursive: true, force: true }); } }); + + test("R8: render with non-existent hash exits with error", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + const { exitCode, stderr, stdout } = await runCli( + ["render", "AAAAAAAAAAAAA"], + tmpStore, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Node not found"); + expect(stderr).toContain("AAAAAAAAAAAAA"); + expect(stdout).toBe(""); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("R9: render with valid hash exits successfully", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Get @string type hash + const { stdout: typesJson } = await runCli(["types"], tmpStore); + const types = JSON.parse(typesJson); + const stringType = types["@string"]; + + // Create and store a simple string node + const nodeFile = join(tmpStore, "test.json"); + writeFileSync(nodeFile, JSON.stringify("hello world")); + const { stdout: nodeHash } = await runCli( + ["put", stringType, nodeFile], + tmpStore, + ); + + // Render the valid hash + const { exitCode, stdout, stderr } = await runCli( + ["render", nodeHash.trim()], + tmpStore, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("hello world"); + expect(stderr).toBe(""); + expect(stdout).not.toContain("Error"); + expect(stdout).not.toContain("Node not found"); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("R10: render --pipe with valid envelope succeeds", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Get @string type hash + const { stdout: typesJson } = await runCli(["types"], tmpStore); + const types = JSON.parse(typesJson); + const stringType = types["@string"]; + + // Create envelope and pipe to render + const envelope = JSON.stringify({ type: stringType, value: "test" }); + const { exitCode, stdout, stderr } = await runCliWithStdin( + ["render", "--pipe"], + tmpStore, + envelope, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("test"); + expect(stderr).toBe(""); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); + + test("R11: render --pipe with invalid type hash still renders", async () => { + const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-")); + try { + await runCli(["init"], tmpStore); + + // Use invalid type hash in envelope + const envelope = JSON.stringify({ + type: "ZZZZZZZZZZZZZ", + value: "test", + }); + const { exitCode, stdout, stderr } = await runCliWithStdin( + ["render", "--pipe"], + tmpStore, + envelope, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("test"); + expect(stderr).toBe(""); + } finally { + rmSync(tmpStore, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 51bdf4d..2f0bd20 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -486,6 +486,9 @@ async function cmdRender(args: string[]): Promise { process.stdout.write(output); } } catch (error) { + if (error instanceof CasNodeNotFoundError) { + die(`Error: Node not found: ${error.hash}`); + } if (error instanceof Error) { die(error.message); } diff --git a/packages/json-cas/src/render.test.ts b/packages/json-cas/src/render.test.ts index eaf629b..bc06d85 100644 --- a/packages/json-cas/src/render.test.ts +++ b/packages/json-cas/src/render.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test"; import { bootstrap } from "./bootstrap.js"; -import { render, renderDirect } from "./render.js"; +import { render, renderAsync, renderDirect } from "./render.js"; import { putSchema } from "./schema.js"; import { createMemoryStore } from "./store.js"; import type { Hash } from "./types.js"; +import { CasNodeNotFoundError } from "./variable-store.js"; describe("Suite 1: Basic Rendering (No Nesting)", () => { test("1.1 Render Simple Primitives", async () => { @@ -65,13 +66,14 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => { expect(output.trim()).toBe(`cas:${hash}`); }); - test("1.5 Render Non-existent Hash", () => { + test("1.5 Render Non-existent Hash Throws Error", () => { const store = createMemoryStore(); const fakeHash = "ZZZZZZZZZZZZZ" as Hash; - // Non-existent node renders as cas: reference - const output = render(store, fakeHash); - expect(output.trim()).toBe(`cas:${fakeHash}`); + // Non-existent root node should throw + expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError); + expect(() => render(store, fakeHash)).toThrow("Node not found"); + expect(() => render(store, fakeHash)).toThrow(fakeHash); }); }); @@ -1055,3 +1057,93 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => { expect(output).toContain("key: val"); }); }); + +describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => { + test("10.1 renderAsync() throws CasNodeNotFoundError for missing root hash", async () => { + const store = createMemoryStore(); + await bootstrap(store); + const fakeHash = "AAAAAAAAAAAAA" as Hash; + + await expect(renderAsync(store, fakeHash)).rejects.toThrow( + CasNodeNotFoundError, + ); + await expect(renderAsync(store, fakeHash)).rejects.toThrow("Node not found"); + await expect(renderAsync(store, fakeHash)).rejects.toThrow(fakeHash); + }); + + test("10.2 render() throws CasNodeNotFoundError for missing root hash", () => { + const store = createMemoryStore(); + const fakeHash = "ZZZZZZZZZZZZZ" as Hash; + + expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError); + expect(() => render(store, fakeHash)).toThrow("Node not found"); + expect(() => render(store, fakeHash)).toThrow(fakeHash); + }); + + test("10.3 renderDirect() does NOT throw for non-existent type hash", () => { + const store = createMemoryStore(); + const fakeTypeHash = "0000000000000" as Hash; + const output = renderDirect(fakeTypeHash, { key: "value" }, store, null); + + expect(output).toContain("key: value"); + }); + + test("10.4 Missing nested node renders as cas: reference (no error)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const parentSchema = await putSchema(store, { + type: "object", + properties: { + title: { type: "string" }, + child: { type: "string", format: "cas_ref" }, + }, + }); + + const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash; + const parentHash = await store.put(parentSchema, { + title: "root", + child: fakeChildHash, + }); + + const output = render(store, parentHash); + + expect(output).toContain("title: root"); + expect(output).toContain(`cas:${fakeChildHash}`); + }); + + test("10.5 Resolution below epsilon renders as cas: reference (no error)", async () => { + const store = createMemoryStore(); + await bootstrap(store); + + const nodeSchema = await putSchema(store, { + type: "object", + properties: { + level: { type: "number" }, + next: { + anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }], + }, + }, + }); + + // Create 3-level chain + let currentHash: Hash | null = null; + for (let i = 2; i >= 0; i--) { + currentHash = await store.put(nodeSchema, { + level: i, + next: currentHash, + }); + } + + const output = render(store, currentHash as Hash, { + resolution: 1.0, + decay: 0.1, + epsilon: 0.5, + }); + + // Level 0 should be expanded (resolution = 1.0 > 0.5) + expect(output).toContain("level: 0"); + // Level 1+ should be cas: references (0.1, 0.01 < 0.5) + expect(output).toContain("cas:"); + }); +}); diff --git a/packages/json-cas/src/render.ts b/packages/json-cas/src/render.ts index 85d163c..d935e74 100644 --- a/packages/json-cas/src/render.ts +++ b/packages/json-cas/src/render.ts @@ -1,6 +1,7 @@ import { renderWithTemplate } from "./liquid-render.js"; import { collectRefs, getSchema, putSchema, refs } from "./schema.js"; import type { Hash, Store } from "./types.js"; +import { CasNodeNotFoundError } from "./variable-store.js"; import type { VariableStore } from "./variable-store.js"; export type RenderOptions = { @@ -55,6 +56,11 @@ export function render( ): string { const { resolution, decay, epsilon } = validateAndExtractOptions(options); + // Check if root node exists + if (store.get(hash) === null) { + throw new CasNodeNotFoundError(hash); + } + const visited = new Set(); return renderNode(store, hash, resolution, decay, epsilon, visited); } @@ -70,6 +76,12 @@ export async function renderAsync( options?: RenderOptions, ): Promise { const { resolution, decay, epsilon } = validateAndExtractOptions(options); + + // Check if root node exists + if (store.get(hash) === null) { + throw new CasNodeNotFoundError(hash); + } + const varStore = options?.varStore; // If varStore provided, try template rendering first diff --git a/packages/json-cas/src/variable-store.ts b/packages/json-cas/src/variable-store.ts index 2f1808b..cd421e3 100644 --- a/packages/json-cas/src/variable-store.ts +++ b/packages/json-cas/src/variable-store.ts @@ -36,8 +36,11 @@ export class SchemaMismatchError extends Error { } export class CasNodeNotFoundError extends Error { - constructor(hash: string) { - super(`CAS node not found: ${hash}`); + constructor( + public readonly hash: string, + message?: string, + ) { + super(message ?? `Node not found: ${hash}`); this.name = "CasNodeNotFoundError"; } } -- 2.43.0 From 0b72c9400f077eeb1a16ff2d323257d3fff1c796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 31 May 2026 08:21:10 +0000 Subject: [PATCH 2/3] style: apply biome formatting fixes Co-Authored-By: Claude Opus 4.6 --- packages/json-cas/src/render.test.ts | 4 +++- packages/json-cas/src/render.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/json-cas/src/render.test.ts b/packages/json-cas/src/render.test.ts index bc06d85..3c224d9 100644 --- a/packages/json-cas/src/render.test.ts +++ b/packages/json-cas/src/render.test.ts @@ -1067,7 +1067,9 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => { await expect(renderAsync(store, fakeHash)).rejects.toThrow( CasNodeNotFoundError, ); - await expect(renderAsync(store, fakeHash)).rejects.toThrow("Node not found"); + await expect(renderAsync(store, fakeHash)).rejects.toThrow( + "Node not found", + ); await expect(renderAsync(store, fakeHash)).rejects.toThrow(fakeHash); }); diff --git a/packages/json-cas/src/render.ts b/packages/json-cas/src/render.ts index d935e74..92dfe53 100644 --- a/packages/json-cas/src/render.ts +++ b/packages/json-cas/src/render.ts @@ -1,8 +1,8 @@ import { renderWithTemplate } from "./liquid-render.js"; import { collectRefs, getSchema, putSchema, refs } from "./schema.js"; import type { Hash, Store } from "./types.js"; -import { CasNodeNotFoundError } from "./variable-store.js"; import type { VariableStore } from "./variable-store.js"; +import { CasNodeNotFoundError } from "./variable-store.js"; export type RenderOptions = { resolution?: number; // (0, 1], default 1.0 -- 2.43.0 From fc869cfc9906dabf05310536a22305b0b13e50be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 31 May 2026 08:36:19 +0000 Subject: [PATCH 3/3] fix: resolve test failures for issue #53 Applied tester feedback to fix 5 test failures: 1. Updated error message format from "Node not found" to "CAS node not found" for consistency with existing tests in variable-store.test.ts and var.test.ts 2. Fixed CLI tests R9 and R10 to use bootstrap() directly instead of non-existent "types" command. Added imports for bootstrap and createFsStore. 3. Fixed render test 6.5 to pass actual schema Hash instead of entire bootstrap object (Record) 4. Updated test expectations in render.test.ts (tests 1.5, 10.1, 10.2) to match new error message format All 390 tests now pass. Core functionality verified: - Missing root hash detection working correctly - CLI exits with code 1 on missing hash - Error message includes hash: "CAS node not found: " - Nested nodes still render as cas: references (preserved behavior) - Resolution decay behavior preserved Fixes #53 Co-Authored-By: Claude Opus 4.6 --- packages/cli-json-cas/src/cli.test.ts | 14 ++++++++------ packages/json-cas/src/render.test.ts | 11 ++++++----- packages/json-cas/src/variable-store.ts | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts index 508ebf7..c284218 100644 --- a/packages/cli-json-cas/src/cli.test.ts +++ b/packages/cli-json-cas/src/cli.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { bootstrap } from "@uncaged/json-cas"; +import { createFsStore } from "@uncaged/json-cas-fs"; const pkgPath = resolve(import.meta.dir, "../package.json"); const entrypoint = resolve(import.meta.dir, "index.ts"); @@ -639,9 +641,9 @@ describe("Suite 6: CLI Integration with Templates", () => { try { await runCli(["init"], tmpStore); - // Get @string type hash - const { stdout: typesJson } = await runCli(["types"], tmpStore); - const types = JSON.parse(typesJson); + // Get @string type hash via bootstrap + const store = createFsStore(tmpStore); + const types = await bootstrap(store); const stringType = types["@string"]; // Create and store a simple string node @@ -673,9 +675,9 @@ describe("Suite 6: CLI Integration with Templates", () => { try { await runCli(["init"], tmpStore); - // Get @string type hash - const { stdout: typesJson } = await runCli(["types"], tmpStore); - const types = JSON.parse(typesJson); + // Get @string type hash via bootstrap + const store = createFsStore(tmpStore); + const types = await bootstrap(store); const stringType = types["@string"]; // Create envelope and pipe to render diff --git a/packages/json-cas/src/render.test.ts b/packages/json-cas/src/render.test.ts index 3c224d9..4ace36d 100644 --- a/packages/json-cas/src/render.test.ts +++ b/packages/json-cas/src/render.test.ts @@ -72,7 +72,7 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => { // Non-existent root node should throw expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError); - expect(() => render(store, fakeHash)).toThrow("Node not found"); + expect(() => render(store, fakeHash)).toThrow("CAS node not found"); expect(() => render(store, fakeHash)).toThrow(fakeHash); }); }); @@ -795,9 +795,10 @@ describe("Suite 6: Schema Integration", () => { test("6.5 Schema-less Node (Bootstrap Node)", async () => { const store = createMemoryStore(); - const metaHash = await bootstrap(store); + const types = await bootstrap(store); + const schemaHash = types["@schema"]; - const output = render(store, metaHash); + const output = render(store, schemaHash); // Should render without recursive expansion expect(output).toBeTruthy(); @@ -1068,7 +1069,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => { CasNodeNotFoundError, ); await expect(renderAsync(store, fakeHash)).rejects.toThrow( - "Node not found", + "CAS node not found", ); await expect(renderAsync(store, fakeHash)).rejects.toThrow(fakeHash); }); @@ -1078,7 +1079,7 @@ describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => { const fakeHash = "ZZZZZZZZZZZZZ" as Hash; expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError); - expect(() => render(store, fakeHash)).toThrow("Node not found"); + expect(() => render(store, fakeHash)).toThrow("CAS node not found"); expect(() => render(store, fakeHash)).toThrow(fakeHash); }); diff --git a/packages/json-cas/src/variable-store.ts b/packages/json-cas/src/variable-store.ts index cd421e3..520bfd6 100644 --- a/packages/json-cas/src/variable-store.ts +++ b/packages/json-cas/src/variable-store.ts @@ -40,7 +40,7 @@ export class CasNodeNotFoundError extends Error { public readonly hash: string, message?: string, ) { - super(message ?? `Node not found: ${hash}`); + super(message ?? `CAS node not found: ${hash}`); this.name = "CasNodeNotFoundError"; } } -- 2.43.0