Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Schema hierarchy levels support DAG (multi-parent) relationships via configurabl
**Customizing Schemas:**
- **Partial schemas**: Files starting with an underscore (like `_ost_tools_base.json`) are loaded and used to resolve references (using `$ref`).
- **Loading priority**: Partial schemas are loaded from both the default schema directory and the directory of your specified target schema.
- **Transitive resolution**: `$ref` chains are resolved recursively across files/schemas (including nested `allOf` usage in partials).
- **Unique IDs**: To encourage clean namespacing, local partial schemas **must** have unique `$id`s that do not collide with the default schemas. If a collision is detected, validation will fail with an error.

Schema resolution order: CLI `--schema` > space config `schema` > global config `schema` > bundled `schemas/general.json`
Expand Down
4 changes: 2 additions & 2 deletions docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ The validator checks every node type and its parent type(s) against the hierarch
Schemas are designed to be composable. You can create custom schemas by:

1. Creating a new `.json` file in the `schemas/` directory
2. Using `$ref` to reference shared definitions from `_shared.json` or other schemas
2. Using `$ref` to reference shared definitions from `_shared.json` or other schemas. This works transitively, including nested `allOf` compositions.
3. Defining your own node types and constraints

Referencing another schema file merges its `$defs` into the compiled schema, including any `_metadata` block. If multiple referenced files each define `_metadata`, only the last one merged is used — `rules` arrays are not combined across sources.
Expand Down Expand Up @@ -199,4 +199,4 @@ Schema files support JSON5 format, allowing inline documentation via `//` commen

- [Teresa Torres' work on Opportunity Solution Trees](https://producttalk.org/2021/02/using-opportunity-solution-trees/)
- "Continuous Discovery Habits" (2021) by Teresa Torres
- [JSON Schema specification](https://json-schema.org/)
- [JSON Schema specification](https://json-schema.org/)
3 changes: 2 additions & 1 deletion src/commands/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { dirname, join } from 'node:path';
import type { AnySchemaObject, SchemaObject } from 'ajv';
import JSON5 from 'json5';
import { loadConfig, resolveSchema } from '../config';
import { buildFullRegistry, bundledSchemasDir, loadMetadata, mergeVariantProperties, readRawSchema } from '../schema';
import { buildFullRegistry, bundledSchemasDir, loadMetadata, readRawSchema } from '../schema';
import { mergeVariantProperties } from '../schema-refs';
import type { SchemaMetadata } from '../types';

function isBundledPath(schemaPath: string): boolean {
Expand Down
3 changes: 2 additions & 1 deletion src/commands/template-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { glob } from 'glob';
import matter from 'gray-matter';
import yaml from 'js-yaml';
import { invertFieldMap } from '../config';
import { buildFullRegistry, mergeVariantProperties, readRawSchema, resolveRef } from '../schema';
import { buildFullRegistry, readRawSchema } from '../schema';
import { mergeVariantProperties, resolveRef } from '../schema-refs';

interface TypeVariant {
required: string[];
Expand Down
227 changes: 227 additions & 0 deletions src/schema-refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import type { AnySchemaObject } from 'ajv';

interface ResolvedSchema {
schema: AnySchemaObject;
rootSchema: AnySchemaObject;
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}

function decodeJsonPointerToken(token: string): string {
return token.replace(/~1/g, '/').replace(/~0/g, '~');
}

function resolveJsonPointer(root: AnySchemaObject, pointer: string, fullRef: string): AnySchemaObject {
if (pointer === '') return root;
if (!pointer.startsWith('/')) {
throw new Error(`Unsupported $ref pointer "${fullRef}". Expected a JSON pointer (e.g. "#/$defs/name").`);
}

let current: unknown = root;
for (const rawToken of pointer.slice(1).split('/')) {
const token = decodeJsonPointerToken(rawToken);

if (Array.isArray(current)) {
const index = Number.parseInt(token, 10);
if (Number.isNaN(index) || index < 0 || index >= current.length) {
throw new Error(`Cannot resolve $ref "${fullRef}": array index "${token}" is out of bounds.`);
}
current = current[index];
continue;
}

if (!isObject(current) || !(token in current)) {
throw new Error(`Cannot resolve $ref "${fullRef}": token "${token}" does not exist.`);
}

current = current[token];
}

if (!isObject(current)) {
throw new Error(`Cannot resolve $ref "${fullRef}": target is not an object schema.`);
}

return current as AnySchemaObject;
}

function mergeSchemaObjects(base: AnySchemaObject, overlay: AnySchemaObject): AnySchemaObject {
const merged = { ...base, ...overlay } as Record<string, unknown>;

const baseProps = isObject(base.properties) ? (base.properties as Record<string, AnySchemaObject>) : undefined;
const overlayProps = isObject(overlay.properties)
? (overlay.properties as Record<string, AnySchemaObject>)
: undefined;
if (baseProps || overlayProps) {
merged.properties = {
...(baseProps ?? {}),
...(overlayProps ?? {}),
};
}

const baseRequired = asArray<string>(base.required);
const overlayRequired = asArray<string>(overlay.required);
if (baseRequired.length > 0 || overlayRequired.length > 0) {
merged.required = [...new Set([...baseRequired, ...overlayRequired])];
}

const baseAllOf = asArray<AnySchemaObject>(base.allOf);
const overlayAllOf = asArray<AnySchemaObject>(overlay.allOf);
if (baseAllOf.length > 0 || overlayAllOf.length > 0) {
merged.allOf = [...baseAllOf, ...overlayAllOf];
}

return merged as AnySchemaObject;
}

function resolveRefTarget(
ref: string,
currentRootSchema: AnySchemaObject,
registry: Map<string, AnySchemaObject>,
): { schema: AnySchemaObject; rootSchema: AnySchemaObject; refKey: string } {
if (ref.startsWith('#')) {
const pointer = ref.slice(1);
const rootId = typeof currentRootSchema.$id === 'string' ? currentRootSchema.$id : '(root)';
return {
schema: resolveJsonPointer(currentRootSchema, pointer, ref),
rootSchema: currentRootSchema,
refKey: `${rootId}#${pointer}`,
};
}

const hashIndex = ref.indexOf('#');
const baseId = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref;
const pointer = hashIndex >= 0 ? ref.slice(hashIndex + 1) : '';

const externalSchema = registry.get(baseId);
if (!externalSchema) {
throw new Error(`Cannot resolve external $ref: ${ref}`);
}

return {
schema: resolveJsonPointer(externalSchema, pointer, ref),
rootSchema: externalSchema,
refKey: `${baseId}#${pointer}`,
};
}

function resolveRefWithContext(
def: AnySchemaObject | undefined,
rootSchema: AnySchemaObject,
registry: Map<string, AnySchemaObject>,
stack: Set<string>,
): ResolvedSchema | undefined {
if (!def) return undefined;
const ref = typeof def.$ref === 'string' ? def.$ref : undefined;
if (!ref) {
return { schema: def, rootSchema };
}

const target = resolveRefTarget(ref, rootSchema, registry);
if (stack.has(target.refKey)) {
throw new Error(`Cyclic $ref detected: ${[...stack, target.refKey].join(' -> ')}`);
}

stack.add(target.refKey);
const resolvedTarget = resolveRefWithContext(target.schema, target.rootSchema, registry, stack);
stack.delete(target.refKey);

if (!resolvedTarget) return undefined;

const overlay: Record<string, unknown> = {};
let hasOverlay = false;
for (const [k, v] of Object.entries(def)) {
if (k !== '$ref') {
overlay[k] = v;
hasOverlay = true;
}
}

if (!hasOverlay) {
return resolvedTarget;
}

return {
schema: mergeSchemaObjects(resolvedTarget.schema, overlay as AnySchemaObject),
rootSchema: resolvedTarget.rootSchema,
};
}

function flattenAllOf(
def: AnySchemaObject | undefined,
rootSchema: AnySchemaObject,
registry: Map<string, AnySchemaObject>,
stack: Set<string>,
visited = new Set<string>(),
): ResolvedSchema[] {
const resolved = resolveRefWithContext(def, rootSchema, registry, stack);
if (!resolved) return [];

const schemaId = typeof resolved.schema.$id === 'string' ? resolved.schema.$id : undefined;
if (schemaId && visited.has(schemaId)) {
// Cycle detected via allOf: this schema is already being processed
return [];
}
if (schemaId) visited.add(schemaId);

const parts: ResolvedSchema[] = [];
const allOf = asArray<AnySchemaObject>(resolved.schema.allOf);
for (const sub of allOf) {
parts.push(...flattenAllOf(sub, resolved.rootSchema, registry, stack, visited));
}

if (schemaId) visited.delete(schemaId);

const own = { ...resolved.schema } as Record<string, unknown>;
delete own.allOf;
parts.push({ schema: own as AnySchemaObject, rootSchema: resolved.rootSchema });
return parts;
}
Comment on lines +155 to +184
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: flattenAllOf has no cycle detection for mutually recursive allOf schemas — infinite loop / stack overflow

The stack passed here is designed to detect cycles in consecutive $ref chains (e.g. A $ref→ B $ref→ A), but it cannot detect cycles that cross allOf boundaries. The reason is that resolveRefWithContext calls stack.delete(target.refKey) before returning, so by the time flattenAllOf iterates over the resolved schema's allOf sub-entries (line 166–168), the stack is empty again and holds no memory of what schemas are currently being flattened.

Concrete example that causes infinite recursion:

// Schema A: { "$id": "A", "allOf": [{ "$ref": "B" }] }
// Schema B: { "$id": "B", "allOf": [{ "$ref": "A" }] }

Trace:

  1. flattenAllOf(A)resolveRefWithContext(A) returns immediately (no $ref). Stack: {}
  2. Iterates A's allOf: [{$ref:"B"}] → calls flattenAllOf({$ref:"B"})
  3. resolveRefWithContext({$ref:"B"}) → stack.add("B#"), resolves B (no $ref), stack.delete("B#"). Stack: {}
  4. Iterates B's allOf: [{$ref:"A"}] → calls flattenAllOf({$ref:"A"})
  5. resolveRefWithContext({$ref:"A"}) → stack.add("A#"), resolves A, stack.delete("A#"). Stack: {}
  6. Iterates A's allOf: [{$ref:"B"}] → back to step 2 → infinite recursion

Suggested fix: Give flattenAllOf its own visited set that tracks which resolved schema IDs are currently being processed, separate from the $ref-chain stack. Before recursing into a resolved schema's allOf entries, check if that schema's ID is already in the visited set and skip it if so:

function flattenAllOf(
  def: AnySchemaObject | undefined,
  rootSchema: AnySchemaObject,
  registry: Map<string, AnySchemaObject>,
  stack: Set<string>,
  visited = new Set<string>(),   // ← new parameter
): ResolvedSchema[] {
  const resolved = resolveRefWithContext(def, rootSchema, registry, stack);
  if (!resolved) return [];

  const schemaId = typeof resolved.schema.$id === 'string' ? resolved.schema.$id : undefined;
  if (schemaId && visited.has(schemaId)) return [];  // ← cycle guard
  if (schemaId) visited.add(schemaId);               // ← mark as in-progress

  const parts: ResolvedSchema[] = [];
  const allOf = asArray<AnySchemaObject>(resolved.schema.allOf);
  for (const sub of allOf) {
    parts.push(...flattenAllOf(sub, resolved.rootSchema, registry, stack, visited));
  }
  // ...
}

Note that mergeVariantProperties (line 198) and callers would also need to pass visited through, or the default parameter handles it automatically for the top-level call.


/**
* Resolve a schema definition, following cross-file and internal refs transitively.
*/
export function resolveRef(
propDef: AnySchemaObject | undefined,
schema: AnySchemaObject,
registry: Map<string, AnySchemaObject>,
): AnySchemaObject | undefined {
return resolveRefWithContext(propDef, schema, registry, new Set())?.schema;
}

/**
* Merge properties and required fields from allOf entries recursively across refs.
* allOf entries are flattened depth-first; direct properties on later fragments override earlier ones.
*/
export function mergeVariantProperties(
variant: AnySchemaObject,
schema: AnySchemaObject,
registry: Map<string, AnySchemaObject>,
): { properties: Record<string, AnySchemaObject>; required: string[] } {
const properties: Record<string, AnySchemaObject> = {};
const requiredSet = new Set<string>();
const fragments = flattenAllOf(variant, schema, registry, new Set());

for (const fragment of fragments) {
const fragmentProps = isObject(fragment.schema.properties)
? (fragment.schema.properties as Record<string, AnySchemaObject>)
: undefined;

if (fragmentProps) {
for (const [key, value] of Object.entries(fragmentProps)) {
properties[key] = resolveRef(value as AnySchemaObject, fragment.rootSchema, registry) ?? value;
}
}

for (const req of asArray<string>(fragment.schema.required)) {
requiredSet.add(req);
}
}

return { properties, required: [...requiredSet] };
}
62 changes: 0 additions & 62 deletions src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { AnySchemaObject, SchemaObject } from 'ajv';
import Ajv, { type ValidateFunction } from 'ajv';
import JSON5 from 'json5';
import type { HierarchyLevel, RulesMetadata, SchemaMetadata } from './types';
Expand Down Expand Up @@ -89,67 +88,6 @@ export function createValidator(schemaPath: string): ValidateFunction {
return ajv.compile(targetSchema);
}

/**
* Resolve a $ref within a schema, handling both external refs (ost-tools://...) and internal refs (#/$defs/...).
* Used by template-sync for traversing schema structures.
*/
export function resolveRef(
propDef: AnySchemaObject | undefined,
schema: SchemaObject,
registry: Map<string, AnySchemaObject>,
): AnySchemaObject | undefined {
if (propDef?.$ref) {
const ref = propDef.$ref as string;

// Handle external refs (e.g., "ost-tools://_shared#/$defs/baseNodeProps")
if (!ref.startsWith('#/')) {
const [baseId, hashPath] = ref.split('#');
const externalSchema = registry.get(baseId ?? '');
if (!externalSchema) {
throw new Error(`Cannot resolve external $ref: ${ref}`);
}

// Resolve the hash path in the external schema
if (hashPath) {
const path = hashPath.replace(/^\//, '').split('/');
// biome-ignore lint/suspicious/noExplicitAny: JSON schema traversal
return path.reduce((obj: any, key: string) => obj[key], externalSchema);
}
return externalSchema;
}

// Handle internal refs (e.g., "#/$defs/baseNodeProps")
const path = ref.replace(/^#\//, '').split('/');
// biome-ignore lint/suspicious/noExplicitAny: JSON schema traversal
return path.reduce((obj: any, key: string) => obj[key], schema);
}
return propDef;
}

/**
* Merge properties and required fields from allOf refs and direct variant properties.
* allOf entries are resolved via resolveRef; direct properties take precedence.
*/
export function mergeVariantProperties(
variant: AnySchemaObject,
schema: SchemaObject,
registry: Map<string, AnySchemaObject>,
): { properties: Record<string, AnySchemaObject>; required: string[] } {
const properties: Record<string, AnySchemaObject> = {};
const required: string[] = [];

for (const sub of (variant.allOf as AnySchemaObject[] | undefined) ?? []) {
const resolved = resolveRef(sub, schema, registry);
Object.assign(properties, resolved?.properties ?? {});
required.push(...((resolved?.required ?? []) as string[]));
}

Object.assign(properties, variant.properties ?? {});
required.push(...((variant.required ?? []) as string[]));

return { properties, required: [...new Set(required)] };
}

export function resolveNodeType(type: string, typeAliases: Record<string, string> | undefined): string {
return typeAliases?.[type] ?? type;
}
Expand Down
18 changes: 18 additions & 0 deletions tests/fixtures/schema-refs/_leaf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "test://leaf",
"$defs": {
"score": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"leafProps": {
"type": "object",
"properties": {
"score": { "$ref": "#/$defs/score" }
},
"required": ["score"]
}
}
}
19 changes: 19 additions & 0 deletions tests/fixtures/schema-refs/_mid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "test://mid",
"$defs": {
"mood": {
"type": "string",
"enum": ["happy", "sad"]
},
"midProps": {
"type": "object",
"allOf": [{ "$ref": "test://leaf#/$defs/leafProps" }],
"properties": {
"mood": { "$ref": "#/$defs/mood" },
"status": { "$ref": "ost-tools://_ost_tools_base#/$defs/status" }
},
"required": ["mood", "status"]
}
}
}
Loading
Loading