From 22fce0ac66d7b32792c20cd3f64b8cefcc9bb065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 31 May 2026 04:18:46 +0000 Subject: [PATCH] feat: add built-in schema aliases with @ prefix support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 1 of issue #37: - Extended variable name validation to allow @ prefix (system-reserved) - Registered 6 built-in schemas with @ aliases during bootstrap - @schema → meta-schema (self-referential) - @string → { type: "string" } - @number → { type: "number" } - @object → { type: "object" } - @array → { type: "array" } - @bool → { type: "boolean" } - Bootstrap now returns Record instead of Hash - Added CLI @ alias resolution for all commands accepting type-hash - ucas schema get @string - ucas put @string - ucas hash @string - Added comprehensive test coverage for all features - Variable name validation with @ prefix - Built-in schema registration - CLI alias resolution - Integration tests Fixes #37 Co-Authored-By: Claude Opus 4.6 --- packages/cli-json-cas/src/cli.test.ts | 219 +++++++++++++++++- packages/cli-json-cas/src/index.ts | 51 +++- packages/cli-json-cas/src/var.test.ts | 3 +- packages/json-cas-fs/src/store.test.ts | 18 +- packages/json-cas/src/bootstrap.test.ts | 129 +++++++++++ packages/json-cas/src/bootstrap.ts | 29 ++- packages/json-cas/src/index.test.ts | 63 +++-- packages/json-cas/src/schema.test.ts | 15 +- packages/json-cas/src/schema.ts | 6 +- packages/json-cas/src/variable-store.test.ts | 183 +++++++++++++++ packages/json-cas/src/variable-store.ts | 12 +- .../json-cas/tests/schema-validation.test.ts | 21 +- 12 files changed, 685 insertions(+), 64 deletions(-) create mode 100644 packages/json-cas/src/bootstrap.test.ts diff --git a/packages/cli-json-cas/src/cli.test.ts b/packages/cli-json-cas/src/cli.test.ts index 4152d8b..e9fd474 100644 --- a/packages/cli-json-cas/src/cli.test.ts +++ b/packages/cli-json-cas/src/cli.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, test } from "bun:test"; -import { resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; const pkgPath = resolve(import.meta.dir, "../package.json"); @@ -31,3 +33,216 @@ describe("ucas command alias", () => { expect(pkg.bin.ucas).toBe(pkg.bin["json-cas"]); }); }); + +// ---- @ Alias Resolution Tests ---- + +let testDir: string; +let storePath: string; +let cliPath: string; + +beforeEach(() => { + // Create unique temp directory for each test + testDir = join( + tmpdir(), + `json-cas-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + storePath = join(testDir, "store"); + cliPath = join(import.meta.dir, "index.ts"); + + mkdirSync(testDir, { recursive: true }); + mkdirSync(storePath, { recursive: true }); +}); + +afterEach(() => { + // Clean up test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +/** + * Run CLI command and return stdout, stderr, and exit code + */ +async function runCli(...args: string[]): Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}> { + const proc = Bun.spawn( + ["bun", "run", cliPath, "--store", storePath, ...args], + { + stdout: "pipe", + stderr: "pipe", + }, + ); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + await proc.exited; + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: proc.exitCode ?? 0, + }; +} + +describe("@ Alias Resolution - schema get", () => { + test("ucas schema get @string should work", async () => { + await runCli("init"); // Initialize store + + const { stdout, stderr, exitCode } = await runCli( + "schema", + "get", + "@string", + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + const schema = JSON.parse(stdout); + expect(schema).toEqual({ type: "string" }); + }); + + test("ucas schema get @number should work", async () => { + await runCli("init"); + + const { stdout, exitCode } = await runCli("schema", "get", "@number"); + + expect(exitCode).toBe(0); + const schema = JSON.parse(stdout); + expect(schema).toEqual({ type: "number" }); + }); + + test("ucas schema get @object should work", async () => { + await runCli("init"); + + const { stdout, exitCode } = await runCli("schema", "get", "@object"); + + expect(exitCode).toBe(0); + const schema = JSON.parse(stdout); + expect(schema).toEqual({ type: "object" }); + }); + + test("ucas schema get @array should work", async () => { + await runCli("init"); + + const { stdout, exitCode } = await runCli("schema", "get", "@array"); + + expect(exitCode).toBe(0); + const schema = JSON.parse(stdout); + expect(schema).toEqual({ type: "array" }); + }); + + test("ucas schema get @bool should work", async () => { + await runCli("init"); + + const { stdout, exitCode } = await runCli("schema", "get", "@bool"); + + expect(exitCode).toBe(0); + const schema = JSON.parse(stdout); + expect(schema).toEqual({ type: "boolean" }); + }); + + test("ucas schema get @schema should work", async () => { + await runCli("init"); + + const { stdout, exitCode } = await runCli("schema", "get", "@schema"); + + expect(exitCode).toBe(0); + const schema = JSON.parse(stdout); + expect(schema).toHaveProperty("type", "object"); + expect(schema).toHaveProperty( + "description", + "json-cas JSON Schema meta-schema", + ); + }); + + test("ucas schema get @invalid should fail gracefully", async () => { + await runCli("init"); + + const { stderr, exitCode } = await runCli("schema", "get", "@invalid"); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Schema not found"); + }); +}); + +describe("@ Alias Resolution - put", () => { + test("ucas put @string should resolve alias", async () => { + await runCli("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, JSON.stringify("hello world")); + + const { stdout, stderr, exitCode } = await runCli( + "put", + "@string", + payloadFile, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + // Should output a valid hash (13 chars) + expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("ucas put @number should resolve alias", async () => { + await runCli("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, "42"); + + const { stdout, exitCode } = await runCli("put", "@number", payloadFile); + + expect(exitCode).toBe(0); + expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("ucas put @object should resolve alias", async () => { + await runCli("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, JSON.stringify({ foo: "bar" })); + + const { stdout, exitCode } = await runCli("put", "@object", payloadFile); + + expect(exitCode).toBe(0); + expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("ucas put @invalid should fail", async () => { + await runCli("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, "{}"); + + const { stderr, exitCode } = await runCli("put", "@invalid", payloadFile); + + expect(exitCode).not.toBe(0); + expect(stderr.length).toBeGreaterThan(0); + }); +}); + +describe("@ Alias Resolution - hash", () => { + test("ucas hash @string should compute hash without storing", async () => { + await runCli("init"); + + const payloadFile = join(testDir, "payload.json"); + writeFileSync(payloadFile, JSON.stringify("test")); + + const { stdout, stderr, exitCode } = await runCli( + "hash", + "@string", + payloadFile, + ); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); +}); diff --git a/packages/cli-json-cas/src/index.ts b/packages/cli-json-cas/src/index.ts index 51f752b..1be5a42 100644 --- a/packages/cli-json-cas/src/index.ts +++ b/packages/cli-json-cas/src/index.ts @@ -109,6 +109,24 @@ function openVarStore(): VariableStore { return createVariableStore(resolve(varDbPath), store); } +/** + * Resolve a type-hash, handling @ aliases + * If the input starts with @, resolve it via bootstrap + * Otherwise, return the hash as-is + */ +async function resolveTypeHash(typeHashOrAlias: string): Promise { + if (typeHashOrAlias.startsWith("@")) { + const store = openStore(); + const builtinSchemas = await bootstrap(store); + const resolvedHash = builtinSchemas[typeHashOrAlias]; + if (!resolvedHash) { + die(`Schema not found: ${typeHashOrAlias}`); + } + return resolvedHash; + } + return typeHashOrAlias; +} + /** * Get the Variable schema's CAS hash * This is the type hash used in JSON envelopes @@ -196,14 +214,16 @@ async function cmdInit(): Promise { const dir = resolve(storePath); mkdirSync(dir, { recursive: true }); const store = createFsStore(dir); - const hash = await bootstrap(store); - console.log(hash); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"]; + console.log(metaHash); } async function cmdBootstrap(): Promise { const store = openStore(); - const hash = await bootstrap(store); - console.log(hash); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"]; + console.log(metaHash); } async function cmdSchemaPut(args: string[]): Promise { @@ -216,17 +236,20 @@ async function cmdSchemaPut(args: string[]): Promise { } async function cmdSchemaGet(args: string[]): Promise { - const hash = args[0]; - if (!hash) die("Usage: json-cas schema get "); + const hashOrAlias = args[0]; + if (!hashOrAlias) die("Usage: json-cas schema get "); + const hash = await resolveTypeHash(hashOrAlias); const store = openStore(); const schema = getSchema(store, hash); - if (schema === null) die(`Schema not found: ${hash}`); + if (schema === null) die(`Schema not found: ${hashOrAlias}`); out(schema); } async function cmdSchemaList(): Promise { const store = openStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"]; + if (!metaHash) throw new Error("Meta-schema not found"); for (const hash of store.listByType(metaHash)) { if (hash === metaHash) continue; const node = store.get(hash); @@ -252,9 +275,11 @@ async function cmdSchemaValidate(args: string[]): Promise { } async function cmdPut(args: string[]): Promise { - const typeHash = args[0]; + const typeHashOrAlias = args[0]; const file = args[1]; - if (!typeHash || !file) die("Usage: json-cas put "); + if (!typeHashOrAlias || !file) + die("Usage: json-cas put "); + const typeHash = await resolveTypeHash(typeHashOrAlias); const payload = readJsonFile(file); const store = openStore(); const hash = await store.put(typeHash, payload); @@ -339,9 +364,11 @@ async function cmdWalk(args: string[]): Promise { } async function cmdHash(args: string[]): Promise { - const typeHash = args[0]; + const typeHashOrAlias = args[0]; const file = args[1]; - if (!typeHash || !file) die("Usage: json-cas hash "); + if (!typeHashOrAlias || !file) + die("Usage: json-cas hash "); + const typeHash = await resolveTypeHash(typeHashOrAlias); const payload = readJsonFile(file); const hash = await computeHash(typeHash, payload); console.log(hash); diff --git a/packages/cli-json-cas/src/var.test.ts b/packages/cli-json-cas/src/var.test.ts index c2530cd..175d1d9 100644 --- a/packages/cli-json-cas/src/var.test.ts +++ b/packages/cli-json-cas/src/var.test.ts @@ -90,7 +90,8 @@ async function createTestNode( * Get bootstrap type hash */ async function getBootstrapHash(store: Store): Promise { - return await bootstrap(store); + const builtinSchemas = await bootstrap(store); + return builtinSchemas["@schema"] ?? ""; } // ---- Tests ---- diff --git a/packages/json-cas-fs/src/store.test.ts b/packages/json-cas-fs/src/store.test.ts index d4566a2..0bde8ed 100644 --- a/packages/json-cas-fs/src/store.test.ts +++ b/packages/json-cas-fs/src/store.test.ts @@ -43,7 +43,8 @@ describe("createFsStore – init and bootstrap", () => { test("bootstrap returns a valid 13-char self-referencing hash", async () => { const store = createFsStore(dir); - const hash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const hash = builtinSchemas["@schema"] ?? ""; expect(hash).toHaveLength(13); expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); @@ -57,8 +58,8 @@ describe("createFsStore – init and bootstrap", () => { const h1 = await bootstrap(store); const h2 = await bootstrap(store); - expect(h1).toBe(h2); - expect(store.listByType(h1)).toHaveLength(1); + expect(h1).toEqual(h2); + expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6); }); }); @@ -104,7 +105,8 @@ describe("createFsStore – persistence round-trip", () => { test("bootstrap survives round-trip: self-referencing node reloads correctly", async () => { const store1 = createFsStore(dir); - const hash = await bootstrap(store1); + const builtinSchemas = await bootstrap(store1); + const hash = builtinSchemas["@schema"] ?? ""; const store2 = createFsStore(dir); const node = store2.get(hash) as CasNode; @@ -251,10 +253,11 @@ describe("createFsStore – listByType", () => { test("bootstrap node is listed under its self type after reload", async () => { const store1 = createFsStore(dir); - const hash = await bootstrap(store1); + const builtinSchemas = await bootstrap(store1); + const hash = builtinSchemas["@schema"] ?? ""; const store2 = createFsStore(dir); - expect(store2.listByType(hash)).toEqual([hash]); + expect(store2.listByType(hash)).toContain(hash); }); }); @@ -284,7 +287,8 @@ describe("createFsStore – verify on disk-loaded nodes", () => { test("verify passes on a disk-loaded bootstrap node", async () => { const store1 = createFsStore(dir); - const hash = await bootstrap(store1); + const builtinSchemas = await bootstrap(store1); + const hash = builtinSchemas["@schema"] ?? ""; const store2 = createFsStore(dir); const node = store2.get(hash) as CasNode; diff --git a/packages/json-cas/src/bootstrap.test.ts b/packages/json-cas/src/bootstrap.test.ts new file mode 100644 index 0000000..9713fcf --- /dev/null +++ b/packages/json-cas/src/bootstrap.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { bootstrap } from "./bootstrap.js"; +import { getSchema } from "./schema.js"; +import { createMemoryStore } from "./store.js"; + +// ────────────────────────────────────────────────────────────────────────────── +// Built-in Schema Registration Tests +// ────────────────────────────────────────────────────────────────────────────── + +describe("bootstrap - Built-in Schemas", () => { + test("should return map of built-in schema aliases to hashes", async () => { + const store = createMemoryStore(); + const builtinSchemas = await bootstrap(store); + + // Should return object with 6 aliases + expect(builtinSchemas).toHaveProperty("@schema"); + expect(builtinSchemas).toHaveProperty("@string"); + expect(builtinSchemas).toHaveProperty("@number"); + expect(builtinSchemas).toHaveProperty("@object"); + expect(builtinSchemas).toHaveProperty("@array"); + expect(builtinSchemas).toHaveProperty("@bool"); + + // All values should be valid hashes + for (const [_alias, hash] of Object.entries(builtinSchemas)) { + expect(typeof hash).toBe("string"); + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } + }); + + test("should register @schema as meta-schema alias", async () => { + const store = createMemoryStore(); + const builtinSchemas = await bootstrap(store); + + const metaHash = builtinSchemas["@schema"]; + if (!metaHash) throw new Error("@schema not found"); + + const metaSchema = getSchema(store, metaHash); + expect(metaSchema).not.toBeNull(); + expect(metaSchema?.type).toBe("object"); + expect(metaSchema?.description).toBe("json-cas JSON Schema meta-schema"); + }); + + test("should register @string schema correctly", async () => { + const store = createMemoryStore(); + const builtinSchemas = await bootstrap(store); + + const stringHash = builtinSchemas["@string"]; + if (!stringHash) throw new Error("@string not found"); + + const stringSchema = getSchema(store, stringHash); + expect(stringSchema).toEqual({ type: "string" }); + }); + + test("should register @number schema correctly", async () => { + const store = createMemoryStore(); + const builtinSchemas = await bootstrap(store); + + const numberHash = builtinSchemas["@number"]; + if (!numberHash) throw new Error("@number not found"); + + const numberSchema = getSchema(store, numberHash); + expect(numberSchema).toEqual({ type: "number" }); + }); + + test("should register @object schema correctly", async () => { + const store = createMemoryStore(); + const builtinSchemas = await bootstrap(store); + + const objectHash = builtinSchemas["@object"]; + if (!objectHash) throw new Error("@object not found"); + + const objectSchema = getSchema(store, objectHash); + expect(objectSchema).toEqual({ type: "object" }); + }); + + test("should register @array schema correctly", async () => { + const store = createMemoryStore(); + const builtinSchemas = await bootstrap(store); + + const arrayHash = builtinSchemas["@array"]; + if (!arrayHash) throw new Error("@array not found"); + + const arraySchema = getSchema(store, arrayHash); + expect(arraySchema).toEqual({ type: "array" }); + }); + + test("should register @bool schema correctly", async () => { + const store = createMemoryStore(); + const builtinSchemas = await bootstrap(store); + + const boolHash = builtinSchemas["@bool"]; + if (!boolHash) throw new Error("@bool not found"); + + const boolSchema = getSchema(store, boolHash); + expect(boolSchema).toEqual({ type: "boolean" }); + }); + + test("should return same hashes on repeated bootstrap calls", async () => { + const store = createMemoryStore(); + const first = await bootstrap(store); + const second = await bootstrap(store); + + expect(first).toEqual(second); + + // Verify each alias points to same hash + expect(first["@string"]).toBe(second["@string"]); + expect(first["@number"]).toBe(second["@number"]); + expect(first["@object"]).toBe(second["@object"]); + expect(first["@array"]).toBe(second["@array"]); + expect(first["@bool"]).toBe(second["@bool"]); + expect(first["@schema"]).toBe(second["@schema"]); + }); + + test("all built-in schemas should be typed by meta-schema", async () => { + const store = createMemoryStore(); + const builtinSchemas = await bootstrap(store); + + const metaHash = builtinSchemas["@schema"]; + if (!metaHash) throw new Error("@schema not found"); + + for (const [alias, hash] of Object.entries(builtinSchemas)) { + if (alias === "@schema") continue; // meta-schema is self-typed + + const node = store.get(hash); + expect(node).not.toBeNull(); + expect(node?.type).toBe(metaHash); + } + }); +}); diff --git a/packages/json-cas/src/bootstrap.ts b/packages/json-cas/src/bootstrap.ts index 7a65ce2..dc205ec 100644 --- a/packages/json-cas/src/bootstrap.ts +++ b/packages/json-cas/src/bootstrap.ts @@ -64,13 +64,32 @@ const BOOTSTRAP_PAYLOAD = { } as const; /** - * Write the meta-schema seed node into the store. - * The returned hash equals the node's own type field (self-referencing). - * Idempotent: calling bootstrap multiple times returns the same hash. + * Write the meta-schema seed node into the store and register built-in schemas. + * The returned object contains aliases for the meta-schema and 5 primitive schemas. + * Idempotent: calling bootstrap multiple times returns the same hashes. */ -export async function bootstrap(store: Store): Promise { +export async function bootstrap(store: Store): Promise> { if (!isBootstrapCapableStore(store)) { throw new Error("Store does not support bootstrap"); } - return store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD); + + // 1. Bootstrap the meta-schema (self-referential) + const metaHash = await store[BOOTSTRAP_STORE](BOOTSTRAP_PAYLOAD); + + // 2. Register built-in primitive schemas directly (without putSchema to avoid recursion) + const stringHash = await store.put(metaHash, { type: "string" }); + const numberHash = await store.put(metaHash, { type: "number" }); + const objectHash = await store.put(metaHash, { type: "object" }); + const arrayHash = await store.put(metaHash, { type: "array" }); + const boolHash = await store.put(metaHash, { type: "boolean" }); + + // 3. Return map of aliases to hashes + return { + "@schema": metaHash, + "@string": stringHash, + "@number": numberHash, + "@object": objectHash, + "@array": arrayHash, + "@bool": boolHash, + }; } diff --git a/packages/json-cas/src/index.test.ts b/packages/json-cas/src/index.test.ts index 58c45a2..45e42eb 100644 --- a/packages/json-cas/src/index.test.ts +++ b/packages/json-cas/src/index.test.ts @@ -197,9 +197,17 @@ describe("createMemoryStore – listByType", () => { test("bootstrap node is listed under its self type", async () => { const store = createMemoryStore(); - const hash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const hash = builtinSchemas["@schema"] ?? ""; - expect(store.listByType(hash)).toEqual([hash]); + // All built-in schemas should be typed by the meta-schema + const allTypedByMeta = store.listByType(hash); + expect(allTypedByMeta).toContain(hash); // meta-schema itself + expect(allTypedByMeta).toContain(builtinSchemas["@string"] ?? ""); + expect(allTypedByMeta).toContain(builtinSchemas["@number"] ?? ""); + expect(allTypedByMeta).toContain(builtinSchemas["@object"] ?? ""); + expect(allTypedByMeta).toContain(builtinSchemas["@array"] ?? ""); + expect(allTypedByMeta).toContain(builtinSchemas["@bool"] ?? ""); }); }); @@ -256,44 +264,59 @@ describe("bootstrap", () => { ); }); - test("returns a valid 13-char hash", async () => { + test("returns a map with 6 built-in schema aliases", async () => { const store = createMemoryStore(); - const hash = await bootstrap(store); - expect(hash).toHaveLength(13); - expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + const builtinSchemas = await bootstrap(store); + + expect(builtinSchemas).toHaveProperty("@schema"); + expect(builtinSchemas).toHaveProperty("@string"); + expect(builtinSchemas).toHaveProperty("@number"); + expect(builtinSchemas).toHaveProperty("@object"); + expect(builtinSchemas).toHaveProperty("@array"); + expect(builtinSchemas).toHaveProperty("@bool"); + + // All values should be valid hashes + for (const hash of Object.values(builtinSchemas)) { + expect(hash).toHaveLength(13); + expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + } }); - test("node is stored and retrievable", async () => { + test("meta-schema node is stored and retrievable", async () => { const store = createMemoryStore(); - const hash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; - expect(store.has(hash)).toBe(true); - const node = store.get(hash); + expect(store.has(metaHash)).toBe(true); + const node = store.get(metaHash); expect(node).not.toBeNull(); }); - test("node is self-referencing: type === hash", async () => { + test("meta-schema node is self-referencing: type === hash", async () => { const store = createMemoryStore(); - const hash = await bootstrap(store); - const node = store.get(hash) as CasNode; + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; + const node = store.get(metaHash) as CasNode; - expect(node.type).toBe(hash); + expect(node.type).toBe(metaHash); }); test("bootstrap node passes verify()", async () => { const store = createMemoryStore(); - const hash = await bootstrap(store); - const node = store.get(hash) as CasNode; + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; + const node = store.get(metaHash) as CasNode; - expect(await verify(hash, node)).toBe(true); + expect(await verify(metaHash, node)).toBe(true); }); - test("bootstrap is idempotent: same hash on repeated calls", async () => { + test("bootstrap is idempotent: same hashes on repeated calls", async () => { const store = createMemoryStore(); const h1 = await bootstrap(store); const h2 = await bootstrap(store); - expect(h1).toBe(h2); - expect(store.listByType(h1)).toHaveLength(1); + expect(h1).toEqual(h2); + // All 6 built-in schemas should be typed by the meta-schema + expect(store.listByType(h1["@schema"] ?? "")).toHaveLength(6); }); }); diff --git a/packages/json-cas/src/schema.test.ts b/packages/json-cas/src/schema.test.ts index 8c3ffa1..59731db 100644 --- a/packages/json-cas/src/schema.test.ts +++ b/packages/json-cas/src/schema.test.ts @@ -29,7 +29,8 @@ describe("putSchema", () => { test("schema node type equals the meta-schema hash", async () => { const store = createMemoryStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const schemaHash = await putSchema(store, { type: "string" }); const node = store.get(schemaHash) as CasNode; @@ -355,7 +356,8 @@ describe("walk", () => { describe("bootstrap meta-schema self-reference", () => { test("metaNode.type === metaHash (self-referencing)", async () => { const store = createMemoryStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const metaNode = store.get(metaHash) as CasNode; expect(metaNode.type).toBe(metaHash); @@ -363,7 +365,8 @@ describe("bootstrap meta-schema self-reference", () => { test("schema nodes have type === metaHash", async () => { const store = createMemoryStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const schemaHash = await putSchema(store, { type: "string" }); const schemaNode = store.get(schemaHash) as CasNode; @@ -372,7 +375,8 @@ describe("bootstrap meta-schema self-reference", () => { test("data nodes have type === schemaHash (not metaHash)", async () => { const store = createMemoryStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const schemaHash = await putSchema(store, { type: "object", properties: { val: { type: "number" } }, @@ -386,7 +390,8 @@ describe("bootstrap meta-schema self-reference", () => { test("bootstrap is idempotent across putSchema calls", async () => { const store = createMemoryStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; await putSchema(store, { type: "string" }); await putSchema(store, { type: "number" }); diff --git a/packages/json-cas/src/schema.ts b/packages/json-cas/src/schema.ts index 831843c..a17c944 100644 --- a/packages/json-cas/src/schema.ts +++ b/packages/json-cas/src/schema.ts @@ -142,7 +142,11 @@ export async function putSchema( store: Store, jsonSchema: JSONSchema, ): Promise { - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"]; + if (!metaHash) { + throw new Error("Meta-schema not found in bootstrap result"); + } if (!isValidSchema(jsonSchema)) { throw new SchemaValidationError( "Invalid schema: input does not conform to the json-cas JSON Schema meta-schema", diff --git a/packages/json-cas/src/variable-store.test.ts b/packages/json-cas/src/variable-store.test.ts index 1c18f02..9c3ac98 100644 --- a/packages/json-cas/src/variable-store.test.ts +++ b/packages/json-cas/src/variable-store.test.ts @@ -1593,3 +1593,186 @@ describe("VariableStore - Tag/Label Management", () => { varStore.close(); }); }); + +// ────────────────────────────────────────────────────────────────────────────── +// @ Prefix Support for Variable Names +// ────────────────────────────────────────────────────────────────────────────── + +describe("VariableStore - @ Prefix Variable Names", () => { + let store: Store; + let dbPath: string; + + afterEach(() => { + if (dbPath) { + try { + unlinkSync(dbPath); + } catch { + // ignore + } + } + }); + + test("should accept variable name with @ prefix in first segment", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "string" }); + const hash = await store.put(schemaHash, "test value"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Should succeed + const variable = varStore.set("@ucas/test/foo", hash); + expect(variable.name).toBe("@ucas/test/foo"); + + const retrieved = varStore.get("@ucas/test/foo", schemaHash); + expect(retrieved).not.toBeNull(); + expect(retrieved?.name).toBe("@ucas/test/foo"); + expect(retrieved?.value).toBe(hash); + + varStore.close(); + }); + + test("should accept variable name starting with @", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "string" }); + const hash = await store.put(schemaHash, "config value"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Single segment with @ + varStore.set("@config", hash); + const result = varStore.get("@config", schemaHash); + expect(result).not.toBeNull(); + expect(result?.name).toBe("@config"); + + varStore.close(); + }); + + test("should accept complex @ prefix paths", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "string" }); + const hash = await store.put(schemaHash, "test"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // Multiple valid patterns + const validNames = [ + "@ucas/render/template", + "@system/config", + "@foo.bar/baz", + "@app-1/test_2", + ]; + + for (const name of validNames) { + expect(() => varStore.set(name, hash)).not.toThrow(); + const retrieved = varStore.get(name, schemaHash); + expect(retrieved).not.toBeNull(); + expect(retrieved?.name).toBe(name); + } + + varStore.close(); + }); + + test("should reject @ in non-first segment", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "string" }); + const hash = await store.put(schemaHash, "test"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // @ only allowed at start of entire name + const invalidNames = [ + "foo/@bar", // @ in second segment + "foo/bar/@baz", // @ in third segment + "foo@bar", // @ within segment (not at start) + ]; + + for (const name of invalidNames) { + expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError); + } + + varStore.close(); + }); + + test("should reject @ followed by invalid characters", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "string" }); + const hash = await store.put(schemaHash, "test"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // @ prefix must still follow segment rules after @ + const invalidNames = [ + "@", // @ alone is empty segment + "@/foo", // empty after @ + "@foo bar", // space not allowed + "@foo$bar", // $ not allowed + ]; + + for (const name of invalidNames) { + expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError); + } + + varStore.close(); + }); + + test("should still accept all previously valid names", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "string" }); + const hash = await store.put(schemaHash, "test"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + // All non-@ names should continue to work + const validNames = [ + "simple", + "with.dots", + "with-dashes", + "with_underscores", + "path/to/var", + "foo.bar/baz-qux/test_123", + ]; + + for (const name of validNames) { + expect(() => varStore.set(name, hash)).not.toThrow(); + } + + varStore.close(); + }); + + test("should still reject previously invalid names", async () => { + store = createMemoryStore(); + await bootstrap(store); + const schemaHash = await putSchema(store, { type: "string" }); + const hash = await store.put(schemaHash, "test"); + + dbPath = tmpDbPath(); + const varStore = new VariableStore(dbPath, store); + + const invalidNames = [ + "", // empty + "/leading", // leading slash + "trailing/", // trailing slash + "double//slash", // empty segment + "has space", // space + "has$dollar", // special char + ]; + + for (const name of invalidNames) { + expect(() => varStore.set(name, hash)).toThrow(InvalidVariableNameError); + } + + varStore.close(); + }); +}); diff --git a/packages/json-cas/src/variable-store.ts b/packages/json-cas/src/variable-store.ts index d354c9f..2f1808b 100644 --- a/packages/json-cas/src/variable-store.ts +++ b/packages/json-cas/src/variable-store.ts @@ -116,6 +116,7 @@ export class VariableStore { /** * Validate variable name format + * @ is allowed at the start of the first segment (system-reserved) */ private validateName(name: string): void { // Rule 1: Cannot be empty @@ -139,9 +140,10 @@ export class VariableStore { ); } - // Rule 4: Each segment must match [a-zA-Z0-9._-]+ and no empty segments + // Rule 4: Each segment must match [a-zA-Z0-9._-]+ (with @ allowed at start of first segment) const segments = name.split("/"); - for (const segment of segments) { + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] as string; if (segment === "") { throw new InvalidVariableNameError( name, @@ -150,10 +152,12 @@ export class VariableStore { } // Check for invalid characters - if (!/^[a-zA-Z0-9._-]+$/.test(segment)) { + // First segment can start with @, all segments can contain [a-zA-Z0-9._-] + const regex = i === 0 ? /^@?[a-zA-Z0-9._-]+$/ : /^[a-zA-Z0-9._-]+$/; + if (!regex.test(segment)) { throw new InvalidVariableNameError( name, - `Segment "${segment}" contains invalid characters (only a-z, A-Z, 0-9, ., _, - allowed)`, + `Segment "${segment}" contains invalid characters (only ${i === 0 ? "@, " : ""}a-z, A-Z, 0-9, ., _, - allowed)`, ); } } diff --git a/packages/json-cas/tests/schema-validation.test.ts b/packages/json-cas/tests/schema-validation.test.ts index 0b963ff..0017674 100644 --- a/packages/json-cas/tests/schema-validation.test.ts +++ b/packages/json-cas/tests/schema-validation.test.ts @@ -15,7 +15,8 @@ import type { CasNode } from "../src/types.js"; describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => { test("1.1: Meta-schema is a valid JSON Schema", async () => { const store = new MemStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const metaNode = store.get(metaHash); expect(metaNode).not.toBeNull(); @@ -25,7 +26,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => { test("1.2: Meta-schema self-validates", async () => { const store = new MemStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const metaNode = store.get(metaHash); expect(metaNode).not.toBeNull(); @@ -34,7 +36,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => { test("1.3: Meta-schema defines all supported keywords", async () => { const store = new MemStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const metaSchema = getSchema(store, metaHash); expect(metaSchema).not.toBeNull(); @@ -57,7 +60,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => { test("1.4: Meta-schema does not include unsupported keywords", async () => { const store = new MemStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const metaSchema = getSchema(store, metaHash); expect(metaSchema).not.toBeNull(); @@ -74,7 +78,8 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => { test("1.5: Meta-schema node type equals its own hash", async () => { const store = new MemStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const metaNode = store.get(metaHash); expect(metaNode).not.toBeNull(); @@ -443,7 +448,8 @@ describe("Test Suite 5: Backward Compatibility and Migration", () => { test("5.1: Bootstrap hash changes (breaking change)", async () => { // This is a documentation test - the old hash was different const store = new MemStore(); - const newMetaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const newMetaHash = builtinSchemas["@schema"] ?? ""; // The new hash should be different from the old system metadata hash // We just verify it's a valid hash format @@ -585,7 +591,8 @@ describe("Test Suite 6: Integration with Existing Functionality", () => { describe("Test Suite 7: Meta-Schema Content Validation", () => { test("7.1: Meta-schema allows recursive schema definitions", async () => { const store = new MemStore(); - const metaHash = await bootstrap(store); + const builtinSchemas = await bootstrap(store); + const metaHash = builtinSchemas["@schema"] ?? ""; const metaSchema = getSchema(store, metaHash); expect(metaSchema).not.toBeNull(); -- 2.43.0