Compare commits

...

11 Commits

Author SHA1 Message Date
xiaoju edff831e87 feat: P2 JSON Schema support — allOf, if/then/else, patternProperties, prefixItems (#82)
Add 10 new schema keywords:

Combinators/conditionals (with collectRefs support):
- allOf: array of sub-schemas, all must match
- if/then/else: conditional validation
- patternProperties: regex-keyed property schemas
- prefixItems: tuple validation (per-position schemas)

Leaf constraints:
- multipleOf, minProperties, maxProperties, default

Updated: ALLOWED_SCHEMA_KEYS, isValidSchema(), collectRefs(),
meta-schema (bootstrap.ts), and schema-validation tests.

13 new tests: schema acceptance, validation, rejection,
and collectRefs traversal for allOf/patternProperties/prefixItems.

510 tests, 0 fail.

Refs #82
2026-06-01 02:10:37 +00:00
xingyue 054b5d9308 Merge pull request 'feat: put/hash support --pipe/-p to read JSON from stdin (#83)' (#85) from fix/83-put-hash-pipe into main 2026-06-01 01:54:28 +00:00
xingyue bdb77fd1e3 feat: put/hash support --pipe/-p to read JSON from stdin (#83) 2026-06-01 09:50:03 +08:00
xingyue b7fc03fa16 Merge pull request 'fix: CLI put @schema uses putSchema() for recursive validation (#82)' (#86) from fix/82-meta-schema-nested-validation into main 2026-06-01 01:39:49 +00:00
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
15 changed files with 749 additions and 76 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": "CRK52TZT2ZND7",
"value": {
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
"type": "5J4TDW3H4J427",
},
}
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `
{
"type": "8E2M8H30BHXS8",
"type": "0RJTSZR6K608G",
"value": "ok",
}
`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `
"{
"type": "4N5REDA48XYJP",
"type": "DGB04ECT2M49Q",
"value": []
}"
`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `
"{
"type": "7124NEATTWYYY",
"type": "EQ015GFTBKQJA",
"value": [
"ERARPP19YJT05"
"078KT0M8N554S"
]
}"
`;
@@ -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": "E88C2CKY90ERT",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "BVW2SAJ8606EZ",
"type": "0GVNZDR5AC35Y",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "3BY1S4RKNMR0P",
"type": "3XAAW976C1KT8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
],
}
@@ -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": "3XAAW976C1KT8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
],
}
@@ -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": "E88C2CKY90ERT",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {},
"value": "F68P1BZ46YDXM",
"value": "0S3E6H68VK328",
},
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "BKMJ3DJHTS6VB",
"type": "92GB8QS47S7HS",
"value": {
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "3BY1S4RKNMR0P",
"type": "3XAAW976C1KT8",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
],
}
@@ -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": "3XAAW976C1KT8",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
],
}
@@ -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": "92GB8QS47S7HS",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "ASWN8JEGAG7AP",
"type": "EV9W9HDVK7WX2",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"schema": "5J4TDW3H4J427",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
"value": "078KT0M8N554S",
},
],
}
`;
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=5J4TDW3H4J427"`;
exports[`Phase 4: Template System 4.1 template set registers template 1`] = `
{
"type": "9YJZ09DDAYAWR",
"type": "FTZ3B2BN1H3SA",
"value": {
"contentHash": "FC8WACA792B6F",
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "0MNS832R7RZSV",
"schemaHash": "5J4TDW3H4J427",
},
}
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `
{
"type": "FJG23DR9456WA",
"type": "EZ034E7H0JWFV",
"value": "Name: {{ payload.name }}, Age: {{ payload.age }}",
}
`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
{
"type": "3JB2JHXHZG2Z1",
"type": "DCVZJFMSKBGJV",
"value": [
{
"contentHash": "FC8WACA792B6F",
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "0MNS832R7RZSV",
"schemaHash": "5J4TDW3H4J427",
},
],
}
@@ -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": "3RKA4H2KVF0TF",
"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: 5J4TDW3H4J427"`;
exports[`Phase 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
@@ -254,13 +254,13 @@ command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json> Store node, print envelope (value=hash) (@output/put)
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
@@ -285,5 +285,5 @@ Flags:
--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)
--pipe, -p Read { type, value } JSON from stdin for render"
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)"
`;
+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", () => {
+65
View File
@@ -628,3 +628,68 @@ describe("Phase 8: Pipe Composition", () => {
expect(stdout).toBe("Person: Carol (25)");
});
});
// ---- Phase 9: Put/Hash Pipe Input ----
describe("Phase 9: Put/Hash Pipe Input", () => {
test("9.1 put -p reads JSON from stdin and stores node", async () => {
const payload = JSON.stringify({ name: "PipeAlice", age: 99 });
const { stdout, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
payload,
);
expect(exitCode).toBe(0);
const hash = envValue(stdout);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Verify stored correctly
const { stdout: getOut } = await runCli(["get", hash as string]);
expect(getOut).toContain("PipeAlice");
});
test("9.2 hash -p reads JSON from stdin and computes hash without storing", async () => {
const payload = JSON.stringify({ name: "PipeBob", age: 55 });
const { stdout, exitCode } = await runCliWithStdin(
["hash", typeHash, "-p"],
payload,
);
expect(exitCode).toBe(0);
const hash = envValue(stdout);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// Should NOT be stored
const { exitCode: hasExit, stdout: hasOut } = await runCli([
"has",
hash as string,
]);
expect(hasExit).toBe(0);
expect(envValue(hasOut)).toBe(false);
});
test("9.3 put -p with file arg errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "some-file.json", "-p"],
"{}",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Cannot use --pipe/-p with a file argument");
});
test("9.4 put -p with empty stdin errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
"",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("No input on stdin");
});
test("9.5 put -p with invalid JSON errors", async () => {
const { stderr, exitCode } = await runCliWithStdin(
["put", typeHash, "-p"],
"not json",
);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Invalid JSON on stdin");
});
});
+57 -11
View File
@@ -13,6 +13,7 @@ import {
getSchema,
InvalidTagFormatError,
InvalidVariableNameError,
putSchema,
refs,
renderAsync,
renderDirect,
@@ -74,6 +75,8 @@ function parseArgs(argv: string[]): { flags: Flags; positional: string[] } {
} else {
flags[key] = true;
}
} else if (arg === "-p") {
flags.p = true;
} else {
positional.push(arg);
}
@@ -112,6 +115,22 @@ function readJsonFile(file: string): unknown {
}
}
async function readStdinJson(): Promise<unknown> {
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 JSON content.");
}
try {
return JSON.parse(input);
} catch {
return die("Invalid JSON on stdin.");
}
}
/**
* Open the filesystem-backed CAS store.
* Automatically creates directory and bootstraps if needed.
@@ -179,14 +198,36 @@ function parseTagsLabels(args: string[]): {
// ---- Commands ----
async function cmdPut(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const typeHashOrAlias = args[0];
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas put <type-hash> <file.json>");
const file = isPipe ? undefined : args[1];
if (!typeHashOrAlias || (!isPipe && !file))
die(
"Usage: json-cas put <type-hash> <file.json>\n json-cas put <type-hash> --pipe/-p",
);
if (isPipe && args[1])
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
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) {
@@ -293,12 +334,17 @@ async function cmdWalk(args: string[]): Promise<void> {
}
async function cmdHash(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const typeHashOrAlias = args[0];
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas hash <type-hash> <file.json>");
const file = isPipe ? undefined : args[1];
if (!typeHashOrAlias || (!isPipe && !file))
die(
"Usage: json-cas hash <type-hash> <file.json>\n json-cas hash <type-hash> --pipe/-p",
);
if (isPipe && args[1])
die("Cannot use --pipe/-p with a file argument. Use one or the other.");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const payload = readJsonFile(file);
const payload = isPipe ? await readStdinJson() : readJsonFile(file as string);
const hash = await computeHash(typeHash, payload);
const store = await openStore();
out(await wrapEnvelope(store, "@output/hash", hash));
@@ -786,13 +832,13 @@ command's @output/* schema (shown in parentheses); pipe any envelope into
\`render -p\` to render its value (cas_ref hashes are expanded).
Commands:
put <type-hash> <file.json> Store node, print envelope (value=hash) (@output/put)
put <type-hash> <file.json|--pipe> Store node, print envelope (value=hash) (@output/put)
get <hash> Print node as envelope (@output/get)
has <hash> Print envelope (value=boolean) (@output/has)
verify <hash> Verify integrity + schema (value=ok/corrupted/invalid) (@output/verify)
refs <hash> List direct cas_ref edges (@output/refs)
walk <hash> [--format tree] Recursive traversal (@output/walk)
hash <type-hash> <file.json> Compute hash without storing (@output/hash)
hash <type-hash> <file.json|--pipe> Compute hash without storing (@output/hash)
render <hash> [options] Render node as text with resolution decay (raw output)
render --pipe/-p [options] Render { type, value } from stdin (raw output)
list --type <hash-or-alias> List hashes for a type (value=string[]) (@output/list)
@@ -817,7 +863,7 @@ Flags:
--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)
--pipe, -p Read { type, value } JSON from stdin for render`);
--pipe, -p Read from stdin (put/hash: raw JSON payload; render: { type, value } envelope)`);
}
// ---- Dispatch ----
+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",
+33
View File
@@ -60,6 +60,39 @@ 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" },
// P2 combinators + conditionals
allOf: {
type: "array",
items: { type: "object", additionalProperties: false },
},
if: { type: "object", additionalProperties: false },
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
then: { type: "object", additionalProperties: false },
else: { type: "object", additionalProperties: false },
patternProperties: {
type: "object",
additionalProperties: { type: "object", additionalProperties: false },
},
prefixItems: {
type: "array",
items: { type: "object", additionalProperties: false },
},
// P2 leaf constraints
multipleOf: { type: "number" },
minProperties: { type: "number" },
maxProperties: { type: "number" },
default: {},
},
} as const;
+263
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);
@@ -400,4 +501,166 @@ describe("bootstrap meta-schema self-reference", () => {
const metaNode = store.get(metaHash) as CasNode;
expect(metaNode.type).toBe(metaHash);
});
// ── P2 combinators, conditionals, and leaf constraints ──────────────────
test("accepts schema with allOf", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
allOf: [
{ type: "object", properties: { name: { type: "string" } } },
{ required: ["name"] },
],
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { name: "Alice" });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, {});
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with if/then/else", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
properties: {
kind: { type: "string" },
value: {},
},
if: { properties: { kind: { const: "number" } } },
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword, not a thenable
then: { properties: { value: { type: "number" } } },
else: { properties: { value: { type: "string" } } },
});
expect(hash).toHaveLength(13);
});
test("accepts schema with patternProperties", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { "x-custom": "hello" });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
});
test("accepts schema with prefixItems (tuple)", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "array",
prefixItems: [{ type: "string" }, { type: "number" }],
});
expect(hash).toHaveLength(13);
});
test("accepts schema with multipleOf", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "number",
multipleOf: 5,
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, 15);
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const bad = await store.put(hash, 7);
expect(validate(store, store.get(bad) as CasNode)).toBe(false);
});
test("accepts schema with minProperties/maxProperties", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "object",
minProperties: 1,
maxProperties: 3,
});
expect(hash).toHaveLength(13);
const good = await store.put(hash, { a: 1, b: 2 });
expect(validate(store, store.get(good) as CasNode)).toBe(true);
const empty = await store.put(hash, {});
expect(validate(store, store.get(empty) as CasNode)).toBe(false);
});
test("accepts schema with default value", async () => {
const store = createMemoryStore();
const hash = await putSchema(store, {
type: "string",
default: "hello",
});
expect(hash).toHaveLength(13);
});
test("rejects invalid P2 keyword types", async () => {
const store = createMemoryStore();
await expect(
putSchema(store, { allOf: "not-array" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { multipleOf: "five" } as never),
).rejects.toThrow();
await expect(
putSchema(store, { patternProperties: [1, 2] } as never),
).rejects.toThrow();
});
test("collectRefs traverses allOf sub-schemas", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
allOf: [
{
type: "object",
properties: { ref: { type: "string", format: "cas_ref" } },
},
],
});
const targetHash = await store.put(innerSchema, "target");
const nodeHash = await store.put(schema, { ref: targetHash });
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
test("collectRefs traverses patternProperties", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
type: "object",
patternProperties: {
"^ref_": { type: "string", format: "cas_ref" },
},
});
const targetHash = await store.put(innerSchema, "hello");
const nodeHash = await store.put(schema, { ref_a: targetHash });
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
test("collectRefs traverses prefixItems", async () => {
const store = createMemoryStore();
const innerSchema = await putSchema(store, { type: "string" });
const schema = await putSchema(store, {
type: "array",
prefixItems: [{ type: "string", format: "cas_ref" }, { type: "number" }],
});
const targetHash = await store.put(innerSchema, "hello");
const nodeHash = await store.put(schema, [targetHash, 42]);
const node = store.get(nodeHash) as CasNode;
const refList = refs(store, node);
expect(refList).toContain(targetHash);
});
});
+143 -6
View File
@@ -35,6 +35,29 @@ 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",
// P2 combinators + conditionals (need collectRefs)
"allOf",
"if",
"then",
"else",
"patternProperties",
"prefixItems",
// P2 leaf constraints
"multipleOf",
"minProperties",
"maxProperties",
"default",
]);
const JSON_SCHEMA_TYPES = new Set([
@@ -126,6 +149,67 @@ 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;
// P2 combinators + conditionals — recursive sub-schema checks
if ("allOf" in schema) {
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) return false;
for (const entry of schema.allOf) {
if (!isValidSchema(entry)) return false;
}
}
if ("if" in schema && !isValidSchema(schema.if)) return false;
if ("then" in schema && !isValidSchema(schema.then)) return false;
if ("else" in schema && !isValidSchema(schema.else)) return false;
if ("patternProperties" in schema) {
const pp = schema.patternProperties;
if (pp === null || typeof pp !== "object" || Array.isArray(pp))
return false;
for (const nested of Object.values(pp as Record<string, unknown>)) {
if (!isValidSchema(nested)) return false;
}
}
if ("prefixItems" in schema) {
if (!Array.isArray(schema.prefixItems) || schema.prefixItems.length === 0)
return false;
for (const entry of schema.prefixItems) {
if (!isValidSchema(entry)) return false;
}
}
// P2 leaf constraints — type checks only
if ("multipleOf" in schema && typeof schema.multipleOf !== "number")
return false;
if ("minProperties" in schema && typeof schema.minProperties !== "number")
return false;
if ("maxProperties" in schema && typeof schema.maxProperties !== "number")
return false;
// "default" accepts any value — no type check needed
return true;
}
@@ -183,8 +267,9 @@ export function validate(store: Store, node: CasNode): boolean {
/**
* Recursively collect values of all properties whose schema has format: 'cas_ref'.
* Handles: direct format, anyOf (nullable refs), items (array refs),
* properties (nested objects), and additionalProperties (record refs).
* Handles: direct format, anyOf/allOf (combinators), oneOf, if/then/else (conditionals),
* items + prefixItems (arrays), properties (nested objects),
* additionalProperties (record refs), and patternProperties (regex-keyed refs).
*/
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
const result: Hash[] = [];
@@ -203,10 +288,45 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
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(...collectRefs(itemSchema, item));
// P2: allOf — each sub-schema applies to the same value
if (Array.isArray(schema.allOf)) {
for (const sub of schema.allOf as JSONSchema[]) {
result.push(...collectRefs(sub, value));
}
}
// P2: if/then/else — conditional sub-schemas apply to the same value
if (schema.if && typeof schema.if === "object") {
result.push(...collectRefs(schema.if as JSONSchema, value));
}
if (schema.then && typeof schema.then === "object") {
result.push(...collectRefs(schema.then as JSONSchema, value));
}
if (schema.else && typeof schema.else === "object") {
result.push(...collectRefs(schema.else as JSONSchema, value));
}
if (schema.type === "array" && Array.isArray(value)) {
// P2: prefixItems — tuple validation, each item has its own schema
if (Array.isArray(schema.prefixItems)) {
const tupleSchemas = schema.prefixItems as JSONSchema[];
const arr = value as unknown[];
for (let i = 0; i < tupleSchemas.length && i < arr.length; i++) {
const ts = tupleSchemas[i];
if (ts) result.push(...collectRefs(ts, arr[i]));
}
}
if (schema.items) {
const itemSchema = schema.items as JSONSchema;
// When prefixItems exists, items applies only to remaining elements
const startIdx = Array.isArray(schema.prefixItems)
? (schema.prefixItems as unknown[]).length
: 0;
const arr = value as unknown[];
for (let i = startIdx; i < arr.length; i++) {
result.push(...collectRefs(itemSchema, arr[i]));
}
}
return result;
}
@@ -230,6 +350,23 @@ export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
result.push(...collectRefs(addlSchema, val));
}
}
// P2: patternProperties — regex-keyed property schemas
if (
schema.patternProperties &&
typeof schema.patternProperties === "object"
) {
const pp = schema.patternProperties as Record<string, JSONSchema>;
const obj = value as Record<string, unknown>;
for (const [pat, subSchema] of Object.entries(pp)) {
const re = new RegExp(pat);
for (const [key, val] of Object.entries(obj)) {
if (re.test(key)) {
result.push(...collectRefs(subSchema, val));
}
}
}
}
}
return result;
@@ -72,8 +72,19 @@ describe("Test Suite 1: Meta-Schema Structure and Self-Validation", () => {
expect(properties).not.toHaveProperty("$ref");
expect(properties).not.toHaveProperty("$id");
expect(properties).not.toHaveProperty("$defs");
expect(properties).not.toHaveProperty("allOf");
expect(properties).not.toHaveProperty("not");
// P2 keywords should be present
expect(properties).toHaveProperty("allOf");
expect(properties).toHaveProperty("if");
expect(properties).toHaveProperty("then");
expect(properties).toHaveProperty("else");
expect(properties).toHaveProperty("patternProperties");
expect(properties).toHaveProperty("prefixItems");
expect(properties).toHaveProperty("multipleOf");
expect(properties).toHaveProperty("minProperties");
expect(properties).toHaveProperty("maxProperties");
expect(properties).toHaveProperty("default");
});
test("1.5: Meta-schema node type equals its own hash", async () => {
+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