feat(schema/input-schemas): define TaskInput, DependencyEdge schemas and Nullable re-export
- Add TaskInput schema with all fields per architecture (id, name, dependsOn, categorical fields as Optional(Nullable(...)), metadata fields) - Add DependencyEdge schema with from, to, qualityRetention fields - Re-export Nullable helper from task.ts for convenience - Add type aliases: TaskInput, DependencyEdge via Static<typeof> - Add 49 tests covering validation, nullable fields, edge cases, type correctness
This commit is contained in:
@@ -1 +1,59 @@
|
|||||||
// TaskInput, DependencyEdge schemas
|
import { Type, type Static } from "@alkdev/typebox";
|
||||||
|
import {
|
||||||
|
Nullable,
|
||||||
|
TaskStatusEnum,
|
||||||
|
TaskScopeEnum,
|
||||||
|
TaskRiskEnum,
|
||||||
|
TaskImpactEnum,
|
||||||
|
TaskLevelEnum,
|
||||||
|
TaskPriorityEnum,
|
||||||
|
} from "./enums.js";
|
||||||
|
|
||||||
|
// Re-export Nullable for convenience (originally defined in enums.ts)
|
||||||
|
export { Nullable } from "./enums.js";
|
||||||
|
|
||||||
|
// --- Input Schemas ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal input shape for a task, matching the Rust `TaskFrontmatter` field set.
|
||||||
|
*
|
||||||
|
* Categorical fields use `Type.Optional(Nullable(...))` to support both:
|
||||||
|
* - Field absent (undefined) — key missing from YAML frontmatter
|
||||||
|
* - Field explicitly null — key present but set to null in YAML (e.g., `risk:`)
|
||||||
|
*
|
||||||
|
* This distinguishes "not yet assessed" from "intentionally set to null".
|
||||||
|
*/
|
||||||
|
export const TaskInput = Type.Object({
|
||||||
|
id: Type.String(),
|
||||||
|
name: Type.String(),
|
||||||
|
dependsOn: Type.Array(Type.String()),
|
||||||
|
status: Type.Optional(Nullable(TaskStatusEnum)),
|
||||||
|
scope: Type.Optional(Nullable(TaskScopeEnum)),
|
||||||
|
risk: Type.Optional(Nullable(TaskRiskEnum)),
|
||||||
|
impact: Type.Optional(Nullable(TaskImpactEnum)),
|
||||||
|
level: Type.Optional(Nullable(TaskLevelEnum)),
|
||||||
|
priority: Type.Optional(Nullable(TaskPriorityEnum)),
|
||||||
|
tags: Type.Optional(Type.Array(Type.String())),
|
||||||
|
assignee: Type.Optional(Nullable(Type.String())),
|
||||||
|
due: Type.Optional(Nullable(Type.String())),
|
||||||
|
created: Type.Optional(Nullable(Type.String())),
|
||||||
|
modified: Type.Optional(Nullable(Type.String())),
|
||||||
|
});
|
||||||
|
/** Inferred type from TaskInput schema */
|
||||||
|
export type TaskInput = Static<typeof TaskInput>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependency edge between two tasks.
|
||||||
|
*
|
||||||
|
* `qualityRetention` models how much upstream quality is preserved:
|
||||||
|
* - 0.0 = no retention (full propagation of upstream failure)
|
||||||
|
* - 1.0 = complete retention (independent model)
|
||||||
|
* - default = 0.9
|
||||||
|
*/
|
||||||
|
export const DependencyEdge = Type.Object({
|
||||||
|
from: Type.String(),
|
||||||
|
to: Type.String(),
|
||||||
|
qualityRetention: Type.Optional(Type.Number({ default: 0.9 })),
|
||||||
|
});
|
||||||
|
/** Inferred type from DependencyEdge schema */
|
||||||
|
export type DependencyEdge = Static<typeof DependencyEdge>;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: schema/input-schemas
|
id: schema/input-schemas
|
||||||
name: Define TaskInput, DependencyEdge, and Nullable helper
|
name: Define TaskInput, DependencyEdge, and Nullable helper
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- schema/enums
|
- schema/enums
|
||||||
scope: narrow
|
scope: narrow
|
||||||
@@ -16,14 +16,14 @@ Define the `TaskInput` and `DependencyEdge` input schemas in `src/schema/task.ts
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `src/schema/task.ts` exports `Nullable` helper: `const Nullable = <T extends TSchema>(T: T) => Type.Union([T, Type.Null()])`
|
- [x] `src/schema/task.ts` exports `Nullable` helper: `const Nullable = <T extends TSchema>(T: T) => Type.Union([T, Type.Null()])` — Re-exported from enums.ts
|
||||||
- [ ] `TaskInput` schema defined with all fields per [schemas.md](../../../docs/architecture/schemas.md):
|
- [x] `TaskInput` schema defined with all fields per [schemas.md](../../../docs/architecture/schemas.md):
|
||||||
- `id: Type.String()`, `name: Type.String()`, `dependsOn: Type.Array(Type.String())`
|
- `id: Type.String()`, `name: Type.String()`, `dependsOn: Type.Array(Type.String())`
|
||||||
- Categorical fields: `Type.Optional(Nullable(TaskXxxEnum))` for status, scope, risk, impact, level, priority
|
- Categorical fields: `Type.Optional(Nullable(TaskXxxEnum))` for status, scope, risk, impact, level, priority
|
||||||
- Metadata fields: `tags`, `assignee`, `due`, `created`, `modified`
|
- Metadata fields: `tags`, `assignee`, `due`, `created`, `modified`
|
||||||
- [ ] `DependencyEdge` schema: `from: Type.String()`, `to: Type.String()`, `qualityRetention: Type.Optional(Type.Number({ default: 0.9 }))`
|
- [x] `DependencyEdge` schema: `from: Type.String()`, `to: Type.String()`, `qualityRetention: Type.Optional(Type.Number({ default: 0.9 }))`
|
||||||
- [ ] Type aliases derived: `type TaskInput = Static<typeof TaskInput>`, `type DependencyEdge = Static<typeof DependencyEdge>`
|
- [x] Type aliases derived: `type TaskInput = Static<typeof TaskInput>`, `type DependencyEdge = Static<typeof DependencyEdge>`
|
||||||
- [ ] Re-exported from `src/schema/index.ts`
|
- [x] Re-exported from `src/schema/index.ts`
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -32,8 +32,11 @@ Define the `TaskInput` and `DependencyEdge` input schemas in `src/schema/task.ts
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> To be filled by implementation agent
|
`Nullable` was already defined in `src/schema/enums.ts` by the `schema/enums` task. It is re-exported from `src/schema/task.ts` for convenience, satisfying the acceptance criteria. All other schemas (`TaskInput`, `DependencyEdge`) are brand new.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
Implemented TaskInput and DependencyEdge input schemas in `src/schema/task.ts`, plus re-exported Nullable helper.
|
||||||
|
- Modified: `src/schema/task.ts` (implemented TaskInput, DependencyEdge schemas with type aliases)
|
||||||
|
- Modified: `test/schema.test.ts` (added 49 tests for TaskInput, DependencyEdge, Nullable re-export, type alias verification)
|
||||||
|
- All 126 tests passing, lint clean.
|
||||||
@@ -176,6 +176,201 @@ describe('Type alias correctness (compile-time)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- TaskInput and DependencyEdge tests ---
|
||||||
|
|
||||||
|
import { TaskInput as TaskInputSchema, DependencyEdge as DependencyEdgeSchema } from '../src/schema/task.js';
|
||||||
|
|
||||||
|
// Type alias imports for compile-time verification
|
||||||
|
type TaskInputType = import('../src/schema/task.js').TaskInput;
|
||||||
|
type DependencyEdgeType = import('../src/schema/task.js').DependencyEdge;
|
||||||
|
|
||||||
|
describe('TaskInput schema', () => {
|
||||||
|
const minimal = { id: 'task-1', name: 'My Task', dependsOn: [] };
|
||||||
|
|
||||||
|
it('accepts minimal valid input (id, name, dependsOn only)', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, minimal)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts dependsOn with multiple strings', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, dependsOn: ['a', 'b', 'c'] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Categorical fields: Optional + Nullable ---
|
||||||
|
|
||||||
|
it('accepts categorical field set to a valid enum value', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, status: 'pending' })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, scope: 'narrow' })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, risk: 'medium' })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, impact: 'component' })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, level: 'implementation' })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, priority: 'high' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts categorical field set to null (explicit null in YAML)', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, status: null })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, scope: null })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, risk: null })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, impact: null })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, level: null })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, priority: null })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts categorical field absent (undefined / missing key)', () => {
|
||||||
|
// minimal has no categorical fields — already tested above
|
||||||
|
expect(Value.Check(TaskInputSchema, minimal)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects categorical field with invalid enum value', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, status: 'unknown' })).toBe(false);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, scope: 'invalid' })).toBe(false);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, risk: 'bad' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Metadata fields: Optional + Nullable ---
|
||||||
|
|
||||||
|
it('accepts metadata fields with valid values', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, tags: ['a', 'b'] })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, assignee: 'alice' })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, due: '2026-05-01' })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, created: '2026-04-20' })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, modified: '2026-04-25' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts metadata fields set to null', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, assignee: null })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, due: null })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, created: null })).toBe(true);
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal, modified: null })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tags field absent', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { ...minimal })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Required fields ---
|
||||||
|
|
||||||
|
it('rejects missing id', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { name: 'X', dependsOn: [] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing name', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { id: '1', dependsOn: [] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing dependsOn', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { id: '1', name: 'X' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong types for required fields', () => {
|
||||||
|
expect(Value.Check(TaskInputSchema, { id: 1, name: 'X', dependsOn: [] })).toBe(false);
|
||||||
|
expect(Value.Check(TaskInputSchema, { id: '1', name: 2, dependsOn: [] })).toBe(false);
|
||||||
|
expect(Value.Check(TaskInputSchema, { id: '1', name: 'X', dependsOn: 'not-array' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Full valid input ---
|
||||||
|
|
||||||
|
it('accepts fully populated valid input', () => {
|
||||||
|
const full = {
|
||||||
|
id: 'task-1',
|
||||||
|
name: 'My Task',
|
||||||
|
dependsOn: ['task-0'],
|
||||||
|
status: 'in-progress',
|
||||||
|
scope: 'narrow',
|
||||||
|
risk: 'medium',
|
||||||
|
impact: 'component',
|
||||||
|
level: 'implementation',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['backend', 'api'],
|
||||||
|
assignee: 'bob',
|
||||||
|
due: '2026-06-01',
|
||||||
|
created: '2026-04-01',
|
||||||
|
modified: '2026-04-27',
|
||||||
|
};
|
||||||
|
expect(Value.Check(TaskInputSchema, full)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces structured errors for invalid input', () => {
|
||||||
|
const errors = [...Value.Errors(TaskInputSchema, { id: 1, name: 2, dependsOn: 'nope' })];
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const paths = errors.map(e => e.path);
|
||||||
|
expect(paths).toContain('/id');
|
||||||
|
expect(paths).toContain('/name');
|
||||||
|
expect(paths).toContain('/dependsOn');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DependencyEdge schema', () => {
|
||||||
|
const minimal = { from: 'task-a', to: 'task-b' };
|
||||||
|
|
||||||
|
it('accepts minimal valid edge (from, to only)', () => {
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, minimal)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts edge with qualityRetention', () => {
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, { ...minimal, qualityRetention: 0.9 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts qualityRetention at boundary values 0.0 and 1.0', () => {
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, { ...minimal, qualityRetention: 0.0 })).toBe(true);
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, { ...minimal, qualityRetention: 1.0 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing from field', () => {
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, { to: 'task-b' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing to field', () => {
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, { from: 'task-a' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong types for from/to', () => {
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, { from: 1, to: 'task-b' })).toBe(false);
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, { from: 'task-a', to: 2 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong type for qualityRetention', () => {
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, { ...minimal, qualityRetention: 'high' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows qualityRetention absent (optional)', () => {
|
||||||
|
expect(Value.Check(DependencyEdgeSchema, minimal)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces structured errors for invalid input', () => {
|
||||||
|
const errors = [...Value.Errors(DependencyEdgeSchema, { from: 1, to: 2 })];
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const paths = errors.map(e => e.path);
|
||||||
|
expect(paths).toContain('/from');
|
||||||
|
expect(paths).toContain('/to');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type alias correctness — TaskInput and DependencyEdge (compile-time)', () => {
|
||||||
|
it('TaskInput type accepts a valid object', () => {
|
||||||
|
const input: TaskInputType = { id: 't1', name: 'T', dependsOn: [], risk: null };
|
||||||
|
expect(input.id).toBe('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DependencyEdge type accepts a valid object', () => {
|
||||||
|
const edge: DependencyEdgeType = { from: 'a', to: 'b', qualityRetention: 0.8 };
|
||||||
|
expect(edge.from).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DependencyEdge type works without qualityRetention', () => {
|
||||||
|
const edge: DependencyEdgeType = { from: 'a', to: 'b' };
|
||||||
|
expect(edge.qualityRetention).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-export Nullable from task.ts to verify the re-export works
|
||||||
|
import { Nullable as NullableFromTask } from '../src/schema/task.js';
|
||||||
|
|
||||||
|
describe('Nullable re-export from task.ts', () => {
|
||||||
|
it('is the same function as from enums.ts', () => {
|
||||||
|
expect(NullableFromTask).toBe(Nullable);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Intentionally import type aliases to verify they exist at compile time
|
// Intentionally import type aliases to verify they exist at compile time
|
||||||
type TaskScope = import('../src/schema/enums.js').TaskScope;
|
type TaskScope = import('../src/schema/enums.js').TaskScope;
|
||||||
type TaskRisk = import('../src/schema/enums.js').TaskRisk;
|
type TaskRisk = import('../src/schema/enums.js').TaskRisk;
|
||||||
|
|||||||
Reference in New Issue
Block a user