Merge schema/graph-schemas: node/edge attribute schemas, SerializedGraph generic factory
# Conflicts: # test/schema.test.ts
This commit is contained in:
@@ -1 +1,89 @@
|
|||||||
// TaskGraphNodeAttributes, TaskGraphEdgeAttributes, SerializedGraph schemas
|
import { Type, type Static, type TSchema } from "@alkdev/typebox";
|
||||||
|
import {
|
||||||
|
TaskScopeEnum,
|
||||||
|
TaskRiskEnum,
|
||||||
|
TaskImpactEnum,
|
||||||
|
TaskLevelEnum,
|
||||||
|
TaskPriorityEnum,
|
||||||
|
TaskStatusEnum,
|
||||||
|
} from "./enums.js";
|
||||||
|
|
||||||
|
// --- TaskGraphNodeAttributes ---
|
||||||
|
|
||||||
|
/** Node attributes stored on the graphology graph. Carries only analysis-relevant metadata. */
|
||||||
|
export const TaskGraphNodeAttributes = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
scope: Type.Optional(TaskScopeEnum),
|
||||||
|
risk: Type.Optional(TaskRiskEnum),
|
||||||
|
impact: Type.Optional(TaskImpactEnum),
|
||||||
|
level: Type.Optional(TaskLevelEnum),
|
||||||
|
priority: Type.Optional(TaskPriorityEnum),
|
||||||
|
status: Type.Optional(TaskStatusEnum),
|
||||||
|
});
|
||||||
|
/** Inferred type for {@link TaskGraphNodeAttributes} schema. */
|
||||||
|
export type TaskGraphNodeAttributes = Static<typeof TaskGraphNodeAttributes>;
|
||||||
|
|
||||||
|
// --- TaskGraphNodeAttributesUpdate ---
|
||||||
|
|
||||||
|
/** All fields optional for partial-update operations. */
|
||||||
|
export const TaskGraphNodeAttributesUpdate = Type.Partial(TaskGraphNodeAttributes);
|
||||||
|
/** Inferred type for {@link TaskGraphNodeAttributesUpdate} schema. */
|
||||||
|
export type TaskGraphNodeAttributesUpdate = Static<typeof TaskGraphNodeAttributesUpdate>;
|
||||||
|
|
||||||
|
// --- TaskGraphEdgeAttributes ---
|
||||||
|
|
||||||
|
/** Edge attributes stored on the graphology graph. */
|
||||||
|
export const TaskGraphEdgeAttributes = Type.Object({
|
||||||
|
qualityRetention: Type.Optional(Type.Number()),
|
||||||
|
});
|
||||||
|
/** Inferred type for {@link TaskGraphEdgeAttributes} schema. */
|
||||||
|
export type TaskGraphEdgeAttributes = Static<typeof TaskGraphEdgeAttributes>;
|
||||||
|
|
||||||
|
// --- SerializedGraph generic factory ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic schema factory for the graphology native JSON format.
|
||||||
|
* Parameterized with node attribute, edge attribute, and graph attribute schemas.
|
||||||
|
*
|
||||||
|
* @param NodeAttrs - Schema for node attributes
|
||||||
|
* @param EdgeAttrs - Schema for edge attributes
|
||||||
|
* @param GraphAttrs - Schema for graph-level attributes
|
||||||
|
*/
|
||||||
|
export const SerializedGraph = <N extends TSchema, E extends TSchema, G extends TSchema>(
|
||||||
|
NodeAttrs: N,
|
||||||
|
EdgeAttrs: E,
|
||||||
|
GraphAttrs: G,
|
||||||
|
) =>
|
||||||
|
Type.Object({
|
||||||
|
attributes: GraphAttrs,
|
||||||
|
options: Type.Object({
|
||||||
|
type: Type.Literal("directed"),
|
||||||
|
multi: Type.Literal(false),
|
||||||
|
allowSelfLoops: Type.Literal(false),
|
||||||
|
}),
|
||||||
|
nodes: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
key: Type.String(),
|
||||||
|
attributes: NodeAttrs,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
edges: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
key: Type.String(),
|
||||||
|
source: Type.String(),
|
||||||
|
target: Type.String(),
|
||||||
|
attributes: EdgeAttrs,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- TaskGraphSerialized ---
|
||||||
|
|
||||||
|
/** Serialized task graph following graphology native JSON format. */
|
||||||
|
export const TaskGraphSerialized = SerializedGraph(
|
||||||
|
TaskGraphNodeAttributes,
|
||||||
|
TaskGraphEdgeAttributes,
|
||||||
|
Type.Object({}),
|
||||||
|
);
|
||||||
|
/** Inferred type for {@link TaskGraphSerialized} schema. */
|
||||||
|
export type TaskGraphSerialized = Static<typeof TaskGraphSerialized>;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: schema/graph-schemas
|
id: schema/graph-schemas
|
||||||
name: Define TaskGraphNodeAttributes, TaskGraphEdgeAttributes, and SerializedGraph
|
name: Define TaskGraphNodeAttributes, TaskGraphEdgeAttributes, and SerializedGraph
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- schema/enums
|
- schema/enums
|
||||||
scope: narrow
|
scope: narrow
|
||||||
@@ -16,17 +16,17 @@ Define graph attribute schemas and the serialized graph generic in `src/schema/g
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `src/schema/graph.ts` exports:
|
- [x] `src/schema/graph.ts` exports:
|
||||||
- `TaskGraphNodeAttributes` schema: `name: Type.String()`, optional categorical enums (scope, risk, impact, level, priority, status) — **not** nullable on the graph (absent = not stored)
|
- [x] `TaskGraphNodeAttributes` schema: `name: Type.String()`, optional categorical enums (scope, risk, impact, level, priority, status) — **not** nullable on the graph (absent = not stored)
|
||||||
- `type TaskGraphNodeAttributes` derived
|
- [x] `type TaskGraphNodeAttributes` derived
|
||||||
- `TaskGraphNodeAttributesUpdate = Type.Partial(TaskGraphNodeAttributes)` and type alias
|
- [x] `TaskGraphNodeAttributesUpdate = Type.Partial(TaskGraphNodeAttributes)` and type alias
|
||||||
- `TaskGraphEdgeAttributes` schema: `qualityRetention: Type.Optional(Type.Number())`
|
- [x] `TaskGraphEdgeAttributes` schema: `qualityRetention: Type.Optional(Type.Number())`
|
||||||
- `type TaskGraphEdgeAttributes` derived
|
- [x] `type TaskGraphEdgeAttributes` derived
|
||||||
- `SerializedGraph` generic factory parameterized with `<N extends TSchema, E extends TSchema, G extends TSchema>`
|
- [x] `SerializedGraph` generic factory parameterized with `<N extends TSchema, E extends TSchema, G extends TSchema>`
|
||||||
- `TaskGraphSerialized = SerializedGraph(TaskGraphNodeAttributes, TaskGraphEdgeAttributes, Type.Object({}))` and type alias
|
- [x] `TaskGraphSerialized = SerializedGraph(TaskGraphNodeAttributes, TaskGraphEdgeAttributes, Type.Object({}))` and type alias
|
||||||
- [ ] `SerializedGraph` generic follows graphology JSON format: `attributes`, `options: { type: "directed", multi: false, allowSelfLoops: false }`, `nodes: [{ key, attributes }]`, `edges: [{ key, source, target, attributes }]`
|
- [x] `SerializedGraph` generic follows graphology JSON format: `attributes`, `options: { type: "directed", multi: false, allowSelfLoops: false }`, `nodes: [{ key, attributes }]`, `edges: [{ key, source, target, attributes }]`
|
||||||
- [ ] No schema version field on `TaskGraphSerialized` per spec
|
- [x] No schema version field on `TaskGraphSerialized` per spec
|
||||||
- [ ] Re-exported from `src/schema/index.ts`
|
- [x] Re-exported from `src/schema/index.ts`
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -35,8 +35,11 @@ Define graph attribute schemas and the serialized graph generic in `src/schema/g
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> To be filled by implementation agent
|
Follows the architecture spec in docs/architecture/schemas.md and the generic schema factory pattern from docs/research/typebox-patterns.md section 6. The `SerializedGraph` generic factory uses the recommended graphology JSON format with `Type.Literal("directed")`, `Type.Literal(false)`, and `Type.Literal(false)` for the options. No `default` on `qualityRetention` in the graph schema (unlike `DependencyEdge` which has `Type.Number({ default: 0.9 })`) — the graph schema keeps it simple with `Type.Optional(Type.Number())`.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
Implemented graph attribute schemas and serialized graph generic factory per spec.
|
||||||
|
- Created: `src/schema/graph.ts` (TaskGraphNodeAttributes, TaskGraphNodeAttributesUpdate, TaskGraphEdgeAttributes, SerializedGraph generic, TaskGraphSerialized, and all type aliases)
|
||||||
|
- Modified: `test/schema.test.ts` (35 new tests for graph schemas: TaskGraphNodeAttributes, TaskGraphNodeAttributesUpdate, TaskGraphEdgeAttributes, SerializedGraph, and compile-time type verification)
|
||||||
|
- Tests: 121 total, all passing; `tsc --noEmit` clean
|
||||||
@@ -9,6 +9,13 @@ import {
|
|||||||
TaskStatusEnum,
|
TaskStatusEnum,
|
||||||
Nullable,
|
Nullable,
|
||||||
} from '../src/schema/enums.js';
|
} from '../src/schema/enums.js';
|
||||||
|
import {
|
||||||
|
TaskGraphNodeAttributes,
|
||||||
|
TaskGraphNodeAttributesUpdate,
|
||||||
|
TaskGraphEdgeAttributes,
|
||||||
|
TaskGraphSerialized,
|
||||||
|
SerializedGraph,
|
||||||
|
} from '../src/schema/graph.js';
|
||||||
|
|
||||||
describe('Enum schemas', () => {
|
describe('Enum schemas', () => {
|
||||||
// --- TaskScopeEnum ---
|
// --- TaskScopeEnum ---
|
||||||
@@ -545,8 +552,6 @@ describe('EvConfig schema', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unknown properties (additionalProperties)', () => {
|
it('rejects unknown properties (additionalProperties)', () => {
|
||||||
// TypeBox Object by default allows additional properties through Check
|
|
||||||
// This test documents behavior — strict additionalProperties would need Type.Object({...}, { additionalProperties: false })
|
|
||||||
expect(Value.Check(EvConfigSchema, { unknownField: 42 })).toBe(true);
|
expect(Value.Check(EvConfigSchema, { unknownField: 42 })).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -668,4 +673,203 @@ describe('Result type alias correctness (compile-time)', () => {
|
|||||||
};
|
};
|
||||||
expect(result.unspecified).toEqual([]);
|
expect(result.unspecified).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Graph schema tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('TaskGraphNodeAttributes', () => {
|
||||||
|
it('accepts a valid node with only required fields', () => {
|
||||||
|
const node = { name: 'my-task' };
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, node)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a node with all optional categorical enum fields', () => {
|
||||||
|
const node = {
|
||||||
|
name: 'my-task',
|
||||||
|
scope: 'narrow',
|
||||||
|
risk: 'high',
|
||||||
|
impact: 'component',
|
||||||
|
level: 'implementation',
|
||||||
|
priority: 'critical',
|
||||||
|
status: 'in-progress',
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, node)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a node missing the required name field', () => {
|
||||||
|
const node = { scope: 'narrow' };
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, node)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid categorical enum values', () => {
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, { name: 't', scope: 'invalid' })).toBe(false);
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, { name: 't', risk: 'unknown' })).toBe(false);
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, { name: 't', status: 'done' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-object values', () => {
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, null)).toBe(false);
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, 'string')).toBe(false);
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributes, 42)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT carry tags/assignee/due fields (analysis-only)', () => {
|
||||||
|
const schema = TaskGraphNodeAttributes as any;
|
||||||
|
const props = schema?.properties;
|
||||||
|
expect(props).toBeDefined();
|
||||||
|
expect(props.tags).toBeUndefined();
|
||||||
|
expect(props.assignee).toBeUndefined();
|
||||||
|
expect(props.due).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskGraphNodeAttributesUpdate', () => {
|
||||||
|
it('accepts an empty object (all fields optional)', () => {
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributesUpdate, {})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a partial object with only name', () => {
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributesUpdate, { name: 't' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a partial object with only optional categorical fields', () => {
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributesUpdate, { risk: 'low', status: 'pending' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid categorical enum values in partial', () => {
|
||||||
|
expect(Value.Check(TaskGraphNodeAttributesUpdate, { risk: 'invalid' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskGraphEdgeAttributes', () => {
|
||||||
|
it('accepts an empty object (qualityRetention is optional)', () => {
|
||||||
|
expect(Value.Check(TaskGraphEdgeAttributes, {})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid qualityRetention number', () => {
|
||||||
|
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: 0.9 })).toBe(true);
|
||||||
|
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: 0.0 })).toBe(true);
|
||||||
|
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: 1.0 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-number qualityRetention', () => {
|
||||||
|
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: 'high' })).toBe(false);
|
||||||
|
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: null })).toBe(false);
|
||||||
|
expect(Value.Check(TaskGraphEdgeAttributes, { qualityRetention: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SerializedGraph generic factory', () => {
|
||||||
|
it('produces a schema that validates graphology JSON format', () => {
|
||||||
|
const graph = {
|
||||||
|
attributes: {},
|
||||||
|
options: { type: 'directed' as const, multi: false, allowSelfLoops: false },
|
||||||
|
nodes: [
|
||||||
|
{ key: 'task-a', attributes: { name: 'Task A' } },
|
||||||
|
{ key: 'task-b', attributes: { name: 'Task B', scope: 'narrow' as const } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ key: 'edge-1', source: 'task-a', target: 'task-b', attributes: { qualityRetention: 0.9 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskGraphSerialized, graph)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts empty nodes and edges arrays', () => {
|
||||||
|
const graph = {
|
||||||
|
attributes: {},
|
||||||
|
options: { type: 'directed' as const, multi: false, allowSelfLoops: false },
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskGraphSerialized, graph)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong options.type', () => {
|
||||||
|
const graph = {
|
||||||
|
attributes: {},
|
||||||
|
options: { type: 'undirected', multi: false, allowSelfLoops: false },
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong options.multi', () => {
|
||||||
|
const graph = {
|
||||||
|
attributes: {},
|
||||||
|
options: { type: 'directed' as const, multi: true, allowSelfLoops: false },
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong options.allowSelfLoops', () => {
|
||||||
|
const graph = {
|
||||||
|
attributes: {},
|
||||||
|
options: { type: 'directed' as const, multi: false, allowSelfLoops: true },
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects node with invalid attributes', () => {
|
||||||
|
const graph = {
|
||||||
|
attributes: {},
|
||||||
|
options: { type: 'directed' as const, multi: false, allowSelfLoops: false },
|
||||||
|
nodes: [{ key: 'bad', attributes: { scope: 'invalid' } }],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects edge with invalid source/target', () => {
|
||||||
|
const graph = {
|
||||||
|
attributes: {},
|
||||||
|
options: { type: 'directed' as const, multi: false, allowSelfLoops: false },
|
||||||
|
nodes: [],
|
||||||
|
edges: [{ key: 'e1', source: 42, target: 'b', attributes: {} }],
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskGraphSerialized, graph)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no schema version field', () => {
|
||||||
|
const schema = TaskGraphSerialized as any;
|
||||||
|
const props = schema?.properties;
|
||||||
|
expect(props).toBeDefined();
|
||||||
|
expect(props.version).toBeUndefined();
|
||||||
|
expect(props.schemaVersion).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be composed with different attribute schemas (is generic)', () => {
|
||||||
|
const CustomGraph = SerializedGraph(
|
||||||
|
TaskGraphNodeAttributes,
|
||||||
|
TaskGraphEdgeAttributes,
|
||||||
|
{ type: 'object', properties: { label: { type: 'string' } } } as any,
|
||||||
|
);
|
||||||
|
expect(CustomGraph).toBeDefined();
|
||||||
|
expect((CustomGraph as any).properties).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskGraphSerialized type alias (compile-time)', () => {
|
||||||
|
it('type alias resolves correctly for a valid graph', () => {
|
||||||
|
const graph: TaskGraphSerialized = {
|
||||||
|
attributes: {},
|
||||||
|
options: { type: 'directed', multi: false, allowSelfLoops: false },
|
||||||
|
nodes: [{ key: 'a', attributes: { name: 'A' } }],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
expect(graph.options.type).toBe('directed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import graph type aliases for compile-time verification
|
||||||
|
type TaskGraphNodeAttributes = import('../src/schema/graph.js').TaskGraphNodeAttributes;
|
||||||
|
type TaskGraphNodeAttributesUpdate = import('../src/schema/graph.js').TaskGraphNodeAttributesUpdate;
|
||||||
|
type TaskGraphEdgeAttributes = import('../src/schema/graph.js').TaskGraphEdgeAttributes;
|
||||||
|
type TaskGraphSerialized = import('../src/schema/graph.js').TaskGraphSerialized;
|
||||||
|
|||||||
Reference in New Issue
Block a user