Compare commits

..

7 Commits

Author SHA1 Message Date
xiaoju 9367a4141a fix: CLI put @schema uses putSchema() for recursive validation (#82)
CLI 'ucas put @schema' now detects meta-schema target and routes
through putSchema() which uses isValidSchema() — a proper recursive
validator — instead of ajv against the meta-schema (which cannot
express recursive constraints for nested property sub-schemas).

This fixes the core issue where schemas with typed properties like
{"type":"string","minLength":1} were rejected by the CLI path.

Added CLI E2E test: put @schema with nested constraints (minLength,
maximum, uniqueItems).

494 tests, 0 fail.

Refs #82
2026-06-01 01:35:40 +00:00
xingyue bfec74fe8e Merge pull request 'feat: support P1 leaf JSON Schema constraints (#82)' (#84) from feat/82-schema-p1-leaf-constraints into main 2026-06-01 01:26:50 +00:00
xiaoju 70af05adf5 feat: support P1 leaf JSON Schema constraints (#82)
Add 10 new schema keywords to whitelist + meta-schema:
- numeric: minimum, maximum, exclusiveMinimum, exclusiveMaximum
- string: minLength, maxLength, pattern
- array: minItems, maxItems, uniqueItems

These are pure leaf constraints with no collectRefs() impact.
5 new tests covering validation, rejection, and nested usage.

Refs #82
2026-06-01 01:14:02 +00:00
xiaoju ceba0b5d43 chore: add prepublishOnly workspace:* guard
Checks for unresolved workspace:* dependencies before publish.
Changesets resolves these automatically, so normal release flow
passes through. Direct npm publish with workspace deps is blocked.
2026-05-31 23:43:51 +00:00
xiaoju a050160bee chore: remove prepublishOnly guards
Changesets already handles workspace:* → real version resolution
during publish. The guard scripts are unnecessary and block the
release flow.
2026-05-31 23:33:58 +00:00
xiaoju 515f4d32f9 chore: version 0.6.0 — unified envelope output (RFC #67) 2026-05-31 23:24:59 +00:00
xiaoju c03180e66c Merge pull request 'feat: wrap template commands with envelope, update docs (Phase 4)' (#81) from fix/72-template-envelope into main 2026-05-31 16:00:32 +00:00
13 changed files with 337 additions and 55 deletions
+28
View File
@@ -1,5 +1,33 @@
# @uncaged/cli-json-cas
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.6.0
- @uncaged/json-cas-fs@0.6.0
## 0.5.3
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-json-cas",
"version": "0.5.3",
"version": "0.6.0",
"type": "module",
"bin": {
"json-cas": "./src/index.ts",
@@ -8,10 +8,10 @@
},
"scripts": {
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
"@uncaged/json-cas": "^0.6.0",
"@uncaged/json-cas-fs": "^0.6.0"
}
}
@@ -2,36 +2,36 @@
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"type": "ASE7K6A0HG8W9",
"type": "2NHHHE9NR0FVT",
"value": {
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
"type": "3K9ETAPGFPE32",
},
}
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
{
"type": "8E2M8H30BHXS8",
"type": "BZ3TV0PTATA52",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
"{
"type": "4N5REDA48XYJP",
"type": "18HZGJY02WN1K",
"value": []
}"
`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
"{
"type": "7124NEATTWYYY",
"type": "9BVX9SHACFC5J",
"value": [
"ERARPP19YJT05"
"A0QKG4ERMXSFG"
]
}"
`;
@@ -40,40 +40,40 @@ exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fai
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
{
"type": "AYHQD2YA9G667",
"type": "EMX6HW4CECBGK",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "BVW2SAJ8606EZ",
"type": "F68GWJAT9H6HP",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "3BY1S4RKNMR0P",
"type": "A4TV1FVBBNG5M",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
],
}
@@ -81,14 +81,14 @@ exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "3BY1S4RKNMR0P",
"type": "A4TV1FVBBNG5M",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
],
}
@@ -96,48 +96,48 @@ exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "AYHQD2YA9G667",
"type": "EMX6HW4CECBGK",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {},
"value": "F68P1BZ46YDXM",
"value": "FNDM9C6P23238",
},
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "BKMJ3DJHTS6VB",
"type": "92JS8RXGKDKC2",
"value": {
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "3BY1S4RKNMR0P",
"type": "A4TV1FVBBNG5M",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
],
}
@@ -145,18 +145,18 @@ exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "3BY1S4RKNMR0P",
"type": "A4TV1FVBBNG5M",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
],
}
@@ -164,62 +164,62 @@ exports[`Phase 3: Variable System 3.8 var list --tag important filters by label
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
{
"type": "BKMJ3DJHTS6VB",
"type": "92JS8RXGKDKC2",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "ASWN8JEGAG7AP",
"type": "00YH4RTNVWTRB",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "3K9ETAPGFPE32",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "A0QKG4ERMXSFG",
},
],
}
`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=7XX5H51CVD9H0"`;
exports[`Phase 3: Variable System 3.11 var get deleted variable returns not found 1`] = `"Error: Variable not found: name=myapp/config, schema=3K9ETAPGFPE32"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
{
"type": "9YJZ09DDAYAWR",
"type": "DTNQZ90QQHA6D",
"value": {
"contentHash": "FC8WACA792B6F",
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "DBBPX5JJ74SWZ",
"schemaHash": "3K9ETAPGFPE32",
},
}
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
{
"type": "FJG23DR9456WA",
"type": "8KMGVC6TG12TS",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
{
"type": "3JB2JHXHZG2Z1",
"type": "6RZCT3XX4J8BC",
"value": [
{
"contentHash": "FC8WACA792B6F",
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "DBBPX5JJ74SWZ",
"schemaHash": "3K9ETAPGFPE32",
},
],
}
@@ -227,14 +227,14 @@ exports[`Phase 4: Template System 4.3 template list shows registered templates 1
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
{
"type": "0PYGQE16XPM70",
"type": "DBHJH385HGVTM",
"value": {
"deleted": true,
},
}
`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 7XX5H51CVD9H0"`;
exports[`Phase 4: Template System 4.5 template get deleted template returns not found 1`] = `"Error: Template not found for schema: 3K9ETAPGFPE32"`;
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
+33
View File
@@ -224,6 +224,39 @@ describe("@ Alias Resolution - put", () => {
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
});
test("ucas put @schema with nested type constraints should succeed", async () => {
await runCliAlias("init");
const schemaFile = join(testDir, "constrained-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 50 },
age: { type: "number", minimum: 0, maximum: 150 },
tags: {
type: "array",
items: { type: "string" },
minItems: 1,
uniqueItems: true,
},
},
required: ["name"],
}),
);
const { stdout, stderr, exitCode } = await runCliAlias(
"put",
"@schema",
schemaFile,
);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(envValue(stdout)).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
describe("@ Alias Resolution - hash", () => {
+18
View File
@@ -13,6 +13,7 @@ import {
getSchema,
InvalidTagFormatError,
InvalidVariableNameError,
putSchema,
refs,
renderAsync,
renderDirect,
@@ -187,6 +188,23 @@ async function cmdPut(args: string[]): Promise<void> {
const payload = readJsonFile(file);
const store = await openStore();
// Schema nodes: use putSchema() which validates via isValidSchema() (recursive)
// instead of ajv against meta-schema (which can't express recursive constraints)
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
if (typeHash === metaHash) {
try {
const hash = await putSchema(store, payload as Record<string, unknown>);
out(await wrapEnvelope(store, "@output/put", hash));
} catch (e) {
console.error(
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
);
process.exit(1);
}
return;
}
// Check if schema exists
const schema = getSchema(store, typeHash);
if (schema === null) {
+27
View File
@@ -1,5 +1,32 @@
# @uncaged/json-cas-fs
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
### Patch Changes
- Updated dependencies []:
- @uncaged/json-cas@0.6.0
## 0.5.3
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas-fs",
"version": "0.5.3",
"version": "0.6.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -16,10 +16,10 @@
],
"scripts": {
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas": "^0.6.0",
"cborg": "^4.2.3"
}
}
+22
View File
@@ -1,5 +1,27 @@
# @uncaged/json-cas
## 0.6.0
### Minor Changes
- Unified `{ type, value }` envelope output for all CLI commands (RFC [#67](https://github.com/uncaged/json-cas/issues/67)).
### Breaking Changes
- Removed `init`, `bootstrap`, `cat`, `schema put/get/list/validate` commands
- All CLI command outputs now wrapped in `{ type, value }` envelope (except `render`)
- `openStore()` is now async and auto-bootstraps
### New Features
- 18 `@output/*` schemas registered at bootstrap
- `list --type <hash-or-alias>` command (replaces `schema list`)
- `verify` now checks both hash integrity and schema validation
- `wrapEnvelope()` helper exported from `@uncaged/json-cas`
- `openStore()` in `@uncaged/json-cas-fs` — async, auto mkdir + bootstrap
- Default LiquidJS templates for all output schemas
- Pipe composition: any command output can be piped to `render -p`
## 0.5.3
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/json-cas",
"version": "0.5.3",
"version": "0.6.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -16,7 +16,7 @@
],
"scripts": {
"test": "bun test",
"prepublishOnly": "echo '请用 bun run release 从根目录发版' && exit 1"
"prepublishOnly": "bash ../../scripts/check-workspace-deps.sh"
},
"dependencies": {
"ajv": "^8.20.0",
+11
View File
@@ -60,6 +60,17 @@ const BOOTSTRAP_PAYLOAD = {
enum: { type: "array" },
const: {},
description: { type: "string" },
// P1 leaf constraints
minimum: { type: "number" },
maximum: { type: "number" },
exclusiveMinimum: { type: "number" },
exclusiveMaximum: { type: "number" },
minLength: { type: "number" },
maxLength: { type: "number" },
pattern: { type: "string" },
minItems: { type: "number" },
maxItems: { type: "number" },
uniqueItems: { type: "boolean" },
},
} as const;
+101
View File
@@ -388,6 +388,107 @@ describe("bootstrap meta-schema self-reference", () => {
expect(dataNode.type).not.toBe(metaHash);
});
// ── P1 leaf constraints ──────────────────────────────────────────────────
test("accepts schema with numeric constraints (minimum/maximum)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "number",
minimum: 0,
maximum: 100,
exclusiveMinimum: -1,
exclusiveMaximum: 101,
});
expect(hash).toHaveLength(13);
// validate a conforming payload
const nodeHash = await store.put(hash, 42);
const node = store.get(nodeHash) as CasNode;
expect(validate(store, node)).toBe(true);
// validate a non-conforming payload
const badHash = await store.put(hash, 200);
const badNode = store.get(badHash) as CasNode;
expect(validate(store, badNode)).toBe(false);
});
test("accepts schema with string constraints (minLength/maxLength/pattern)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
minLength: 1,
maxLength: 10,
pattern: "^[a-z]+$",
});
expect(hash).toHaveLength(13);
const goodHash = await store.put(hash, "hello");
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
const badHash = await store.put(hash, "HELLO");
expect(validate(store, store.get(badHash) as CasNode)).toBe(false);
});
test("accepts schema with array constraints (minItems/maxItems/uniqueItems)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
items: { type: "number" },
minItems: 1,
maxItems: 3,
uniqueItems: true,
});
expect(hash).toHaveLength(13);
const goodHash = await store.put(hash, [1, 2, 3]);
expect(validate(store, store.get(goodHash) as CasNode)).toBe(true);
const tooMany = await store.put(hash, [1, 2, 3, 4]);
expect(validate(store, store.get(tooMany) as CasNode)).toBe(false);
const dupes = await store.put(hash, [1, 1]);
expect(validate(store, store.get(dupes) as CasNode)).toBe(false);
});
test("rejects schema with wrong constraint types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { type: "number", minimum: "zero" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { type: "string", maxLength: true } as never),
).rejects.toThrow();
await expect(
putSchema(store, { type: "array", uniqueItems: 1 } as never),
).rejects.toThrow();
});
test("accepts schema with nested property constraints", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 50 },
age: { type: "number", minimum: 0, maximum: 150 },
scores: {
type: "array",
items: { type: "number" },
minItems: 1,
uniqueItems: true,
},
},
required: ["name"],
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, {
name: "Alice",
age: 30,
scores: [95, 87],
});
expect(validate(store, store.get(good) as CasNode)).toBe(true);
});
test("bootstrap is idempotent across putSchema calls", async () => {
const store = createMemoryStore();
const builtinSchemas = await bootstrap(store);
+34
View File
@@ -35,6 +35,17 @@ const ALLOWED_SCHEMA_KEYS = new Set([
"enum",
"const",
"description",
// P1 leaf constraints (no collectRefs impact)
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"pattern",
"minItems",
"maxItems",
"uniqueItems",
]);
const JSON_SCHEMA_TYPES = new Set([
@@ -126,6 +137,29 @@ function isValidSchema(value: unknown): boolean {
if (!Array.isArray(schema.enum) || schema.enum.length === 0) return false;
}
// P1 leaf constraints — type checks only
if ("minimum" in schema && typeof schema.minimum !== "number") return false;
if ("maximum" in schema && typeof schema.maximum !== "number") return false;
if (
"exclusiveMinimum" in schema &&
typeof schema.exclusiveMinimum !== "number"
)
return false;
if (
"exclusiveMaximum" in schema &&
typeof schema.exclusiveMaximum !== "number"
)
return false;
if ("minLength" in schema && typeof schema.minLength !== "number")
return false;
if ("maxLength" in schema && typeof schema.maxLength !== "number")
return false;
if ("pattern" in schema && typeof schema.pattern !== "string") return false;
if ("minItems" in schema && typeof schema.minItems !== "number") return false;
if ("maxItems" in schema && typeof schema.maxItems !== "number") return false;
if ("uniqueItems" in schema && typeof schema.uniqueItems !== "boolean")
return false;
return true;
}
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Prevent publishing packages that still reference workspace:* dependencies
if grep -q '"workspace:' package.json 2>/dev/null; then
echo "❌ Found workspace:* dependencies in package.json — cannot publish directly."
echo " Use 'changeset publish' which resolves workspace protocol automatically."
grep '"workspace:' package.json
exit 1
fi