Merge pull request 'feat: ucas render --pipe/-p for stdin { type, value } input' (#49) from feat/48-render-pipe into main
This commit was merged in pull request #49.
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
putSchema,
|
||||
refs,
|
||||
renderAsync,
|
||||
renderDirect,
|
||||
TagLabelConflictError,
|
||||
VariableNotFoundError,
|
||||
validate,
|
||||
@@ -386,10 +387,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 +428,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 +890,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 +912,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 ----
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user