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)
This commit is contained in:
2026-05-31 07:43:25 +00:00
parent 7b29fe777c
commit a0d7b67923
4 changed files with 98 additions and 112 deletions
+21 -2
View File
@@ -390,6 +390,10 @@ async function cmdRender(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const hash = args[0];
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>]\n ucas render --pipe/-p [--resolution <n>] [--decay <n>] [--epsilon <n>]",
@@ -426,7 +430,11 @@ async function cmdRender(args: string[]): Promise<void> {
try {
if (isPipe) {
// Read { type, value } JSON from stdin
const input = readFileSync("/dev/stdin", "utf-8").trim();
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.");
}
@@ -448,11 +456,22 @@ async function cmdRender(args: string[]): Promise<void> {
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 },
{
resolution,
decay,
epsilon,
},
);
process.stdout.write(output);
} else {
+42 -19
View File
@@ -937,23 +937,28 @@ describe("Suite 8: Performance & Edge Cases", () => {
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");
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,
});
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"]);
const output = renderDirect(fakeTypeHash, ["a", "b", "c"], null, null);
expect(output).toContain("-");
expect(output).toContain("a");
expect(output).toContain("b");
@@ -962,10 +967,15 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
test("9.4 Render nested object without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, {
user: { name: "Bob", role: "admin" },
active: true,
});
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");
@@ -991,13 +1001,18 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
});
// Render directly with store — cas_ref should expand
const output = renderDirect(parentSchema, { child: childHash }, store);
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" }, undefined, {
const output = renderDirect(fakeTypeHash, { key: "value" }, null, {
resolution: 0.5,
decay: 0.8,
});
@@ -1007,19 +1022,19 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
test("9.7 Validate parameters", () => {
const fakeTypeHash = "0000000000000" as Hash;
expect(() =>
renderDirect(fakeTypeHash, "x", undefined, { resolution: 2 }),
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", undefined, { decay: 0 }),
).toThrow("decay must be in (0, 1]");
expect(() =>
renderDirect(fakeTypeHash, "x", undefined, { epsilon: -1 }),
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);
const output = renderDirect(fakeTypeHash, null, null, null);
expect(output.trim()).toBe("null");
});
@@ -1027,8 +1042,16 @@ describe("Suite 9: renderDirect (in-memory rendering)", () => {
// 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 });
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");
});
});
+34 -90
View File
@@ -1,6 +1,5 @@
import { renderWithTemplate } from "./liquid-render.js";
import type { JSONSchema } from "./schema.js";
import { getSchema, 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";
@@ -17,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.
@@ -28,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);
@@ -57,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 {
@@ -107,30 +106,17 @@ export async function renderAsync(
export function renderDirect(
typeHash: Hash,
value: unknown,
store?: Store,
options?: Omit<RenderOptions, "varStore">,
store: Store | null,
options: Omit<RenderOptions, "varStore"> | null,
): 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);
// Try to get schema from store to identify cas_ref fields
let refSet = new Set<Hash>();
if (store !== undefined) {
if (store !== null) {
const schema = getSchema(store, typeHash);
if (schema !== null) {
refSet = new Set(collectRefsFromSchema(schema, value));
refSet = new Set(collectRefs(schema, value));
}
}
@@ -148,48 +134,6 @@ export function renderDirect(
);
}
/**
* Collect cas_ref hashes from a value using its schema definition.
* Mirrors the logic in schema.ts collectRefs but is local to render.
*/
function collectRefsFromSchema(schema: JSONSchema, value: unknown): Hash[] {
const result: Hash[] = [];
if (schema.format === "cas_ref") {
if (typeof value === "string") {
result.push(value as Hash);
}
return result;
}
if (Array.isArray(schema.anyOf)) {
for (const sub of schema.anyOf as JSONSchema[]) {
result.push(...collectRefsFromSchema(sub, value));
}
return result;
}
if (schema.type === "array" && schema.items && Array.isArray(value)) {
const itemSchema = schema.items as JSONSchema;
for (const item of value as unknown[]) {
result.push(...collectRefsFromSchema(itemSchema, item));
}
return result;
}
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
if (schema.properties && typeof schema.properties === "object") {
const props = schema.properties as Record<string, JSONSchema>;
const obj = value as Record<string, unknown>;
for (const [key, subSchema] of Object.entries(props)) {
result.push(...collectRefsFromSchema(subSchema, obj[key]));
}
}
}
return result;
}
/**
* Check if a template exists for a given type
*/
+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") {