Compare commits

...

30 Commits

Author SHA1 Message Date
xingyue 9912013b0a fix(cli): use dot notation for GC result assertions (Biome lint)
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-31 19:30:31 +08:00
xingyue 2ed097e207 fix(cli): make e2e snapshots stable across machines and runs
- Strip volatile fields (timestamp, created, updated) from JSON before
  snapshotting using a stripVolatile helper
- Remove toMatchSnapshot() from test 2.1 to avoid embedding machine-
  specific tmp paths in the snapshot; use toContain assertions instead
- Replace GC count snapshot with structural shape assertions so counts
  don't need to match exact phase-history state

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-31 19:29:25 +08:00
xingyue b0d5b05457 feat(cli): add e2e snapshot fixture tests for all CLI scenarios
Convert 46 e2e-check workflow scenarios to fast bun test snapshot tests.
7 describe phases share a single mkdtempSync store; hashes are deterministic
so cross-phase data dependencies work without re-creating data.

Closes #66

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-31 19:24:17 +08:00
xiaomo de20cfde53 Merge pull request 'refactor: e2e-check workflow 拆分为 4 角色' (#64) from fix/e2e-check-role-split into main 2026-05-31 10:58:50 +00:00
xingyue 9948db77ea refactor: split e2e-check workflow into 4 roles
- preparer: Docker setup, install, build, lint, unit test, init store
- tester: pure CLI scenario testing (receives container + store path)
- reporter: file Gitea issues (triggered by bugs or setup failures)
- cleanup: stop and remove Docker container

Also fixes template variable prefix (payload.name vs name).
2026-05-31 18:23:34 +08:00
xiaomo c60f05f650 Merge pull request 'fix: validate store directory exists before CLI operations' (#63) from fix/55-store-path-validation-rebase into main 2026-05-31 09:20:41 +00:00
xiaoju 314b076c05 fix: validate store path before read operations (Fixes #55)
- Add validateStoreExists() helper to check store directory existence
- Modify openStore() to accept shouldCreate parameter (default: false)
- Update read commands to validate store exists first
- Update write commands (init, put, schema put, hash) to allow creation
- Add 10 E2E tests covering all affected commands
- Improve error message: "Store not found at <path>" vs "Node not found"

Commands that now validate:
- get, has, verify, refs, walk, cat
- schema get, schema list

Commands that still create:
- init, put, schema put, hash
- var commands (use openVarStore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 09:19:23 +00:00
xiaomo 664409b94a Merge pull request 'fix: workflow frontmatter schema 加 type: object' (#56) from fix/workflow-frontmatter-schema into main 2026-05-31 09:16:53 +00:00
xingyue f3f13e6f35 fix: add type: object to all oneOf variants in workflow frontmatter schemas
ajv strict mode requires explicit type: object when properties/required
are used. All three workflow YAMLs had this missing, causing frontmatter
validation to fail with:
  strict mode: missing type "object" for keyword "required"

Also added frontmatter output example in e2e-check.yaml procedure to
prevent agents from outputting bugs as plain strings instead of objects.
2026-05-31 17:15:43 +08:00
xiaomo 10c5c8f98e Merge pull request 'fix: clean error message for invalid schema in schema put command' (#61) from fix/54-schema-put-invalid-error into main 2026-05-31 09:15:37 +00:00
xiaomo d28003779e Merge pull request 'fix: detect missing root hash in render command' (#60) from fix/53-render-missing-hash-error into main 2026-05-31 09:15:35 +00:00
xiaomo 885a8e1147 Merge pull request 'test: add E2E template variable rendering tests' (#59) from fix/52-template-variable-rendering into main 2026-05-31 09:15:33 +00:00
xiaomo f0ffe6b234 Merge pull request 'fix: validate payload against schema in put command' (#57) from fix/50-schema-validation into main 2026-05-31 09:15:28 +00:00
xiaoju fc869cfc99 fix: resolve test failures for issue #53
Applied tester feedback to fix 5 test failures:

1. Updated error message format from "Node not found" to "CAS node not found"
   for consistency with existing tests in variable-store.test.ts and var.test.ts

2. Fixed CLI tests R9 and R10 to use bootstrap() directly instead of
   non-existent "types" command. Added imports for bootstrap and createFsStore.

3. Fixed render test 6.5 to pass actual schema Hash instead of entire
   bootstrap object (Record<string, Hash>)

4. Updated test expectations in render.test.ts (tests 1.5, 10.1, 10.2) to
   match new error message format

All 390 tests now pass. Core functionality verified:
- Missing root hash detection working correctly
- CLI exits with code 1 on missing hash
- Error message includes hash: "CAS node not found: <hash>"
- Nested nodes still render as cas: references (preserved behavior)
- Resolution decay behavior preserved

Fixes #53

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:36:19 +00:00
xiaoju 7fd2013ef2 fix: validate payload against schema in put command
Add schema validation to the `json-cas put` command to ensure data
integrity. The CLI now validates the payload against the specified
schema before storing, and exits with a non-zero code and descriptive
error message if validation fails.

Changes:
- Add schema existence check in cmdPut()
- Add payload validation before storing
- Exit with error code 1 on validation failure
- Provide helpful error messages indicating the file and schema
- Add comprehensive test suite with 16 test scenarios covering:
  - Valid data (regression tests)
  - Type mismatches (new validation)
  - Schema errors (edge cases)
  - Integration with existing features
  - Error message quality

The hash command continues to work without validation (dry-run
consistency), and schema put continues to use its own validation.

Fixes #50

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:24:13 +00:00
xiaoju 0b72c9400f style: apply biome formatting fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:21:10 +00:00
xiaoju eb36c16420 fix: detect missing root hash in render command and exit with error
When rendering a non-existent hash, the CLI now exits with code 1 and
displays an error message instead of silently outputting "cas:<hash>"
with exit code 0.

Changes:
- Updated CasNodeNotFoundError constructor signature to store hash
- Added root hash existence check in render() and renderAsync()
- Updated CLI error handling to catch CasNodeNotFoundError
- Added comprehensive test suite for missing hash error handling
- Updated existing incorrect tests (R2 and test 1.5)

The fix distinguishes between:
- Root hash (user-requested): Must exist or throw error
- Nested hash (during traversal): Renders as cas: reference (existing behavior)
- Resolution below epsilon: Renders as cas: reference (existing behavior)

Fixes #53

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:18:15 +00:00
xiaoju 3c8b16d7b1 fix: clean error message for invalid schema in schema put command
Fixes #54

The `json-cas schema put` command now catches SchemaValidationError
and displays a clean error message instead of showing a raw stack trace.

Changes:
- Import SchemaValidationError from @uncaged/json-cas
- Wrap putSchema() call in try-catch in cmdSchemaPut
- Catch SchemaValidationError specifically and call die(e.message)
- Add 5 comprehensive tests for invalid schema error handling

Test cases:
1. Invalid JSON Schema type value
2. Unknown schema keys
3. Invalid nested schema
4. Non-object root schema
5. Valid schema regression test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:14:23 +00:00
xiaoju 51e81c7b99 test: add E2E template variable rendering tests for issue #52
Add comprehensive test suite (Suite 9 and 10) covering template variable rendering:
- Suite 9: E2E Template Variable Rendering (12 tests)
  - Tests correct {{ payload.* }} syntax vs incorrect direct property access
  - Tests primitive payloads (string, number)
  - Tests nested objects, arrays, null values, booleans
  - Tests edge cases: empty strings, zero values, special characters
  - Validates CLI integration flow
- Suite 10: Context Variable Completeness (2 tests)
  - Verifies context propagation through recursive renders
  - Tests context isolation between parent and child nodes

All tests pass. Confirms the renderNode function correctly passes
node.payload to template context. Issue #52 was user error - templates
require {{ payload.name }} syntax, not {{ name }}.

Fixes #52

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 08:13:36 +00:00
xiaomo 2932aa5980 Merge pull request 'feat: ucas render --pipe/-p for stdin { type, value } input' (#49) from feat/48-render-pipe into main 2026-05-31 07:47:01 +00:00
xiaoju a0d7b67923 fix: address PR #49 review feedback
- Convention: renderDirect uses Store | null, options | null (no ?:)
- Validation: hash format check (13-char Crockford Base32) on stdin input
- DRY: remove collectRefsFromSchema, import collectRefs from schema.ts
- DRY: extract validateAndExtractOptions shared by render/renderAsync/renderDirect
- stdin: use process.stdin async iteration instead of /dev/stdin
- UX: error on --pipe + hash conflict instead of silent ignore
- Tests: add 9.10 (store present, schema missing)
2026-05-31 07:43:25 +00:00
xiaoju 7b29fe777c chore: remove stale temp files 2026-05-31 07:34:16 +00:00
xiaoju 64b8a88bdc feat: add renderDirect() and ucas render --pipe/-p
In-memory rendering of { type, value } envelopes without store writes.
Store is optional and read-only (for expanding nested cas_ref references).

CLI: ucas render --pipe/-p reads JSON from stdin.
Core: renderDirect(typeHash, value, store?, options?) for programmatic use.

Fixes #48
2026-05-31 07:34:07 +00:00
xiaoju 4717024e9b Merge pull request 'chore: Phase 4 cleanup — dedupe types, remove unused params, fix tests' (#47) from fix/46-phase4-cleanup into main 2026-05-31 07:19:42 +00:00
xiaoju 1e5f4b7c46 chore: Phase 4 cleanup — dedupe RenderOptions, remove unused param, fix test numbering
1. Deduplicate RenderOptions type
   - Remove duplicate definition from liquid-render.ts
   - Import from render.ts instead (canonical location)

2. Remove unused _globalDecay parameter
   - Remove from renderNode function signature
   - Update all call sites

3. Fix test numbering gaps
   - Suite 4: Renumber 4.4→4.2, 4.5→4.3
   - Suite 7→6: Renumber 7.1→6.1, 7.2→6.2, 7.4→6.3
   - Suite 8→7: Renumber all 8.x→7.x tests
   - Suite 10→8: Renumber 10.1→8.1
   - Result: consecutive suite numbering (1-8)

4. CLI test status: All 95 tests pass (no pre-existing failures found)

Fixes #46

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 07:11:44 +00:00
xiaoju 0a761f5289 Merge pull request 'feat: LiquidJS template rendering integration (#40)' (#45) from fix/40-liquidjs-integration into main 2026-05-31 06:08:31 +00:00
xiaoju 07e08e3b38 feat: Add CLI integration for LiquidJS template rendering
Integrates LiquidJS template rendering into the CLI render command.
When a template is registered for a node's schema type via the variable
system (@ucas/template/text/<schema-hash>), the CLI will use the template
for rendering. Otherwise, it falls back to YAML output.

Changes:
- Modified cmdRender in index.ts to use renderAsync with variable store
- Added Suite 6: CLI Integration with Templates (5 comprehensive tests)
- Fixed template file format: templates must be JSON-encoded strings
- Removed unused render import from index.ts
- Renamed unused globalDecay parameter in liquid-render.ts

Test coverage increased from 336 to 341 tests, all passing.

Fixes #40

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju e0af351991 fix: resolve TypeScript strict mode and dynamic import issues
Fixes reviewer feedback:
1. Fixed TypeScript strict mode error where ctx.engine was possibly null
   - Refactored to pass store/varStore/globalDecay directly to createLiquidEngine
   - Eliminated RenderContext type that caused circular dependency
   - Engine is now properly typed as Liquid (non-nullable)

2. Removed dynamic imports from production code
   - Changed render.ts to use static import of renderWithTemplate
   - Changed hasTemplate to use static import of putSchema
   - Complies with CLAUDE.md convention against dynamic imports

All tests pass (336 tests), build succeeds with no TypeScript errors,
lint checks pass with only 1 minor warning about unused parameter
(which is actually used in recursive calls).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju 72f85c9077 feat: implement LiquidJS template rendering integration
Integrates LiquidJS as the template engine for CAS node rendering with
custom {% render %} tag supporting recursive rendering with resolution
decay. Templates are discovered via variables under
@ucas/template/text/<type-hash>. When ucas render <hash> is invoked,
the system queries for a registered template; if found, uses LiquidJS;
otherwise falls back to Phase 3's default YAML renderer.

Key features:
- Custom {% render %} tag with recursive CAS node rendering
- Decay priority chain: template decay > CLI --decay > default 0.5
- Context variables: resolution, epsilon, hash, payload, type, timestamp
- Graceful fallback: No template → YAML rendering (Phase 3)
- Zero breaking changes: All Phase 3 tests still pass
- Template discovery via @ucas/template/text/<type-hash> variables

Implementation:
- New file: packages/json-cas/src/liquid-render.ts — LiquidJS integration
- Modified: packages/json-cas/src/render.ts — Template lookup + YAML fallback
- New file: packages/json-cas/src/liquid-render.test.ts — 32 comprehensive tests
- Dependency: liquidjs npm package
- CLI: No changes needed (transparent integration)

All tests pass (336 tests), build succeeds, lint checks pass.

Closes #40

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 06:05:44 +00:00
xiaoju cccfca3137 Merge pull request 'feat: implement template CLI subcommands (set/get/list/delete)' (#44) from fix/38-template-cli into main 2026-05-31 05:29:12 +00:00
16 changed files with 5217 additions and 70 deletions
+413
View File
@@ -0,0 +1,413 @@
name: "e2e-check"
description: "Docker-isolated E2E testing of json-cas CLI. Preparer builds from scratch, tester runs scenarios, reporter files bugs."
roles:
preparer:
description: "Spins up Docker container, copies repo, installs, builds, runs unit tests"
goal: "You set up a clean Docker environment for E2E testing. Your job is to start a container, install deps, build, lint, run unit tests, and initialize a CAS store. Report any setup failures as bugs."
capabilities:
- docker
procedure: |
1. Start a detached container:
```bash
docker run -d --name json-cas-e2e \
-v "<repoPath>:/src:ro" \
-w /workspace \
oven/bun:latest \
sleep 3600
```
2. Copy repo and install:
```bash
docker exec json-cas-e2e bash -c 'cp -r /src/. /workspace/ && cd /workspace && bun install'
```
✅ exit code 0, no missing peer deps
3. Build:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run build'
```
✅ exit code 0, no type errors
4. Lint:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun run check'
```
✅ exit code 0
5. Unit tests:
```bash
docker exec json-cas-e2e bash -c 'cd /workspace && bun test'
```
✅ all pass (ignore dist/ false positives)
6. Init CAS store:
```bash
docker exec json-cas-e2e bash -c 'mkdir -p /tmp/cas-test && cd /workspace && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test init && bun packages/cli-json-cas/src/index.ts --store /tmp/cas-test bootstrap'
```
**Any failure here is a high-severity bug** — it means a clean environment can't build/run the project.
Set $status=ready if all steps pass. Set $status=setup_failed with failures list if anything breaks.
output: "Setup result summary."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "ready" }
containerName: { type: string }
storePath: { type: string }
repoPath: { type: string }
required: [$status, containerName, storePath, repoPath]
- type: object
properties:
$status: { const: "setup_failed" }
failures:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
repoPath: { type: string }
required: [$status, failures, repoPath]
tester:
description: "Runs CLI scenarios against the prepared Docker environment"
goal: "You are an exploratory QA agent. The Docker container is already running with the project built and a CAS store initialized. Run CLI test scenarios and report bugs."
capabilities:
- testing
- cli
procedure: |
The container `{{{containerName}}}` is already running with the project built.
Store path: `{{{storePath}}}`.
Run all commands via:
```bash
docker exec {{{containerName}}} bash -c 'cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}} <subcommand>'
```
Define a shorthand in your notes:
`CMD="cd /workspace && bun packages/cli-json-cas/src/index.ts --store {{{storePath}}}"`
## Phase 1: CAS Core Operations
1. **bootstrap** — `$CMD bootstrap`
Expected: prints meta-schema hash (13-char Base32)
2. **schema put** — Create `/tmp/test-schema.json` in container, then `$CMD schema put /tmp/test-schema.json`
Schema: `{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"number"}},"required":["name"],"additionalProperties":false}`
Expected: prints type hash
3. **schema get** — `$CMD schema get <type-hash>`
Expected: returns the schema JSON
4. **schema list** — `$CMD schema list`
Expected: lists registered schemas
5. **put** — Create data file, `$CMD put <type-hash> /tmp/test-node.json`
Data: `{"name":"Alice","age":30}`
Expected: prints node hash
6. **get** — `$CMD get <node-hash>`
Expected: returns node JSON
7. **has (exists)** — `$CMD has <node-hash>`
Expected: true
8. **has (not exists)** — `$CMD has AAAAAAAAAAAAA`
Expected: false
9. **verify** — `$CMD verify <node-hash>`
Expected: ok
10. **refs** — `$CMD refs <node-hash>`
Expected: lists refs (may be empty)
11. **walk** — `$CMD walk <node-hash>`
Expected: shows traversal tree
12. **hash (dry run)** — `$CMD hash <type-hash> /tmp/test-node.json`
Expected: same hash as put
13. **cat** — `$CMD cat <node-hash>`
Expected: full node output
14. **cat --payload** — `$CMD cat <node-hash> --payload`
Expected: payload only (no type wrapper)
## Phase 2: Schema Validation
1. **Invalid node** — `$CMD put <type-hash> /tmp/bad-node.json` where bad-node = `{"name":123}`
Expected: validation error, non-zero exit
2. **schema validate** — `$CMD schema validate <node-hash>`
Expected: valid for good node
3. **Non-existent schema** — `$CMD put AAAAAAAAAAAAA /tmp/test-node.json`
Expected: error about missing schema
## Phase 3: Variable System
1. **var set** — `$CMD var set myapp/config <node-hash>`
Expected: creates variable
2. **var get** — `$CMD var get myapp/config --schema <type-hash>`
Expected: returns variable
3. **var list** — `$CMD var list`
Expected: shows all variables
4. **var list prefix** — `$CMD var list myapp/`
Expected: filtered results
5. **var set (update)** — Put a second node, `$CMD var set myapp/config <new-hash>`
Expected: upsert succeeds
6. **var tag** — `$CMD var tag myapp/config --schema <type-hash> env:prod important`
Expected: adds tag and label
7. **var list --tag** — `$CMD var list --tag env:prod`
Expected: finds tagged variable
8. **var list --tag (label)** — `$CMD var list --tag important`
Expected: finds labeled variable
9. **var tag remove** — `$CMD var tag myapp/config --schema <type-hash> :important`
Expected: removes label
10. **var delete** — `$CMD var delete myapp/config`
Expected: deletes variable
11. **var get (deleted)** — `$CMD var get myapp/config --schema <type-hash>`
Expected: not found error
## Phase 4: Template System
1. **template set** — Create template file, `$CMD template set <type-hash> /tmp/test.liquid`
Template: `Name: {{ payload.name }}, Age: {{ payload.age }}`
Expected: success
2. **template get** — `$CMD template get <type-hash>`
Expected: returns template text
3. **template list** — `$CMD template list`
Expected: lists templates
4. **template delete** — `$CMD template delete <type-hash>`
Expected: success
5. **template get (deleted)** — `$CMD template get <type-hash>`
Expected: not found error
## Phase 5: Render
1. Re-register template, then `$CMD render <node-hash>`
Expected: rendered output with payload values filled in
2. **render --resolution** — `$CMD render <node-hash> --resolution 0.5`
Expected: different resolution output
3. **render (bad hash)** — `$CMD render AAAAAAAAAAAAA`
Expected: graceful error, non-zero exit
## Phase 6: GC
1. **gc basic** — `$CMD gc`
Expected: runs without error
2. **gc preserves referenced** — Verify `$CMD has <node-hash>` still true
3. **gc collects orphans** — Put an orphan node (not in any variable), run gc, check it's gone
## Phase 7: Edge Cases & Error Handling
1. `$CMD get AAAAAAAAAAAAA` — non-existent
2. `$CMD put <type-hash> /nonexistent/file.json` — missing file
3. `$CMD var set "" <hash>` — empty name
4. `$CMD var set "bad name!" <hash>` — invalid name chars
5. `$CMD schema put /tmp/bad-schema.json` — `{"type":"invalid"}`
6. `$CMD` with no subcommand — should show help
7. `$CMD --store /nonexistent/path get <hash>` — bad store path
## Recording Results
For each scenario:
- ✅ Pass: works as expected
- ❌ Fail: unexpected behavior, crash, wrong output
- ⚠️ Questionable: works but confusing UX
Collect all ❌ and ⚠️. For each, record:
- Title (concise description)
- Command (exact command run)
- Expected behavior
- Actual behavior (include actual output)
- Severity: critical / high / medium / low
- Phase: which test phase
## CRITICAL: Frontmatter Output Format
Your response MUST start with YAML frontmatter. The `bugs` field MUST be an array of objects, NOT strings.
Example of CORRECT frontmatter:
```yaml
---
$status: bugs_found
containerName: json-cas-e2e
repoPath: /path/to/repo
bugs:
- title: "put does not validate data against schema"
command: "json-cas put <hash> bad-data.json"
expected: "Validation error, non-zero exit"
actual: "Accepted invalid data, exit 0"
severity: "high"
phase: "Schema Validation"
---
```
Do NOT write bugs as plain strings like `- some bug description`. Each bug MUST be an object with all 6 fields.
If all tests pass:
```yaml
---
$status: all_passed
containerName: json-cas-e2e
---
```
output: "Summary of all phases with pass/fail counts. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "bugs_found" }
bugs:
type: array
items:
type: object
properties:
title: { type: string }
command: { type: string }
expected: { type: string }
actual: { type: string }
severity: { type: string }
phase: { type: string }
required: [title, command, expected, actual, severity, phase]
containerName: { type: string }
repoPath: { type: string }
required: [$status, bugs, containerName]
- type: object
properties:
$status: { const: "all_passed" }
containerName: { type: string }
required: [$status, containerName]
reporter:
description: "Opens Gitea issues for each bug found by the tester"
goal: "You are a bug reporter. You create well-formatted Gitea issues for each bug found during E2E testing."
capabilities:
- issue-management
procedure: |
1. Parse the bugs array from the tester's output
2. Group bugs by severity (critical first)
3. For each bug, create a Gitea issue:
```bash
tea issues create -r uncaged/json-cas \
-t "[E2E] <title>" \
-d "## Bug Report (E2E Check)
**Phase:** <phase>
**Severity:** <severity>
**Command:**
\`\`\`
<exact command>
\`\`\`
**Expected:** <expected>
**Actual:** <actual>
---
_Reported by e2e-check workflow (Docker isolated)_"
```
⚠️ If `tea issues create` fails with long body, use Gitea REST API:
```bash
eval "$(cfg env)" && GITEA_TOKEN=$(cfg get GITEA_TOKEN)
curl -s -X POST "https://git.shazhou.work/api/v1/repos/uncaged/json-cas/issues" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"[E2E] ...","body":"..."}'
```
4. Collect created issue numbers
output: "List created issues. Set $status."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "reported" }
issues:
type: array
items: { type: string }
required: [$status, issues]
- type: object
properties:
$status: { const: "partial" }
created: { type: number }
failed: { type: number }
required: [$status, created, failed]
cleanup:
description: "Stops and removes the Docker container"
goal: "You clean up the Docker environment after testing is complete."
capabilities:
- docker
procedure: |
Stop and remove the container:
```bash
docker stop {{{containerName}}} && docker rm {{{containerName}}}
```
Verify it's gone:
```bash
docker ps -a --filter name={{{containerName}}} --format '{{.Names}}'
```
Expected: empty output.
output: "Cleanup result."
frontmatter:
oneOf:
- type: object
properties:
$status: { const: "cleaned" }
required: [$status]
- type: object
properties:
$status: { const: "cleanup_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "preparer", prompt: "Set up Docker environment for E2E testing. Repo at {{{repoPath}}}." }
preparer:
ready: { role: "tester", prompt: "Environment ready. Container: {{{containerName}}}, store: {{{storePath}}}. Run all test scenarios." }
setup_failed: { role: "reporter", prompt: "Setup failures found. File these as bugs: {{{failures}}}" }
tester:
all_passed: { role: "cleanup", prompt: "All tests passed. Clean up container {{{containerName}}}." }
bugs_found: { role: "reporter", prompt: "File these bugs as Gitea issues: {{{bugs}}}" }
reporter:
reported: { role: "cleanup", prompt: "Bugs filed: {{{issues}}}. Clean up container {{{containerName}}}." }
partial: { role: "cleanup", prompt: "Filed {{{created}}} issues, {{{failed}}} failed. Clean up container {{{containerName}}}." }
cleanup:
cleaned: { role: "$END", prompt: "E2E check complete. Environment cleaned up." }
cleanup_failed: { role: "$END", prompt: "E2E check complete but cleanup failed: {{{error}}}" }
+22 -11
View File
@@ -51,16 +51,19 @@ roles:
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- properties:
- type: object
properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- properties:
- type: object
properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
@@ -93,12 +96,14 @@ roles:
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
- type: object
properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
@@ -128,12 +133,14 @@ roles:
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
@@ -157,12 +164,14 @@ roles:
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
@@ -191,11 +200,13 @@ roles:
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
- type: object
properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
+22 -11
View File
@@ -26,12 +26,14 @@ roles:
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
- type: object
properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
@@ -67,12 +69,14 @@ roles:
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
@@ -105,12 +109,14 @@ roles:
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
@@ -133,16 +139,19 @@ roles:
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
- type: object
properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- properties:
- type: object
properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
@@ -169,11 +178,13 @@ roles:
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
- type: object
properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
- type: object
properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
+5
View File
@@ -30,6 +30,7 @@
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0",
},
},
@@ -141,6 +142,8 @@
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="],
@@ -203,6 +206,8 @@
"layerr": ["layerr@3.0.0", "", {}, "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="],
"liquidjs": ["liquidjs@10.27.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
@@ -0,0 +1,305 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`Phase 1: CAS Core 1.3 schema get returns schema JSON (snapshot) 1`] = `
"{
"type": "object",
"required": [
"name"
],
"properties": {
"age": {
"type": "number"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
}"
`;
exports[`Phase 1: CAS Core 1.4 schema list shows registered schemas 1`] = `
"8J5E9WCD54ZJZ (unnamed)
89ZQWMV9PTC7B (unnamed)
86QB0Q5VA7797 (unnamed)
AKEX4KYV98MGT (unnamed)
AGSJVKM01WNKZ (unnamed)
7XX5H51CVD9H0 (unnamed)"
`;
exports[`Phase 1: CAS Core 1.6 get returns node JSON (snapshot) 1`] = `
{
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
}
`;
exports[`Phase 1: CAS Core 1.9 verify returns ok for valid node 1`] = `"ok"`;
exports[`Phase 1: CAS Core 1.10 refs lists direct references (snapshot) 1`] = `""`;
exports[`Phase 1: CAS Core 1.11 walk shows traversal tree (snapshot) 1`] = `"ERARPP19YJT05"`;
exports[`Phase 1: CAS Core 1.13 cat returns full node (snapshot) 1`] = `
{
"payload": {
"age": 30,
"name": "Alice",
},
"type": "7XX5H51CVD9H0",
}
`;
exports[`Phase 1: CAS Core 1.14 cat --payload returns only payload (snapshot) 1`] = `
"{
"age": 30,
"name": "Alice"
}"
`;
exports[`Phase 2: Schema Validation 2.2 schema validate on valid node returns valid 1`] = `"valid"`;
exports[`Phase 2: Schema Validation 2.3 put against non-existent schema hash fails 1`] = `"Schema not found: AAAAAAAAAAAAA"`;
exports[`Phase 3: Variable System 3.1 var set creates variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.2 var get returns variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.3 var list shows all variables 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.4 var list prefix filters by prefix 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.5 var set upsert updates existing variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {},
"value": "F68P1BZ46YDXM",
},
}
`;
exports[`Phase 3: Variable System 3.6 var tag adds kv tag and label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.7 var list --tag env:prod filters by kv tag 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.8 var list --tag important filters by label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [
"important",
],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
exports[`Phase 3: Variable System 3.9 var tag remove deletes label 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": {
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
}
`;
exports[`Phase 3: Variable System 3.10 var delete removes variable 1`] = `
{
"type": "E1D32N3GT69Q8",
"value": [
{
"labels": [],
"name": "myapp/config",
"schema": "7XX5H51CVD9H0",
"tags": {
"env": "prod",
},
"value": "ERARPP19YJT05",
},
],
}
`;
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 4: Template System 4.1 template set registers template 1`] = `
"{
"schemaHash": "7XX5H51CVD9H0",
"contentHash": "FC8WACA792B6F"
}"
`;
exports[`Phase 4: Template System 4.2 template get returns template text 1`] = `"Name: {{ payload.name }}, Age: {{ payload.age }}"`;
exports[`Phase 4: Template System 4.3 template list shows registered templates 1`] = `
"[
{
"schemaHash": "7XX5H51CVD9H0",
"preview": "Name: {{ payload.name }}, Age: {{ payload.age }}"
}
]"
`;
exports[`Phase 4: Template System 4.4 template delete removes template 1`] = `
"{
"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 5: Render 5.1 render fills payload variables 1`] = `"Hello Alice!"`;
exports[`Phase 5: Render 5.2 render --resolution with different value 1`] = `"Hello Alice!"`;
exports[`Phase 7: Edge Cases 7.1 get non-existent hash errors gracefully 1`] = `"Node not found: AAAAAAAAAAAAA"`;
exports[`Phase 7: Edge Cases 7.3 var set empty name errors 1`] = `"Usage: json-cas var set <name> <hash> [--tag <tag>...]"`;
exports[`Phase 7: Edge Cases 7.4 var set name with invalid chars errors 1`] = `"Error: Invalid variable name "invalid name!": Segment "invalid name!" contains invalid characters (only @, a-z, A-Z, 0-9, ., _, - allowed)"`;
exports[`Phase 7: Edge Cases 7.5 schema put invalid schema errors 1`] = `"Invalid schema: input does not conform to the json-cas JSON Schema meta-schema"`;
exports[`Phase 7: Edge Cases 7.6 no subcommand shows help text 1`] = `
"Usage: json-cas [--store <path>] [--json] <command> [args]
Commands:
init Create store dir and write bootstrap seed
bootstrap Write meta-schema seed, print hash
schema put <file.json> Register schema, print type hash
schema get <type-hash> Print schema JSON
schema list List all schemas (name + hash)
schema validate <hash> Validate node against its schema
put <type-hash> <file.json> Store node, print hash
get <hash> Print node as JSON
has <hash> Print true/false
verify <hash> Verify integrity, print ok/corrupted
refs <hash> List direct cas_ref edges
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
render <hash> [options] Render node as YAML with resolution decay
render --pipe/-p [options] Render { type, value } from stdin
cat <hash> [--payload] Output node (--payload for payload only)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
var delete <name> [--schema <hash>] Delete variable(s)
var list [prefix] [--schema <hash>] [--tag <tag>...] List variables
var tag <name> --schema <hash> <operations...> Modify tags/labels
template set <schema-hash> <file> | --inline <text> Set template for schema
template get <schema-hash> Get template content as raw text
template list List all templates
template delete <schema-hash> Delete template for schema
gc Run garbage collection
Flags:
--store <path> Store directory (default: ~/.uncaged/json-cas)
--var-db <path> Variable database path (default: <store>/variables.db)
--json Compact JSON output
--schema <hash> Schema hash filter for var get/delete/tag/list
--tag <tag> Tag/label (can be repeated): key:value (tag), name (label), :name (delete)
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read { type, value } JSON from stdin for render"
`;
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const entrypoint = resolve(import.meta.dir, "index.ts");
let tmpStore: string;
let varDbPath: string;
// Shared hashes across phases
let typeHash: string;
let nodeHash: string;
beforeAll(() => {
tmpStore = mkdtempSync(join(tmpdir(), "json-cas-e2e-"));
varDbPath = join(tmpStore, "variables.db");
});
afterAll(() => {
rmSync(tmpStore, { recursive: true, force: true });
});
async function runCli(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(
["bun", entrypoint, "--store", tmpStore, "--var-db", varDbPath, ...args],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stdout = (await new Response(proc.stdout).text()).trim();
const stderr = (await new Response(proc.stderr).text()).trim();
return { stdout, stderr, exitCode };
}
/**
* Parse JSON and strip volatile fields (timestamp, created, updated)
* so snapshots are stable across runs.
*/
function stripVolatile(json: string): unknown {
const strip = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(strip);
if (v !== null && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (k === "timestamp" || k === "created" || k === "updated") continue;
out[k] = strip(val);
}
return out;
}
return v;
};
return strip(JSON.parse(json));
}
// ---- Phase 1: CAS Core ----
describe("Phase 1: CAS Core", () => {
test("1.1 bootstrap returns 13-char Base32 hash", async () => {
const { stdout, exitCode } = await runCli(["init"]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("1.2 schema put returns type hash", async () => {
const schemaFile = join(tmpStore, "test-schema.json");
writeFileSync(
schemaFile,
JSON.stringify({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
additionalProperties: false,
}),
);
const { stdout, exitCode } = await runCli(["schema", "put", schemaFile]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
typeHash = stdout;
});
test("1.3 schema get returns schema JSON (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["schema", "get", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.4 schema list shows registered schemas", async () => {
const { stdout, exitCode } = await runCli(["schema", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
expect(stdout).toContain(typeHash);
});
test("1.5 put returns node hash", async () => {
const nodeFile = join(tmpStore, "test-node.json");
writeFileSync(nodeFile, JSON.stringify({ name: "Alice", age: 30 }));
const { stdout, exitCode } = await runCli(["put", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
nodeHash = stdout;
});
test("1.6 get returns node JSON (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["get", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.7 has returns true for existing node", async () => {
const { stdout, exitCode } = await runCli(["has", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("true");
});
test("1.8 has returns false for non-existing hash", async () => {
const { stdout, exitCode } = await runCli(["has", "AAAAAAAAAAAAA"]);
expect(exitCode).toBe(0);
expect(stdout).toBe("false");
});
test("1.9 verify returns ok for valid node", async () => {
const { stdout, exitCode } = await runCli(["verify", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.10 refs lists direct references (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["refs", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.11 walk shows traversal tree (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["walk", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("1.12 hash dry-run returns same hash as put", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stdout, exitCode } = await runCli(["hash", typeHash, nodeFile]);
expect(exitCode).toBe(0);
expect(stdout).toBe(nodeHash);
});
test("1.13 cat returns full node (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["cat", nodeHash]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("1.14 cat --payload returns only payload (snapshot)", async () => {
const { stdout, exitCode } = await runCli(["cat", nodeHash, "--payload"]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
const parsed = JSON.parse(stdout) as Record<string, unknown>;
expect(parsed).not.toHaveProperty("type");
expect(parsed).toHaveProperty("name", "Alice");
});
});
// ---- Phase 2: Schema Validation ----
describe("Phase 2: Schema Validation", () => {
test("2.1 put {name:123} against string-schema fails with non-zero exit", async () => {
const badFile = join(tmpStore, "bad-node.json");
writeFileSync(badFile, JSON.stringify({ name: 123 }));
const { stdout, stderr, exitCode } = await runCli([
"put",
typeHash,
badFile,
]);
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toContain("Validation failed");
expect(stderr).toContain(typeHash);
// Do NOT snapshot stderr — it embeds a machine-specific tmp path
});
test("2.2 schema validate on valid node returns valid", async () => {
const { stdout, exitCode } = await runCli(["schema", "validate", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("2.3 put against non-existent schema hash fails", async () => {
const nodeFile = join(tmpStore, "test-node.json");
const { stderr, exitCode } = await runCli([
"put",
"AAAAAAAAAAAAA",
nodeFile,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 3: Variable System ----
describe("Phase 3: Variable System", () => {
test("3.1 var set creates variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
nodeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.2 var get returns variable", async () => {
const { stdout, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain(nodeHash);
});
test("3.3 var list shows all variables", async () => {
const { stdout, exitCode } = await runCli(["var", "list"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.4 var list prefix filters by prefix", async () => {
const { stdout, exitCode } = await runCli(["var", "list", "myapp/"]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
expect(stdout).toContain("myapp/config");
});
test("3.5 var set upsert updates existing variable", async () => {
const node2File = join(tmpStore, "node2.json");
writeFileSync(node2File, JSON.stringify({ name: "Bob", age: 25 }));
const { stdout: node2Hash } = await runCli(["put", typeHash, node2File]);
const { exitCode, stdout } = await runCli([
"var",
"set",
"myapp/config",
node2Hash.trim(),
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Restore original value
await runCli(["var", "set", "myapp/config", nodeHash]);
});
test("3.6 var tag adds kv tag and label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
"env:prod",
"important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.7 var list --tag env:prod filters by kv tag", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"env:prod",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.8 var list --tag important filters by label", async () => {
const { stdout, exitCode } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("myapp/config");
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.9 var tag remove deletes label", async () => {
const { exitCode, stdout } = await runCli([
"var",
"tag",
"myapp/config",
"--schema",
typeHash,
":important",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
// Verify label is gone
const { stdout: listOut } = await runCli([
"var",
"list",
"--tag",
"important",
]);
expect(listOut).not.toContain("myapp/config");
});
test("3.10 var delete removes variable", async () => {
const { exitCode, stdout } = await runCli([
"var",
"delete",
"myapp/config",
]);
expect(exitCode).toBe(0);
expect(stripVolatile(stdout)).toMatchSnapshot();
});
test("3.11 var get deleted variable returns not found", async () => {
const { stderr, exitCode } = await runCli([
"var",
"get",
"myapp/config",
"--schema",
typeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 4: Template System ----
describe("Phase 4: Template System", () => {
test("4.1 template set registers template", async () => {
const tmplFile = join(tmpStore, "test.liquid");
writeFileSync(tmplFile, "Name: {{ payload.name }}, Age: {{ payload.age }}");
const { exitCode, stdout } = await runCli([
"template",
"set",
typeHash,
tmplFile,
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("4.2 template get returns template text", async () => {
const { stdout, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Name: {{ payload.name }}, Age: {{ payload.age }}");
expect(stdout).toMatchSnapshot();
});
test("4.3 template list shows registered templates", async () => {
const { stdout, exitCode } = await runCli(["template", "list"]);
expect(exitCode).toBe(0);
expect(stdout).toContain(typeHash);
expect(stdout).toMatchSnapshot();
});
test("4.4 template delete removes template", async () => {
const { exitCode, stdout } = await runCli(["template", "delete", typeHash]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("4.5 template get deleted template returns not found", async () => {
const { stderr, exitCode } = await runCli(["template", "get", typeHash]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
});
// ---- Phase 5: Render ----
describe("Phase 5: Render", () => {
beforeAll(async () => {
const tmplFile = join(tmpStore, "render-template.liquid");
writeFileSync(tmplFile, "Hello {{ payload.name }}!");
await runCli(["template", "set", typeHash, tmplFile]);
});
test("5.1 render fills payload variables", async () => {
const { stdout, exitCode } = await runCli(["render", nodeHash]);
expect(exitCode).toBe(0);
expect(stdout).toBe("Hello Alice!");
expect(stdout).toMatchSnapshot();
});
test("5.2 render --resolution with different value", async () => {
const { stdout, exitCode } = await runCli([
"render",
nodeHash,
"--resolution",
"0.5",
]);
expect(exitCode).toBe(0);
expect(stdout).toMatchSnapshot();
});
test("5.3 render non-existent hash fails with error", async () => {
const { stderr, exitCode } = await runCli(["render", "ZZZZZZZZZZZZZ"]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Node not found");
expect(stderr).toContain("ZZZZZZZZZZZZZ");
});
});
// ---- Phase 6: GC ----
describe("Phase 6: GC", () => {
let gcNodeHash: string;
beforeAll(async () => {
// Create a fresh node for GC tests (independent of shared nodeHash)
const gcNodeFile = join(tmpStore, "gc-node.json");
writeFileSync(gcNodeFile, JSON.stringify({ name: "GcAlice", age: 30 }));
const { stdout } = await runCli(["put", typeHash, gcNodeFile]);
gcNodeHash = stdout.trim();
// Set a var referencing this node so it survives GC during Phase 6
await runCli(["var", "set", "gc-test/ref", gcNodeHash]);
});
test("6.1 gc runs without error", async () => {
const { exitCode, stdout } = await runCli(["gc"]);
expect(exitCode).toBe(0);
// Assert structural shape only — exact counts depend on phase history
const result = JSON.parse(stdout) as Record<string, unknown>;
expect(typeof result.total).toBe("number");
expect(typeof result.reachable).toBe("number");
expect(typeof result.collected).toBe("number");
expect(typeof result.scanned).toBe("number");
expect(result.total as number).toBeGreaterThanOrEqual(
result.reachable as number,
);
});
test("6.2 gc preserves node referenced by a var", async () => {
const { exitCode } = await runCli(["gc"]);
expect(exitCode).toBe(0);
const { stdout } = await runCli(["has", gcNodeHash]);
expect(stdout).toBe("true");
});
test("6.3 gc reclaims orphan node", async () => {
const orphanFile = join(tmpStore, "orphan.json");
writeFileSync(orphanFile, JSON.stringify({ name: "Orphan", age: 99 }));
const { stdout: orphanHashRaw } = await runCli([
"put",
typeHash,
orphanFile,
]);
const orphanHash = orphanHashRaw.trim();
const { stdout: beforeGc } = await runCli(["has", orphanHash]);
expect(beforeGc).toBe("true");
await runCli(["gc"]);
const { stdout: afterGc } = await runCli(["has", orphanHash]);
expect(afterGc).toBe("false");
});
});
// ---- Phase 7: Edge Cases ----
describe("Phase 7: Edge Cases", () => {
test("7.1 get non-existent hash errors gracefully", async () => {
const { stderr, exitCode } = await runCli(["get", "AAAAAAAAAAAAA"]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatchSnapshot();
});
test("7.2 put with non-existent file errors with ENOENT", async () => {
const { stderr, exitCode } = await runCli([
"put",
typeHash,
"/nonexistent/file.json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toContain("ENOENT");
});
test("7.3 var set empty name errors", async () => {
const { stderr, exitCode } = await runCli(["var", "set", "", nodeHash]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.4 var set name with invalid chars errors", async () => {
const { stderr, exitCode } = await runCli([
"var",
"set",
"invalid name!",
nodeHash,
]);
expect(exitCode).not.toBe(0);
expect(stderr.length).toBeGreaterThan(0);
expect(stderr).toMatchSnapshot();
});
test("7.5 schema put invalid schema errors", async () => {
const badSchemaFile = join(tmpStore, "bad-schema.json");
writeFileSync(badSchemaFile, JSON.stringify({ type: "invalid" }));
const { stdout, stderr, exitCode } = await runCli([
"schema",
"put",
badSchemaFile,
]);
expect(exitCode).not.toBe(0);
expect(stdout).toBe("");
expect(stderr).toMatchSnapshot();
});
test("7.6 no subcommand shows help text", async () => {
const { stdout, stderr, exitCode: _exitCode } = await runCli([]);
const combined = stdout + stderr;
expect(combined.length).toBeGreaterThan(0);
expect(combined).toMatchSnapshot();
expect(combined.toLowerCase()).toContain("usage");
});
test("7.7 --store non-existent path errors with Store not found", async () => {
const fakeStore = "/nonexistent/store/path";
const proc = Bun.spawn(
[
"bun",
entrypoint,
"--store",
fakeStore,
"--var-db",
varDbPath,
"get",
"AAAAAAAAAAAAA",
],
{ stdout: "pipe", stderr: "pipe" },
);
const exitCode = await proc.exited;
const stderr = (await new Response(proc.stderr).text()).trim();
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Store not found");
expect(stderr).not.toContain("Node not found");
});
});
+127 -19
View File
@@ -15,7 +15,9 @@ import {
InvalidVariableNameError,
putSchema,
refs,
render,
renderAsync,
renderDirect,
SchemaValidationError,
TagLabelConflictError,
VariableNotFoundError,
validate,
@@ -110,12 +112,26 @@ function readJsonFile(file: string): unknown {
}
}
function openStore(): Store {
return createFsStore(resolve(storePath));
/**
* Validate that the store directory exists.
* Dies with a clear error message if not found.
*/
function validateStoreExists(path: string): void {
if (!existsSync(path)) {
die(`Store not found at ${path}`);
}
}
function openStore(shouldCreate = false): Store {
const fullPath = resolve(storePath);
if (!shouldCreate) {
validateStoreExists(fullPath);
}
return createFsStore(fullPath);
}
function openVarStore(): VariableStore {
const store = openStore();
const store = openStore(true);
mkdirSync(resolve(storePath), { recursive: true });
return createVariableStore(resolve(varDbPath), store);
}
@@ -125,9 +141,12 @@ function openVarStore(): VariableStore {
* If the input starts with @, resolve it via bootstrap
* Otherwise, return the hash as-is
*/
async function resolveTypeHash(typeHashOrAlias: string): Promise<Hash> {
async function resolveTypeHash(
typeHashOrAlias: string,
shouldCreate = false,
): Promise<Hash> {
if (typeHashOrAlias.startsWith("@")) {
const store = openStore();
const store = openStore(shouldCreate);
const builtinSchemas = await bootstrap(store);
const resolvedHash = builtinSchemas[typeHashOrAlias];
if (!resolvedHash) {
@@ -231,7 +250,7 @@ async function cmdInit(): Promise<void> {
}
async function cmdBootstrap(): Promise<void> {
const store = openStore();
const store = openStore(true);
const builtinSchemas = await bootstrap(store);
const metaHash = builtinSchemas["@schema"];
console.log(metaHash);
@@ -241,9 +260,16 @@ async function cmdSchemaPut(args: string[]): Promise<void> {
const file = args[0];
if (!file) die("Usage: json-cas schema put <file.json>");
const schema = readJsonFile(file) as JSONSchema;
const store = openStore();
const hash = await putSchema(store, schema);
console.log(hash);
const store = openStore(true);
try {
const hash = await putSchema(store, schema);
console.log(hash);
} catch (e) {
if (e instanceof SchemaValidationError) {
die(e.message);
}
throw e;
}
}
async function cmdSchemaGet(args: string[]): Promise<void> {
@@ -290,9 +316,26 @@ async function cmdPut(args: string[]): Promise<void> {
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas put <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const payload = readJsonFile(file);
const store = openStore();
const store = openStore(true);
// Check if schema exists
const schema = getSchema(store, typeHash);
if (schema === null) {
console.error(`Schema not found: ${typeHash}`);
process.exit(1);
}
// Validate payload against schema before storing
const tempNode = { type: typeHash, payload, timestamp: Date.now() };
if (!validate(store, tempNode)) {
console.error(
`Validation failed: payload in ${file} does not match schema ${typeHash}`,
);
process.exit(1);
}
const hash = await store.put(typeHash, payload);
console.log(hash);
}
@@ -379,17 +422,23 @@ async function cmdHash(args: string[]): Promise<void> {
const file = args[1];
if (!typeHashOrAlias || !file)
die("Usage: json-cas hash <type-hash> <file.json>");
const typeHash = await resolveTypeHash(typeHashOrAlias);
const typeHash = await resolveTypeHash(typeHashOrAlias, true);
const payload = readJsonFile(file);
const hash = await computeHash(typeHash, payload);
console.log(hash);
}
async function cmdRender(args: string[]): Promise<void> {
const isPipe = flags.pipe === true || flags.p === true;
const hash = args[0];
if (!hash) {
if (isPipe && hash) {
die("Cannot use --pipe/-p with a hash argument. Use one or the other.");
}
if (!isPipe && !hash) {
die(
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]",
"Usage: ucas render <hash> [--resolution <n>] [--decay <n>] [--epsilon <n>]\n ucas render --pipe/-p [--resolution <n>] [--decay <n>] [--epsilon <n>]",
);
}
@@ -421,10 +470,67 @@ async function cmdRender(args: string[]): Promise<void> {
}
try {
const output = render(store, hash, { resolution, decay, epsilon });
// Output to stdout without JSON wrapping (raw YAML)
process.stdout.write(output);
if (isPipe) {
// Read { type, value } JSON from stdin
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const input = Buffer.concat(chunks).toString("utf-8").trim();
if (!input) {
die("No input on stdin. Pipe a { type, value } JSON envelope.");
}
let envelope: { type: string; value: unknown };
try {
envelope = JSON.parse(input) as { type: string; value: unknown };
} catch {
die("Invalid JSON on stdin. Expected { type, value } envelope.");
return; // unreachable, for TS
}
if (
typeof envelope !== "object" ||
envelope === null ||
typeof envelope.type !== "string" ||
!("value" in envelope)
) {
die("Invalid envelope. Expected { type: string, value: unknown }.");
}
// Validate type hash format: 13-char uppercase Crockford Base32
if (!/^[0-9A-Z]{13}$/.test(envelope.type)) {
die(
`Invalid type hash: "${envelope.type}". Expected 13-character uppercase Crockford Base32 string.`,
);
}
const output = renderDirect(
envelope.type as Hash,
envelope.value,
store,
{
resolution,
decay,
epsilon,
},
);
process.stdout.write(output);
} else {
const varStore = openVarStore();
const output = await renderAsync(store, hash, {
resolution,
decay,
epsilon,
varStore,
});
// Output to stdout without JSON wrapping (raw output)
process.stdout.write(output);
}
} catch (error) {
if (error instanceof CasNodeNotFoundError) {
die(`Error: Node not found: ${error.hash}`);
}
if (error instanceof Error) {
die(error.message);
}
@@ -829,6 +935,7 @@ Commands:
walk <hash> [--format tree] Recursive traversal
hash <type-hash> <file.json> Compute hash without storing (dry run)
render <hash> [options] Render node as YAML with resolution decay
render --pipe/-p [options] Render { type, value } from stdin
cat <hash> [--payload] Output node (--payload for payload only)
var set <name> <hash> [--tag <tag>...] Create/update a variable
var get <name> --schema <hash> Get a variable by name + schema
@@ -850,7 +957,8 @@ Flags:
--inline <text> Inline text content for template set
--resolution <n> Initial resolution for render (default: 1.0)
--decay <n> Decay factor for render (default: 0.5)
--epsilon <n> Cutoff threshold for render (default: 0.01)`);
--epsilon <n> Cutoff threshold for render (default: 0.01)
--pipe, -p Read { type, value } JSON from stdin for render`);
}
// ---- Dispatch ----
+1
View File
@@ -21,6 +21,7 @@
"dependencies": {
"ajv": "^8.20.0",
"cborg": "^4.2.3",
"liquidjs": "^10.27.0",
"xxhash-wasm": "^1.1.0"
}
}
+7 -1
View File
@@ -4,7 +4,13 @@ export { BOOTSTRAP_STORE } from "./bootstrap-capable.js";
export { cborEncode } from "./cbor.js";
export { type GcStats, gc } from "./gc.js";
export { computeHash, computeSelfHash } from "./hash.js";
export { type RenderOptions, render } from "./render.js";
export { renderWithTemplate } from "./liquid-render.js";
export {
type RenderOptions,
render,
renderAsync,
renderDirect,
} from "./render.js";
export type { JSONSchema } from "./schema.js";
export {
getSchema,
File diff suppressed because it is too large Load Diff
+285
View File
@@ -0,0 +1,285 @@
import { type Context, Liquid, type TagToken } from "liquidjs";
import type { RenderOptions } from "./render.js";
import { putSchema } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
const DEFAULT_RESOLUTION = 1.0;
const DEFAULT_DECAY = 0.5;
const DEFAULT_EPSILON = 0.01;
const FLOAT_TOLERANCE = 1e-10;
/**
* Render a CAS node using LiquidJS templates with resolution-based decay.
* Templates are discovered via variables: @ucas/template/text/<type-hash>
*/
export async function renderWithTemplate(
store: Store,
varStore: VariableStore,
hash: Hash,
options?: RenderOptions,
): Promise<string> {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
if (decay <= 0 || decay > 1) {
throw new Error("decay must be in (0, 1]");
}
if (epsilon < 0) {
throw new Error("epsilon must be >= 0");
}
const visited = new Set<Hash>();
// Create Liquid engine
const engine = createLiquidEngine(store, varStore, decay);
return await renderNode(
engine,
store,
varStore,
hash,
resolution,
epsilon,
visited,
);
}
/**
* Create a Liquid engine instance with custom render tag
*/
function createLiquidEngine(
store: Store,
varStore: VariableStore,
globalDecay: number,
): Liquid {
const engine = new Liquid({
strictFilters: false,
strictVariables: false,
});
// Type for storing parsed tag data
type RenderTagState = {
variable: string;
decay: number | undefined;
};
// Register custom {% render %} tag
// Capture store, varStore, globalDecay in closure
engine.registerTag("render", {
parse(token: TagToken) {
// Parse "variable" or "variable, decay: 0.7" syntax
const args = token.args.trim();
const match = args.match(/^(\S+)(?:,\s*decay:\s*([\d.]+))?$/);
if (!match) {
throw new Error(
`Invalid render tag syntax: ${args}. Expected: {% render variable %} or {% render variable, decay: 0.7 %}`,
);
}
// Store parsed values on the tag instance
const state = this as unknown as RenderTagState;
state.variable = match[1] as string;
state.decay = match[2] ? Number.parseFloat(match[2]) : undefined;
// Validate decay if provided
if (state.decay !== undefined) {
if (state.decay <= 0 || state.decay > 1) {
throw new Error("decay must be in (0, 1]");
}
}
},
async render(ctxLiquid: Context) {
// Access parsed values
const state = this as unknown as RenderTagState;
const variable = state.variable;
const explicitDecay = state.decay;
// Resolve the variable to a hash (split on dots for nested paths)
const variablePath = variable.split(".");
const value = ctxLiquid.get(variablePath);
// Handle null/undefined - render as empty
if (value === null || value === undefined) {
return "";
}
// Handle non-string values - render as empty
if (typeof value !== "string") {
return "";
}
const nodeHash = value as Hash;
// Get current render context
const currentResolution = ctxLiquid.get(["resolution"]) as number;
const currentEpsilon = ctxLiquid.get(["epsilon"]) as number;
// Compute child resolution using decay priority:
// 1. Template explicit decay (explicitDecay)
// 2. Global decay (from CLI/options)
// 3. Engine default (0.5)
const effectiveDecay =
explicitDecay !== undefined
? explicitDecay
: (globalDecay ?? DEFAULT_DECAY);
const childResolution = currentResolution * effectiveDecay;
// Recursively render the referenced node
const visited = ctxLiquid.get(["__visited"]) as Set<Hash>;
const output = await renderNode(
engine,
store,
varStore,
nodeHash,
childResolution,
currentEpsilon,
visited,
);
return output;
},
});
return engine;
}
/**
* Render a single node with template or fallback to cas: reference
*/
async function renderNode(
engine: Liquid,
store: Store,
varStore: VariableStore,
hash: Hash,
currentResolution: number,
epsilon: number,
visited: Set<Hash>,
): Promise<string> {
// Check if resolution is below threshold
if (currentResolution < epsilon + FLOAT_TOLERANCE) {
return `cas:${hash}`;
}
// Fetch the node
const node = store.get(hash);
if (node === null) {
return `cas:${hash}`;
}
// Cycle detection
if (visited.has(hash)) {
return `cas:${hash}`;
}
visited.add(hash);
try {
// Try to find a template for this node's type
const template = await findTemplate(store, varStore, node.type);
if (template === null) {
// No template found - this is handled by the caller (fallback to YAML)
// For now, return a simple representation
visited.delete(hash);
return renderFallback(store, node.payload);
}
// Render using the template
const context = {
resolution: currentResolution,
epsilon,
hash,
payload: node.payload,
type: node.type,
timestamp: node.timestamp,
__visited: visited, // Pass visited set through context
};
const output = await engine.parseAndRender(template, context);
visited.delete(hash);
return output;
} catch (error) {
visited.delete(hash);
throw error;
}
}
/**
* Find a template for a given type hash
*/
async function findTemplate(
store: Store,
varStore: VariableStore,
typeHash: Hash,
): Promise<string | null> {
const varName = `@ucas/template/text/${typeHash}`;
try {
// Find the string schema hash (we need this to query variables)
const stringSchema = await putSchema(store, { type: "string" });
const variable = varStore.get(varName, stringSchema);
if (variable === null) {
return null;
}
const templateNode = store.get(variable.value);
if (templateNode === null) {
return null;
}
// Template should be a string
if (typeof templateNode.payload !== "string") {
return null;
}
return templateNode.payload;
} catch {
return null;
}
}
/**
* Fallback renderer for nodes without templates
*/
function renderFallback(_store: Store, payload: unknown): string {
// Simple YAML-like representation
if (payload === null) {
return "null\n";
}
if (typeof payload === "string") {
return `${payload}\n`;
}
if (typeof payload === "number" || typeof payload === "boolean") {
return `${payload}\n`;
}
if (Array.isArray(payload)) {
if (payload.length === 0) {
return "[]\n";
}
return `- ${payload.join("\n- ")}\n`;
}
if (typeof payload === "object") {
const obj = payload as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 0) {
return "{}\n";
}
const pairs = keys.map((key) => `${key}: ${obj[key]}`);
return `${pairs.join("\n")}\n`;
}
return "null\n";
}
+224 -7
View File
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test";
import { bootstrap } from "./bootstrap.js";
import { render } from "./render.js";
import { render, renderAsync, renderDirect } from "./render.js";
import { putSchema } from "./schema.js";
import { createMemoryStore } from "./store.js";
import type { Hash } from "./types.js";
import { CasNodeNotFoundError } from "./variable-store.js";
describe("Suite 1: Basic Rendering (No Nesting)", () => {
test("1.1 Render Simple Primitives", async () => {
@@ -65,13 +66,14 @@ describe("Suite 1: Basic Rendering (No Nesting)", () => {
expect(output.trim()).toBe(`cas:${hash}`);
});
test("1.5 Render Non-existent Hash", () => {
test("1.5 Render Non-existent Hash Throws Error", () => {
const store = createMemoryStore();
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
// Non-existent node renders as cas: reference
const output = render(store, fakeHash);
expect(output.trim()).toBe(`cas:${fakeHash}`);
// Non-existent root node should throw
expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError);
expect(() => render(store, fakeHash)).toThrow("CAS node not found");
expect(() => render(store, fakeHash)).toThrow(fakeHash);
});
});
@@ -793,9 +795,10 @@ describe("Suite 6: Schema Integration", () => {
test("6.5 Schema-less Node (Bootstrap Node)", async () => {
const store = createMemoryStore();
const metaHash = await bootstrap(store);
const types = await bootstrap(store);
const schemaHash = types["@schema"];
const output = render(store, metaHash);
const output = render(store, schemaHash);
// Should render without recursive expansion
expect(output).toBeTruthy();
@@ -933,3 +936,217 @@ describe("Suite 8: Performance & Edge Cases", () => {
expect(output).toContain("🌍");
});
});
describe("Suite 9: renderDirect (in-memory rendering)", () => {
test("9.1 Render primitive value without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, "hello world", null, null);
expect(output.trim()).toBe("hello world");
});
test("9.2 Render object value without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(
fakeTypeHash,
{
name: "Alice",
age: 30,
},
null,
null,
);
expect(output).toContain("name: Alice");
expect(output).toContain("age: 30");
});
test("9.3 Render array value without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, ["a", "b", "c"], null, null);
expect(output).toContain("-");
expect(output).toContain("a");
expect(output).toContain("b");
expect(output).toContain("c");
});
test("9.4 Render nested object without store", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(
fakeTypeHash,
{
user: { name: "Bob", role: "admin" },
active: true,
},
null,
null,
);
expect(output).toContain("name: Bob");
expect(output).toContain("role: admin");
expect(output).toContain("active: true");
});
test("9.5 Render with store expands cas_ref fields", async () => {
const store = createMemoryStore();
await bootstrap(store);
// Create a child node
const childSchema = await putSchema(store, {
type: "object",
properties: { msg: { type: "string" } },
});
const childHash = await store.put(childSchema, { msg: "inner" });
// Parent schema with cas_ref
const parentSchema = await putSchema(store, {
type: "object",
properties: {
child: { type: "string", format: "cas_ref" },
},
});
// Render directly with store — cas_ref should expand
const output = renderDirect(
parentSchema,
{ child: childHash },
store,
null,
);
expect(output).toContain("msg: inner");
});
test("9.6 Render with resolution/decay options", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, { key: "value" }, null, {
resolution: 0.5,
decay: 0.8,
});
expect(output).toContain("key: value");
});
test("9.7 Validate parameters", () => {
const fakeTypeHash = "0000000000000" as Hash;
expect(() =>
renderDirect(fakeTypeHash, "x", null, { resolution: 2 }),
).toThrow("resolution must be in [0, 1]");
expect(() => renderDirect(fakeTypeHash, "x", null, { decay: 0 })).toThrow(
"decay must be in (0, 1]",
);
expect(() =>
renderDirect(fakeTypeHash, "x", null, { epsilon: -1 }),
).toThrow("epsilon must be >= 0");
});
test("9.8 Render null value", () => {
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, null, null, null);
expect(output.trim()).toBe("null");
});
test("9.9 cas_ref without store renders as cas: reference", () => {
// Without store, can't identify cas_ref fields — hash strings stay as strings
const fakeTypeHash = "0000000000000" as Hash;
const someHash = "ABCDEFGH12345" as Hash;
const output = renderDirect(fakeTypeHash, { ref: someHash }, null, null);
// Without store, it's just a string value
expect(output).toContain(`ref: ${someHash}`);
});
test("9.10 store present but schema missing — renders without ref expansion", async () => {
const store = createMemoryStore();
await bootstrap(store);
const unknownType = "ZZZZZZZZZZZZ0" as Hash;
const output = renderDirect(unknownType, { key: "val" }, store, null);
expect(output).toContain("key: val");
});
});
describe("Suite 10: Missing Root Hash Error Handling (Issue #53)", () => {
test("10.1 renderAsync() throws CasNodeNotFoundError for missing root hash", async () => {
const store = createMemoryStore();
await bootstrap(store);
const fakeHash = "AAAAAAAAAAAAA" as Hash;
await expect(renderAsync(store, fakeHash)).rejects.toThrow(
CasNodeNotFoundError,
);
await expect(renderAsync(store, fakeHash)).rejects.toThrow(
"CAS node not found",
);
await expect(renderAsync(store, fakeHash)).rejects.toThrow(fakeHash);
});
test("10.2 render() throws CasNodeNotFoundError for missing root hash", () => {
const store = createMemoryStore();
const fakeHash = "ZZZZZZZZZZZZZ" as Hash;
expect(() => render(store, fakeHash)).toThrow(CasNodeNotFoundError);
expect(() => render(store, fakeHash)).toThrow("CAS node not found");
expect(() => render(store, fakeHash)).toThrow(fakeHash);
});
test("10.3 renderDirect() does NOT throw for non-existent type hash", () => {
const store = createMemoryStore();
const fakeTypeHash = "0000000000000" as Hash;
const output = renderDirect(fakeTypeHash, { key: "value" }, store, null);
expect(output).toContain("key: value");
});
test("10.4 Missing nested node renders as cas: reference (no error)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const parentSchema = await putSchema(store, {
type: "object",
properties: {
title: { type: "string" },
child: { type: "string", format: "cas_ref" },
},
});
const fakeChildHash = "ZZZZZZZZZZZZZ" as Hash;
const parentHash = await store.put(parentSchema, {
title: "root",
child: fakeChildHash,
});
const output = render(store, parentHash);
expect(output).toContain("title: root");
expect(output).toContain(`cas:${fakeChildHash}`);
});
test("10.5 Resolution below epsilon renders as cas: reference (no error)", async () => {
const store = createMemoryStore();
await bootstrap(store);
const nodeSchema = await putSchema(store, {
type: "object",
properties: {
level: { type: "number" },
next: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
});
// Create 3-level chain
let currentHash: Hash | null = null;
for (let i = 2; i >= 0; i--) {
currentHash = await store.put(nodeSchema, {
level: i,
next: currentHash,
});
}
const output = render(store, currentHash as Hash, {
resolution: 1.0,
decay: 0.1,
epsilon: 0.5,
});
// Level 0 should be expanded (resolution = 1.0 > 0.5)
expect(output).toContain("level: 0");
// Level 1+ should be cas: references (0.1, 0.01 < 0.5)
expect(output).toContain("cas:");
});
});
+136 -14
View File
@@ -1,10 +1,14 @@
import { refs } from "./schema.js";
import { renderWithTemplate } from "./liquid-render.js";
import { collectRefs, getSchema, putSchema, refs } from "./schema.js";
import type { Hash, Store } from "./types.js";
import type { VariableStore } from "./variable-store.js";
import { CasNodeNotFoundError } from "./variable-store.js";
export type RenderOptions = {
resolution?: number; // (0, 1], default 1.0
decay?: number; // (0, 1], default 0.5
epsilon?: number; // >= 0, default 0.01
varStore?: VariableStore; // Optional: for template lookup
};
const DEFAULT_RESOLUTION = 1.0;
@@ -14,19 +18,18 @@ const DEFAULT_EPSILON = 0.01;
const FLOAT_TOLERANCE = 1e-10;
/**
* Render a CAS node as YAML with resolution-based decay.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
* Extract and validate resolution/decay/epsilon from options.
*/
export function render(
store: Store,
hash: Hash,
options?: RenderOptions,
): string {
function validateAndExtractOptions(
options:
| Pick<RenderOptions, "resolution" | "decay" | "epsilon">
| null
| undefined,
): { resolution: number; decay: number; epsilon: number } {
const resolution = options?.resolution ?? DEFAULT_RESOLUTION;
const decay = options?.decay ?? DEFAULT_DECAY;
const epsilon = options?.epsilon ?? DEFAULT_EPSILON;
// Validate parameters
if (resolution < 0 || resolution > 1) {
throw new Error("resolution must be in [0, 1]");
}
@@ -37,14 +40,133 @@ export function render(
throw new Error("epsilon must be >= 0");
}
const visited = new Set<Hash>();
return { resolution, decay, epsilon };
}
/**
* Render a CAS node as YAML with resolution-based decay.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
* This is the synchronous version without template support.
* For template support, use renderAsync() with varStore.
*/
export function render(
store: Store,
hash: Hash,
options?: RenderOptions,
): string {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Check if root node exists
if (store.get(hash) === null) {
throw new CasNodeNotFoundError(hash);
}
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
function renderNode(
/**
* Async render with LiquidJS template support.
* When resolution ≤ epsilon, nodes are rendered as opaque `cas:<hash>` references.
* If varStore is provided, attempts to use LiquidJS templates first, fallback to YAML.
*/
export async function renderAsync(
store: Store,
hash: Hash,
options?: RenderOptions,
): Promise<string> {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Check if root node exists
if (store.get(hash) === null) {
throw new CasNodeNotFoundError(hash);
}
const varStore = options?.varStore;
// If varStore provided, try template rendering first
if (varStore !== undefined) {
try {
const node = store.get(hash);
if (node !== null) {
// Check if a template exists for this type
const templateExists = await hasTemplate(store, varStore, node.type);
if (templateExists) {
return await renderWithTemplate(store, varStore, hash, {
resolution,
decay,
epsilon,
});
}
}
} catch {
// Fall through to YAML rendering
}
}
// Fallback to YAML rendering
const visited = new Set<Hash>();
return renderNode(store, hash, resolution, decay, epsilon, visited);
}
/**
* Render a value directly (in-memory) without requiring it to be stored.
* Accepts a raw { type, value } pair. Store is optional and read-only —
* used only for schema lookup and expanding nested cas_ref references.
* No data is written to the store.
*/
export function renderDirect(
typeHash: Hash,
value: unknown,
store: Store | null,
options: Omit<RenderOptions, "varStore"> | null,
): string {
const { resolution, decay, epsilon } = validateAndExtractOptions(options);
// Try to get schema from store to identify cas_ref fields
let refSet = new Set<Hash>();
if (store !== null) {
const schema = getSchema(store, typeHash);
if (schema !== null) {
refSet = new Set(collectRefs(schema, value));
}
}
const childResolution = resolution * decay;
const visited = new Set<Hash>();
return renderValue(
store ?? null,
value,
refSet,
childResolution,
decay,
epsilon,
visited,
);
}
/**
* Check if a template exists for a given type
*/
async function hasTemplate(
store: Store,
varStore: VariableStore,
typeHash: Hash,
): Promise<boolean> {
const varName = `@ucas/template/text/${typeHash}`;
try {
const stringSchema = await putSchema(store, { type: "string" });
const variable = varStore.get(varName, stringSchema);
return variable !== null;
} catch {
return false;
}
}
function renderNode(
store: Store | null,
hash: Hash,
currentResolution: number,
decay: number,
epsilon: number,
@@ -56,7 +178,7 @@ function renderNode(
}
// Fetch the node
const node = store.get(hash);
const node = store !== null ? store.get(hash) : null;
if (node === null) {
// Missing node - render as cas: reference
return `cas:${hash}`;
@@ -69,7 +191,7 @@ function renderNode(
visited.add(hash);
// Get references from this node's schema
const nodeRefs = refs(store, node);
const nodeRefs = store !== null ? refs(store, node) : [];
const refSet = new Set(nodeRefs);
// Calculate child resolution for next level
@@ -92,7 +214,7 @@ function renderNode(
}
function renderValue(
store: Store,
store: Store | null,
value: unknown,
refHashes: Set<Hash>,
childResolution: number,
+1 -1
View File
@@ -186,7 +186,7 @@ export function validate(store: Store, node: CasNode): boolean {
* Handles: direct format, anyOf (nullable refs), items (array refs),
* properties (nested objects), and additionalProperties (record refs).
*/
function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
export function collectRefs(schema: JSONSchema, value: unknown): Hash[] {
const result: Hash[] = [];
if (schema.format === "cas_ref") {
+5 -2
View File
@@ -36,8 +36,11 @@ export class SchemaMismatchError extends Error {
}
export class CasNodeNotFoundError extends Error {
constructor(hash: string) {
super(`CAS node not found: ${hash}`);
constructor(
public readonly hash: string,
message?: string,
) {
super(message ?? `CAS node not found: ${hash}`);
this.name = "CasNodeNotFoundError";
}
}