Compare commits

...

6 Commits

Author SHA1 Message Date
xiaoju 0aa9074de6 fix: validate schema exists in json-cas put command
Add schema existence check before storing nodes to prevent data
corruption from invalid schema references. The put command now
properly rejects non-existent schema hashes and @aliases with
clear error messages.

Changes:
- Add store.has(typeHash) check in cmdPut before store.put()
- Return exit code 1 with "Schema not found" error message
- Add 11 comprehensive tests covering error and regression cases
- All 392 tests pass, no regressions

Fixes #51

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:31:59 +00:00
xiaomo 2932aa5980 Merge pull request 'feat: ucas render --pipe/-p for stdin { type, value } input' (#49) from feat/48-render-pipe into main 2026-05-31 07:47:01 +00:00
xiaoju a0d7b67923 fix: address PR #49 review feedback
- Convention: renderDirect uses Store | null, options | null (no ?:)
- Validation: hash format check (13-char Crockford Base32) on stdin input
- DRY: remove collectRefsFromSchema, import collectRefs from schema.ts
- DRY: extract validateAndExtractOptions shared by render/renderAsync/renderDirect
- stdin: use process.stdin async iteration instead of /dev/stdin
- UX: error on --pipe + hash conflict instead of silent ignore
- Tests: add 9.10 (store present, schema missing)
2026-05-31 07:43:25 +00:00
xiaoju 7b29fe777c chore: remove stale temp files 2026-05-31 07:34:16 +00:00
xiaoju 64b8a88bdc feat: add renderDirect() and ucas render --pipe/-p
In-memory rendering of { type, value } envelopes without store writes.
Store is optional and read-only (for expanding nested cas_ref references).

CLI: ucas render --pipe/-p reads JSON from stdin.
Core: renderDirect(typeHash, value, store?, options?) for programmatic use.

Fixes #48
2026-05-31 07:34:07 +00:00
xiaoju 4717024e9b Merge pull request 'chore: Phase 4 cleanup — dedupe types, remove unused params, fix tests' (#47) from fix/46-phase4-cleanup into main 2026-05-31 07:19:42 +00:00
6 changed files with 476 additions and 48 deletions
+201
View File
@@ -315,6 +315,207 @@ describe("ucas render command", () => {
});
});
describe("Schema Hash Validation in put command", () => {
test("put with non-existent literal hash should fail", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ test: "data" }));
const { stderr, exitCode } = await runCliAlias(
"put",
"AAAAAAAAAAAAA",
payloadFile,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found: AAAAAAAAAAAAA");
});
test("put with different non-existent literal hash should fail", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ test: "data" }));
const { stderr, exitCode } = await runCliAlias(
"put",
"ZZZZZZZZZZZZZ",
payloadFile,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found: ZZZZZZZZZZZZZ");
});
test("put with malformed hash should fail", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ test: "data" }));
const { stderr, exitCode } = await runCliAlias(
"put",
"INVALID_HASH",
payloadFile,
);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
});
test("put with non-existent @alias should fail with clear message", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ test: "data" }));
const { stderr, exitCode } = await runCliAlias(
"put",
"@nonexistent",
payloadFile,
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Schema not found");
expect(stderr).toContain("@nonexistent");
});
test("put with valid @string alias should succeed (regression)", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify("hello"));
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@string",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("put with valid @number alias should succeed (regression)", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, "42");
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@number",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("put with valid @object alias should succeed (regression)", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ key: "value" }));
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@object",
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("put with explicit valid schema hash should succeed (regression)", async () => {
await runCliAlias("init");
// First, create a custom schema
const schemaFile = join(testDir, "schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: { name: { type: "string" } },
}),
);
const { stdout: schemaHash } = await runCliAlias(
"schema",
"put",
schemaFile,
);
// Now create a node with that schema
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ name: "test" }));
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
schemaHash.trim(),
payloadFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("all bootstrap schema aliases should work (regression)", async () => {
await runCliAlias("init");
const aliases = ["@string", "@number", "@object", "@array", "@bool"];
const payloads = [
JSON.stringify("test"),
"123",
JSON.stringify({}),
JSON.stringify([]),
"true",
];
for (let i = 0; i < aliases.length; i++) {
const payloadFile = join(testDir, `payload-${i}.json`);
writeFileSync(payloadFile, payloads[i] ?? "");
const { exitCode } = await runCliAlias(
"put",
aliases[i] ?? "",
payloadFile,
);
expect(exitCode).toBe(0);
}
});
test("error message should preserve original input (hash)", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ test: "data" }));
const { stderr } = await runCliAlias("put", "AAAAAAAAAAAAA", payloadFile);
// Error should show the original hash, not a resolved version
expect(stderr).toContain("AAAAAAAAAAAAA");
});
test("error message should preserve original input (alias)", async () => {
await runCliAlias("init");
const payloadFile = join(testDir, "payload.json");
writeFileSync(payloadFile, JSON.stringify({ test: "data" }));
const { stderr } = await runCliAlias("put", "@invalid", payloadFile);
// Error should show the original alias, not a resolved hash
expect(stderr).toContain("@invalid");
});
});
describe("Suite 6: CLI Integration with Templates", () => {
test("6.1 CLI with Template (Default Parameters)", async () => {
const tmpStore = mkdtempSync(join(tmpdir(), "json-cas-test-"));
+75 -12
View File
@@ -16,6 +16,7 @@ import {
putSchema,
refs,
renderAsync,
renderDirect,
TagLabelConflictError,
VariableNotFoundError,
validate,
@@ -293,6 +294,12 @@ async function cmdPut(args: string[]): Promise<void> {
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const store = openStore();
// Check if schema exists before storing
if (!store.has(typeHash)) {
die(`Schema not found: ${typeHashOrAlias}`);
}
const hash = await store.put(typeHash, payload);
console.log(hash);
}
@@ -386,10 +393,16 @@ async function cmdHash(args: string[]): Promise<void> {
}
async function cmdRender(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const hash = args[0];
if (!hash) {
if (isPipe && hash) {
die("Cannot use --pipe/-p with a hash argument. Use one or the other.");
}
if (!isPipe && !hash) {
die(
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]",
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]\n ucas render --pipe/-p [--resolution <n>] [--decay <n>] [--epsilon <n>]",
);
}
@@ -421,15 +434,63 @@ async function cmdRender(args: string[]): Promise<void> {
}
try {
const varStore = openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
epsilon,
varStore,
});
// Output to stdout without JSON wrapping (raw output)
process.stdout.write(output);
if (isPipe) {
// Read { type, value } JSON from stdin
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const input = Buffer.concat(chunks).toString("utf-8").trim();
if (!input) {
die("No input on stdin. Pipe a { type, value } JSON envelope.");
}
let envelope: { type: string; value: unknown };
try {
envelope = JSON.parse(input) as { type: string; value: unknown };
} catch {
die("Invalid JSON on stdin. Expected { type, value } envelope.");
return; // unreachable, for TS
}
if (
typeof envelope !== "object" ||
envelope === null ||
typeof envelope.type !== "string" ||
!("value" in envelope)
) {
die("Invalid envelope. Expected { type: string, value: unknown }.");
}
// Validate type hash format: 13-char uppercase Crockford Base32
if (!/^[0-9A-Z]{13}$/.test(envelope.type)) {
die(
`Invalid type hash: "${envelope.type}". Expected 13-character uppercase Crockford Base32 string.`,
);
}
const output = renderDirect(
envelope.type as Hash,
envelope.value,
store,
{
resolution,
decay,
epsilon,
},
);
process.stdout.write(output);
} else {
const varStore = openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
epsilon,
varStore,
});
// Output to stdout without JSON wrapping (raw output)
process.stdout.write(output);
}
} catch (error) {
if (error instanceof Error) {
die(error.message);
@@ -835,6 +896,7 @@ Commands:
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
render <hash> [options] Render node as YAML with resolution decay
render --pipe/-p [options] Render { type, value } from stdin
cat <hash> [--payload] Output node (--payload for payload only)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
@@ -856,7 +918,8 @@ Flags:
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)`);
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read { type, value } JSON from stdin for render`);
}
// ---- Dispatch ----
+6 -1
View File
@@ -5,7 +5,12 @@ export { cborEncode } from "./cbor.js";
export { type GcStats, gc } from "./gc.js";
export { computeHash, computeSelfHash } from "./hash.js";
export { renderWithTemplate } from "./liquid-render.js";
export { type RenderOptions, render, renderAsync } from "./render.js";
export {
type RenderOptions,
render,
renderAsync,
renderDirect,
} from "./render.js";
export type { JSONSchema } from "./schema.js";
export {
getSchema,
+123 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { render } from "./render.js";
import { render, renderDirect } from "./render.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
import type { Hash } from "./types.js";
@@ -933,3 +933,125 @@ describe("Suite 8: Performance & Edge Cases", () => {
expect(output).toContain("🌍");
});
});
describe("Suite 9: renderDirect (in-memory rendering)", () => {
test("9.1 Render primitive value without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, "hello world", null, null);
expect(output.trim()).toBe("hello world");
});
test("9.2 Render object value without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(
fakeTypeHash,
{
name: "Alice",
age: 30,
},
null,
null,
);
expect(output).toContain("name: Alice");
expect(output).toContain("age: 30");
});
test("9.3 Render array value without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, ["a", "b", "c"], null, null);
expect(output).toContain("-");
expect(output).toContain("a");
expect(output).toContain("b");
expect(output).toContain("c");
});
test("9.4 Render nested object without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(
fakeTypeHash,
{
user: { name: "Bob", role: "admin" },
active: true,
},
null,
null,
);
expect(output).toContain("name: Bob");
expect(output).toContain("role: admin");
expect(output).toContain("active: true");
});
test("9.5 Render with store expands cas_ref fields", async () => {
const store = createMemoryStore();
await bootstrap(store);
// Create a child node
const childSchema = await putSchema(store, {
type: "object",
properties: { msg: { type: "string" } },
});
const childHash = await store.put(childSchema, { msg: "inner" });
// Parent schema with cas_ref
const parentSchema = await putSchema(store, {
type: "object",
properties: {
child: { type: "string", format: "cas_ref" },
},
});
// Render directly with store — cas_ref should expand
const output = renderDirect(
parentSchema,
{ child: childHash },
store,
null,
);
expect(output).toContain("msg: inner");
});
test("9.6 Render with resolution/decay options", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, { key: "value" }, null, {
resolution: 0.5,
decay: 0.8,
});
expect(output).toContain("key: value");
});
test("9.7 Validate parameters", () => {
const fakeTypeHash = "0000000000000" as Hash;
expect(() =>
renderDirect(fakeTypeHash, "x", null, { resolution: 2 }),
).toThrow("resolution must be in [0, 1]");
expect(() => renderDirect(fakeTypeHash, "x", null, { decay: 0 })).toThrow(
"decay must be in (0, 1]",
);
expect(() =>
renderDirect(fakeTypeHash, "x", null, { epsilon: -1 }),
).toThrow("epsilon must be >= 0");
});
test("9.8 Render null value", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, null, null, null);
expect(output.trim()).toBe("null");
});
test("9.9 cas_ref without store renders as cas: reference", () => {
// Without store, can't identify cas_ref fields — hash strings stay as strings
const fakeTypeHash = "0000000000000" as Hash;
const someHash = "ABCDEFGH12345" as Hash;
const output = renderDirect(fakeTypeHash, { ref: someHash }, null, null);
// Without store, it's just a string value
expect(output).toContain(`ref: ${someHash}`);
});
test("9.10 store present but schema missing — renders without ref expansion", async () => {
const store = createMemoryStore();
await bootstrap(store);
const unknownType = "ZZZZZZZZZZZZ0" as Hash;
const output = renderDirect(unknownType, { key: "val" }, store, null);
expect(output).toContain("key: val");
});
});
+70 -33
View File
@@ -1,5 +1,5 @@
import { renderWithTemplate } from "./liquid-render.js";
import { putSchema, refs } from "./schema.js";
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
@@ -16,6 +16,32 @@ const DEFAULT_EPSILON = 0.01;
// Small tolerance for floating point comparison
const FLOAT_TOLERANCE = 1e-10;
/**
* Extract and validate resolution/decay/epsilon from options.
*/
function validateAndExtractOptions(
options:
| Pick<RenderOptions, "resolution" | "decay" | "epsilon">
| null
| undefined,
): { resolution: number; decay: number; epsilon: number } {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
return { resolution, decay, epsilon };
}
/**
* Render a CAS node as YAML with resolution-based decay.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
@@ -27,20 +53,7 @@ export function render(
hash: Hash,
options?: RenderOptions,
): string {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
@@ -56,22 +69,9 @@ export async function renderAsync(
hash: Hash,
options?: RenderOptions,
): Promise<string> {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
const varStore = options?.varStore;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
// If varStore provided, try template rendering first
if (varStore !== undefined) {
try {
@@ -97,6 +97,43 @@ export async function renderAsync(
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
/**
* Render a value directly (in-memory) without requiring it to be stored.
* Accepts a raw { type, value } pair. Store is optional and read-only —
* used only for schema lookup and expanding nested cas_ref references.
* No data is written to the store.
*/
export function renderDirect(
typeHash: Hash,
value: unknown,
store: Store | null,
options: Omit<RenderOptions, "varStore"> | null,
): string {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Try to get schema from store to identify cas_ref fields
let refSet = new Set<Hash>();
if (store !== null) {
const schema = getSchema(store, typeHash);
if (schema !== null) {
refSet = new Set(collectRefs(schema, value));
}
}
const childResolution = resolution * decay;
const visited = new Set<Hash>();
return renderValue(
store ?? null,
value,
refSet,
childResolution,
decay,
epsilon,
visited,
);
}
/**
* Check if a template exists for a given type
*/
@@ -116,7 +153,7 @@ async function hasTemplate(
}
function renderNode(
store: Store,
store: Store | null,
hash: Hash,
currentResolution: number,
decay: number,
@@ -129,7 +166,7 @@ function renderNode(
}
// Fetch the node
const node = store.get(hash);
const node = store !== null ? store.get(hash) : null;
if (node === null) {
// Missing node - render as cas: reference
return `cas:${hash}`;
@@ -142,7 +179,7 @@ function renderNode(
visited.add(hash);
// Get references from this node's schema
const nodeRefs = refs(store, node);
const nodeRefs = store !== null ? refs(store, node) : [];
const refSet = new Set(nodeRefs);
// Calculate child resolution for next level
@@ -165,7 +202,7 @@ function renderNode(
}
function renderValue(
store: Store,
store: Store | null,
value: unknown,
refHashes: Set<Hash>,
childResolution: number,
+1 -1
View File
@@ -186,7 +186,7 @@ export function validate(store: Store, node: CasNode): boolean {
* Handles: direct format, anyOf (nullable refs), items (array refs),
* properties (nested objects), and additionalProperties (record refs).
*/
function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
const result: Hash[] = [];
if (schema.format === "cas_ref") {