@cleocode/contracts 2026.5.103 → 2026.5.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/boundary.js +5 -5
  2. package/dist/boundary.js.map +1 -1
  3. package/dist/errors.d.ts +61 -0
  4. package/dist/errors.d.ts.map +1 -1
  5. package/dist/errors.js +46 -0
  6. package/dist/errors.js.map +1 -1
  7. package/dist/exit-codes.d.ts +13 -0
  8. package/dist/exit-codes.d.ts.map +1 -1
  9. package/dist/exit-codes.js +13 -0
  10. package/dist/exit-codes.js.map +1 -1
  11. package/dist/index.d.ts +5 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +5 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/release/ship-e2e-smoke.d.ts +97 -0
  16. package/dist/release/ship-e2e-smoke.d.ts.map +1 -0
  17. package/dist/release/ship-e2e-smoke.js +20 -0
  18. package/dist/release/ship-e2e-smoke.js.map +1 -0
  19. package/dist/render/__tests__/envelope.test.d.ts +14 -0
  20. package/dist/render/__tests__/envelope.test.d.ts.map +1 -0
  21. package/dist/render/__tests__/envelope.test.js +178 -0
  22. package/dist/render/__tests__/envelope.test.js.map +1 -0
  23. package/dist/render/envelope.d.ts +103 -0
  24. package/dist/render/envelope.d.ts.map +1 -0
  25. package/dist/render/envelope.js +53 -0
  26. package/dist/render/envelope.js.map +1 -0
  27. package/dist/render/index.d.ts +16 -0
  28. package/dist/render/index.d.ts.map +1 -0
  29. package/dist/render/index.js +12 -0
  30. package/dist/render/index.js.map +1 -0
  31. package/dist/render/list.d.ts +77 -0
  32. package/dist/render/list.d.ts.map +1 -0
  33. package/dist/render/list.js +39 -0
  34. package/dist/render/list.js.map +1 -0
  35. package/dist/render/table.d.ts +73 -0
  36. package/dist/render/table.d.ts.map +1 -0
  37. package/dist/render/table.js +32 -0
  38. package/dist/render/table.js.map +1 -0
  39. package/dist/render/tree.d.ts +94 -0
  40. package/dist/render/tree.d.ts.map +1 -0
  41. package/dist/render/tree.js +30 -0
  42. package/dist/render/tree.js.map +1 -0
  43. package/package.json +2 -2
  44. package/src/boundary.ts +5 -5
  45. package/src/errors.ts +71 -0
  46. package/src/exit-codes.ts +13 -0
  47. package/src/index.ts +15 -1
  48. package/src/release/ship-e2e-smoke.ts +113 -0
  49. package/src/render/__tests__/envelope.test.ts +194 -0
  50. package/src/render/envelope.ts +112 -0
  51. package/src/render/index.ts +40 -0
  52. package/src/render/list.ts +90 -0
  53. package/src/render/table.ts +85 -0
  54. package/src/render/tree.ts +114 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Compile-time + runtime narrowing tests for `RenderableEnvelope<T>` and the
3
+ * per-kind type guards.
4
+ *
5
+ * Each test constructs a valid envelope of a single `kind` and asserts that
6
+ * the matching guard narrows it correctly while every other guard returns
7
+ * `false`. This catches both schema drift and accidental short-circuits in
8
+ * the guards.
9
+ *
10
+ * @epic T10114
11
+ * @task T10141
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+ import {
16
+ isGenericEnvelope,
17
+ isGroupedListEnvelope,
18
+ isListEnvelope,
19
+ isSectionEnvelope,
20
+ isSingleEnvelope,
21
+ isTableEnvelope,
22
+ isTreeEnvelope,
23
+ type RenderableEnvelope,
24
+ } from '../envelope.js';
25
+ import { isGroupedListResponse, isListResponse } from '../list.js';
26
+ import { isTableResponse } from '../table.js';
27
+ import { isTreeResponse } from '../tree.js';
28
+
29
+ interface Sample {
30
+ readonly x: number;
31
+ }
32
+
33
+ describe('RenderableEnvelope<T> discriminated-union narrowing', () => {
34
+ it('narrows the tree variant and rejects all others', () => {
35
+ const env: RenderableEnvelope<Sample> = {
36
+ kind: 'tree',
37
+ data: { tree: [], root: 'T1', totalNodes: 0, maxDepth: 0 },
38
+ };
39
+ expect(isTreeEnvelope(env)).toBe(true);
40
+ expect(isTableEnvelope(env)).toBe(false);
41
+ expect(isListEnvelope(env)).toBe(false);
42
+ expect(isGroupedListEnvelope(env)).toBe(false);
43
+ expect(isSectionEnvelope(env)).toBe(false);
44
+ expect(isSingleEnvelope(env)).toBe(false);
45
+ expect(isGenericEnvelope(env)).toBe(false);
46
+ if (isTreeEnvelope(env)) {
47
+ expect(env.data.root).toBe('T1');
48
+ } else {
49
+ throw new Error('expected tree narrowing');
50
+ }
51
+ });
52
+
53
+ it('narrows the table variant and rejects all others', () => {
54
+ const env: RenderableEnvelope<Sample> = {
55
+ kind: 'table',
56
+ data: {
57
+ rows: [{ x: 1 }],
58
+ schema: { columns: [{ key: 'x', header: 'X' }] },
59
+ total: 1,
60
+ },
61
+ };
62
+ expect(isTableEnvelope(env)).toBe(true);
63
+ expect(isTreeEnvelope(env)).toBe(false);
64
+ if (isTableEnvelope(env)) {
65
+ expect(env.data.rows[0]?.x).toBe(1);
66
+ } else {
67
+ throw new Error('expected table narrowing');
68
+ }
69
+ });
70
+
71
+ it('narrows the flat list variant', () => {
72
+ const env: RenderableEnvelope<Sample> = {
73
+ kind: 'list',
74
+ data: { items: [{ x: 7 }], total: 1, renderItem: 'kv' },
75
+ };
76
+ expect(isListEnvelope(env)).toBe(true);
77
+ expect(isGroupedListEnvelope(env)).toBe(false);
78
+ if (isListEnvelope(env)) {
79
+ expect(env.data.items[0]?.x).toBe(7);
80
+ expect(env.data.renderItem).toBe('kv');
81
+ } else {
82
+ throw new Error('expected list narrowing');
83
+ }
84
+ });
85
+
86
+ it('narrows the grouped-list variant', () => {
87
+ const env: RenderableEnvelope<Sample> = {
88
+ kind: 'grouped-list',
89
+ data: {
90
+ groups: [{ key: 'a', label: 'A', items: [{ x: 2 }] }],
91
+ total: 1,
92
+ },
93
+ };
94
+ expect(isGroupedListEnvelope(env)).toBe(true);
95
+ expect(isListEnvelope(env)).toBe(false);
96
+ if (isGroupedListEnvelope(env)) {
97
+ expect(env.data.groups[0]?.items[0]?.x).toBe(2);
98
+ } else {
99
+ throw new Error('expected grouped-list narrowing');
100
+ }
101
+ });
102
+
103
+ it('narrows the section variant', () => {
104
+ const env: RenderableEnvelope<Sample> = {
105
+ kind: 'section',
106
+ data: { header: 'Summary', icon: 'i', items: ['a', 'b'] },
107
+ };
108
+ expect(isSectionEnvelope(env)).toBe(true);
109
+ if (isSectionEnvelope(env)) {
110
+ expect(env.data.items).toEqual(['a', 'b']);
111
+ } else {
112
+ throw new Error('expected section narrowing');
113
+ }
114
+ });
115
+
116
+ it('narrows the single variant', () => {
117
+ const env: RenderableEnvelope<Sample> = {
118
+ kind: 'single',
119
+ data: { x: 42 },
120
+ };
121
+ expect(isSingleEnvelope(env)).toBe(true);
122
+ if (isSingleEnvelope(env)) {
123
+ expect(env.data.x).toBe(42);
124
+ } else {
125
+ throw new Error('expected single narrowing');
126
+ }
127
+ });
128
+
129
+ it('narrows the generic variant', () => {
130
+ const env: RenderableEnvelope<Sample> = {
131
+ kind: 'generic',
132
+ data: { foo: 'bar', count: 3 },
133
+ };
134
+ expect(isGenericEnvelope(env)).toBe(true);
135
+ if (isGenericEnvelope(env)) {
136
+ expect(env.data.foo).toBe('bar');
137
+ } else {
138
+ throw new Error('expected generic narrowing');
139
+ }
140
+ });
141
+
142
+ it('exhaustively switches on kind without falling through', () => {
143
+ const envelopes: Array<RenderableEnvelope<Sample>> = [
144
+ { kind: 'tree', data: { tree: [], root: 'r', totalNodes: 0, maxDepth: 0 } },
145
+ {
146
+ kind: 'table',
147
+ data: { rows: [], schema: { columns: [] }, total: 0 },
148
+ },
149
+ { kind: 'list', data: { items: [], total: 0 } },
150
+ { kind: 'grouped-list', data: { groups: [], total: 0 } },
151
+ { kind: 'section', data: { header: 'h', items: [] } },
152
+ { kind: 'single', data: { x: 0 } },
153
+ { kind: 'generic', data: {} },
154
+ ];
155
+ const seen = new Set<RenderableEnvelope<Sample>['kind']>();
156
+ for (const env of envelopes) {
157
+ switch (env.kind) {
158
+ case 'tree':
159
+ case 'table':
160
+ case 'list':
161
+ case 'grouped-list':
162
+ case 'section':
163
+ case 'single':
164
+ case 'generic':
165
+ seen.add(env.kind);
166
+ break;
167
+ default: {
168
+ const _exhaustive: never = env;
169
+ throw new Error(`unreachable: ${JSON.stringify(_exhaustive)}`);
170
+ }
171
+ }
172
+ }
173
+ expect(seen.size).toBe(7);
174
+ });
175
+ });
176
+
177
+ describe('per-response type guards', () => {
178
+ it('accepts valid response shapes', () => {
179
+ expect(isTreeResponse({ tree: [], root: 'r', totalNodes: 0, maxDepth: 0 })).toBe(true);
180
+ expect(isTableResponse({ rows: [], schema: { columns: [] }, total: 0 })).toBe(true);
181
+ expect(isListResponse({ items: [], total: 0 })).toBe(true);
182
+ expect(isGroupedListResponse({ groups: [], total: 0 })).toBe(true);
183
+ });
184
+
185
+ it('rejects invalid response shapes', () => {
186
+ expect(isTreeResponse(null)).toBe(false);
187
+ expect(isTreeResponse({})).toBe(false);
188
+ expect(isTreeResponse({ tree: 'nope', root: 'r', totalNodes: 0, maxDepth: 0 })).toBe(false);
189
+ expect(isTableResponse({ rows: [], total: 0 })).toBe(false);
190
+ expect(isTableResponse({ rows: [], schema: 'nope', total: 0 })).toBe(false);
191
+ expect(isListResponse({ items: 'nope', total: 0 })).toBe(false);
192
+ expect(isGroupedListResponse({ groups: 'nope', total: 0 })).toBe(false);
193
+ });
194
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Typed render contracts — top-level `RenderableEnvelope<T>` discriminated union.
3
+ *
4
+ * Part of the Human Render Contract (Epic T10114, ADR-077). Every renderable
5
+ * value flowing from CLEO commands to presenters MUST be wrapped in one of the
6
+ * variants below. Presenters dispatch on `kind` to pick the right renderer.
7
+ *
8
+ * @epic T10114
9
+ * @task T10141
10
+ */
11
+
12
+ import type { GroupedListResponse, ListResponse } from './list.js';
13
+ import type { TableResponse } from './table.js';
14
+ import type { TreeResponse } from './tree.js';
15
+
16
+ /**
17
+ * Free-form section block — a labelled header with an ordered set of
18
+ * pre-formatted item strings. Used for ad-hoc human output that doesn't fit
19
+ * the structured `tree` / `table` / `list` shapes.
20
+ */
21
+ export interface SectionResponse {
22
+ /** Heading rendered above the items. */
23
+ readonly header: string;
24
+ /** Optional icon hint — presenters MAY prepend it to the header. */
25
+ readonly icon?: string;
26
+ /** Pre-formatted item strings rendered as a bulleted block. */
27
+ readonly items: ReadonlyArray<string>;
28
+ }
29
+
30
+ /**
31
+ * Discriminated union of every renderable envelope variant CLEO presenters
32
+ * understand.
33
+ *
34
+ * The `kind` field is the discriminator — narrow on it inside a `switch`
35
+ * statement or via the per-kind type guards exported below.
36
+ *
37
+ * @typeParam T — caller-defined payload shape. For `single` it is the entire
38
+ * payload; for the collection variants it parameterises the
39
+ * inner response type. `section` and `generic` are independent
40
+ * of `T`.
41
+ */
42
+ export type RenderableEnvelope<T> =
43
+ | { readonly kind: 'tree'; readonly data: TreeResponse<T> }
44
+ | { readonly kind: 'table'; readonly data: TableResponse<T> }
45
+ | { readonly kind: 'list'; readonly data: ListResponse<T> }
46
+ | { readonly kind: 'grouped-list'; readonly data: GroupedListResponse<T> }
47
+ | { readonly kind: 'section'; readonly data: SectionResponse }
48
+ | { readonly kind: 'single'; readonly data: T }
49
+ | { readonly kind: 'generic'; readonly data: Record<string, unknown> };
50
+
51
+ /**
52
+ * Type guard — narrows to the `tree` variant.
53
+ */
54
+ export function isTreeEnvelope<T>(
55
+ env: RenderableEnvelope<T>,
56
+ ): env is Extract<RenderableEnvelope<T>, { kind: 'tree' }> {
57
+ return env.kind === 'tree';
58
+ }
59
+
60
+ /**
61
+ * Type guard — narrows to the `table` variant.
62
+ */
63
+ export function isTableEnvelope<T>(
64
+ env: RenderableEnvelope<T>,
65
+ ): env is Extract<RenderableEnvelope<T>, { kind: 'table' }> {
66
+ return env.kind === 'table';
67
+ }
68
+
69
+ /**
70
+ * Type guard — narrows to the flat `list` variant.
71
+ */
72
+ export function isListEnvelope<T>(
73
+ env: RenderableEnvelope<T>,
74
+ ): env is Extract<RenderableEnvelope<T>, { kind: 'list' }> {
75
+ return env.kind === 'list';
76
+ }
77
+
78
+ /**
79
+ * Type guard — narrows to the `grouped-list` variant.
80
+ */
81
+ export function isGroupedListEnvelope<T>(
82
+ env: RenderableEnvelope<T>,
83
+ ): env is Extract<RenderableEnvelope<T>, { kind: 'grouped-list' }> {
84
+ return env.kind === 'grouped-list';
85
+ }
86
+
87
+ /**
88
+ * Type guard — narrows to the `section` variant.
89
+ */
90
+ export function isSectionEnvelope<T>(
91
+ env: RenderableEnvelope<T>,
92
+ ): env is Extract<RenderableEnvelope<T>, { kind: 'section' }> {
93
+ return env.kind === 'section';
94
+ }
95
+
96
+ /**
97
+ * Type guard — narrows to the `single` variant.
98
+ */
99
+ export function isSingleEnvelope<T>(
100
+ env: RenderableEnvelope<T>,
101
+ ): env is Extract<RenderableEnvelope<T>, { kind: 'single' }> {
102
+ return env.kind === 'single';
103
+ }
104
+
105
+ /**
106
+ * Type guard — narrows to the `generic` variant.
107
+ */
108
+ export function isGenericEnvelope<T>(
109
+ env: RenderableEnvelope<T>,
110
+ ): env is Extract<RenderableEnvelope<T>, { kind: 'generic' }> {
111
+ return env.kind === 'generic';
112
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Barrel for the typed render contracts (Epic T10114, ADR-077).
3
+ *
4
+ * Icon enums are intentionally omitted — they ship with T10127 (B2).
5
+ *
6
+ * @epic T10114
7
+ */
8
+
9
+ export type { RenderableEnvelope, SectionResponse } from './envelope.js';
10
+ export {
11
+ isGenericEnvelope,
12
+ isGroupedListEnvelope,
13
+ isListEnvelope,
14
+ isSectionEnvelope,
15
+ isSingleEnvelope,
16
+ isTableEnvelope,
17
+ isTreeEnvelope,
18
+ } from './envelope.js';
19
+ export type {
20
+ GroupedListResponse,
21
+ ListGroup,
22
+ ListItemStyle,
23
+ ListResponse,
24
+ } from './list.js';
25
+ export { isGroupedListResponse, isListResponse } from './list.js';
26
+ export type {
27
+ ColumnAlign,
28
+ TableColumn,
29
+ TableResponse,
30
+ TableSchema,
31
+ } from './table.js';
32
+ export { isTableResponse } from './table.js';
33
+ export type {
34
+ FlatTreeNode,
35
+ RenderTreeOptions,
36
+ TreeNodeKind,
37
+ TreeNodeStatus,
38
+ TreeResponse,
39
+ } from './tree.js';
40
+ export { isTreeResponse } from './tree.js';
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Typed render contracts — list (flat) and grouped-list.
3
+ *
4
+ * Part of the Human Render Contract (Epic T10114, ADR-077). The list shape is
5
+ * the canonical wire format for ordered or grouped sequential output —
6
+ * presenters render each item according to `renderItem` (or a default).
7
+ *
8
+ * @epic T10114
9
+ * @task T10140
10
+ */
11
+
12
+ /**
13
+ * Per-item rendering style hint for list responses.
14
+ *
15
+ * - `'text'` — render the item as a plain string (default presenter behaviour).
16
+ * - `'badge'` — render with status / category styling.
17
+ * - `'kv'` — render as a key/value pair block.
18
+ */
19
+ export type ListItemStyle = 'text' | 'badge' | 'kv';
20
+
21
+ /**
22
+ * Flat list response — items rendered in `items` order.
23
+ *
24
+ * @typeParam T — caller-defined item payload shape.
25
+ */
26
+ export interface ListResponse<T> {
27
+ /** Item payloads in render order. */
28
+ readonly items: ReadonlyArray<T>;
29
+ /**
30
+ * Total item count. May exceed `items.length` when the response is paginated
31
+ * or truncated.
32
+ */
33
+ readonly total: number;
34
+ /** Optional per-item rendering style hint. */
35
+ readonly renderItem?: ListItemStyle;
36
+ }
37
+
38
+ /**
39
+ * One labelled bucket inside a grouped list response.
40
+ *
41
+ * @typeParam T — caller-defined item payload shape.
42
+ */
43
+ export interface ListGroup<T> {
44
+ /** Stable group identifier — used for diff / equality, not display. */
45
+ readonly key: string;
46
+ /** Human-readable group label rendered above its items. */
47
+ readonly label: string;
48
+ /** Item payloads inside this group, in render order. */
49
+ readonly items: ReadonlyArray<T>;
50
+ }
51
+
52
+ /**
53
+ * Grouped list response — items split across labelled buckets.
54
+ *
55
+ * @typeParam T — caller-defined item payload shape.
56
+ */
57
+ export interface GroupedListResponse<T> {
58
+ /** Ordered list of labelled groups. */
59
+ readonly groups: ReadonlyArray<ListGroup<T>>;
60
+ /** Total item count across all groups. */
61
+ readonly total: number;
62
+ }
63
+
64
+ /**
65
+ * Runtime type guard for `ListResponse<T>`.
66
+ *
67
+ * Checks the envelope shape only — does not inspect item payloads against `T`.
68
+ *
69
+ * @param value — candidate value to inspect.
70
+ * @returns `true` iff `value` matches the `ListResponse<T>` envelope shape.
71
+ */
72
+ export function isListResponse<T>(value: unknown): value is ListResponse<T> {
73
+ if (typeof value !== 'object' || value === null) return false;
74
+ const v = value as Record<string, unknown>;
75
+ return Array.isArray(v.items) && typeof v.total === 'number';
76
+ }
77
+
78
+ /**
79
+ * Runtime type guard for `GroupedListResponse<T>`.
80
+ *
81
+ * Checks the envelope shape only — does not inspect item payloads against `T`.
82
+ *
83
+ * @param value — candidate value to inspect.
84
+ * @returns `true` iff `value` matches the `GroupedListResponse<T>` envelope shape.
85
+ */
86
+ export function isGroupedListResponse<T>(value: unknown): value is GroupedListResponse<T> {
87
+ if (typeof value !== 'object' || value === null) return false;
88
+ const v = value as Record<string, unknown>;
89
+ return Array.isArray(v.groups) && typeof v.total === 'number';
90
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Typed render contracts — table (column-schema-driven rows).
3
+ *
4
+ * Part of the Human Render Contract (Epic T10114, ADR-077). The table shape is
5
+ * the canonical wire format for tabular CLI output — presenters consume the
6
+ * schema to compute widths, headers, and per-cell formatting.
7
+ *
8
+ * @epic T10114
9
+ * @task T10139
10
+ */
11
+
12
+ /**
13
+ * Horizontal alignment hint for a column.
14
+ */
15
+ export type ColumnAlign = 'left' | 'right' | 'center';
16
+
17
+ /**
18
+ * Schema entry for a single table column.
19
+ *
20
+ * `key` MUST be a string-typed property on the row payload `T`. Presenters
21
+ * are expected to extract `row[column.key]` and pipe it through `format` when
22
+ * one is supplied.
23
+ *
24
+ * @typeParam T — caller-defined row payload shape.
25
+ */
26
+ export interface TableColumn<T> {
27
+ /** Property key on the row payload. */
28
+ readonly key: keyof T & string;
29
+ /** Display header rendered above the column. */
30
+ readonly header: string;
31
+ /** Optional alignment hint. Defaults to `'left'`. */
32
+ readonly align?: ColumnAlign;
33
+ /** Optional fixed width (character count) hint. */
34
+ readonly width?: number;
35
+ /**
36
+ * Optional per-cell formatter. Receives the raw value at `row[key]` and
37
+ * returns the display string. When absent, presenters call `String(value)`.
38
+ */
39
+ readonly format?: (value: unknown) => string;
40
+ }
41
+
42
+ /**
43
+ * Column-schema metadata that accompanies every table response.
44
+ *
45
+ * @typeParam T — caller-defined row payload shape.
46
+ */
47
+ export interface TableSchema<T> {
48
+ /** Ordered list of column descriptors. Rendering order matches this order. */
49
+ readonly columns: ReadonlyArray<TableColumn<T>>;
50
+ }
51
+
52
+ /**
53
+ * Top-level envelope returned by a table-shaped renderer.
54
+ *
55
+ * @typeParam T — caller-defined row payload shape.
56
+ */
57
+ export interface TableResponse<T> {
58
+ /** Row payloads in the order they should be rendered. */
59
+ readonly rows: ReadonlyArray<T>;
60
+ /** Column schema — drives presenter formatting. */
61
+ readonly schema: TableSchema<T>;
62
+ /**
63
+ * Total row count. May exceed `rows.length` when the response is paginated
64
+ * or truncated; presenters use this to render "showing N of M" hints.
65
+ */
66
+ readonly total: number;
67
+ }
68
+
69
+ /**
70
+ * Runtime type guard for `TableResponse<T>`.
71
+ *
72
+ * Checks the envelope shape only — does not inspect row payloads against `T`.
73
+ *
74
+ * @param value — candidate value to inspect.
75
+ * @returns `true` iff `value` matches the `TableResponse<T>` envelope shape.
76
+ */
77
+ export function isTableResponse<T>(value: unknown): value is TableResponse<T> {
78
+ if (typeof value !== 'object' || value === null) return false;
79
+ const v = value as Record<string, unknown>;
80
+ if (!Array.isArray(v.rows)) return false;
81
+ if (typeof v.total !== 'number') return false;
82
+ const schema = v.schema as Record<string, unknown> | undefined;
83
+ if (typeof schema !== 'object' || schema === null) return false;
84
+ return Array.isArray(schema.columns);
85
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Typed render contracts — tree (flattened hierarchy).
3
+ *
4
+ * Part of the Human Render Contract (Epic T10114, ADR-077). The flat-tree shape
5
+ * is the canonical wire format for hierarchical task / saga / epic listings —
6
+ * presenters re-materialise the parent/child structure from `parentId` + `depth`.
7
+ *
8
+ * @epic T10114
9
+ * @task T10138
10
+ */
11
+
12
+ /**
13
+ * Discriminator for a node's place in the task hierarchy.
14
+ *
15
+ * Matches the CLEO 4-tier task model (Saga → Epic → Task → Subtask).
16
+ */
17
+ export type TreeNodeKind = 'saga' | 'epic' | 'task' | 'subtask';
18
+
19
+ /**
20
+ * Lifecycle status of a tree node.
21
+ *
22
+ * Mirrors the canonical task status union used across CLEO.
23
+ */
24
+ export type TreeNodeStatus =
25
+ | 'pending'
26
+ | 'in_progress'
27
+ | 'done'
28
+ | 'blocked'
29
+ | 'cancelled'
30
+ | 'archived';
31
+
32
+ /**
33
+ * One row in a flattened tree response.
34
+ *
35
+ * `parentId === null` identifies the root. `depth` is the integer distance
36
+ * from the root (root is `0`). `metadata` is the caller-defined payload —
37
+ * presenters MUST NOT assume any specific shape beyond `T`.
38
+ *
39
+ * @typeParam T — caller-defined metadata payload attached to the node.
40
+ */
41
+ export interface FlatTreeNode<T> {
42
+ /** Stable, unique identifier (typically a CLEO task ID such as `T1234`). */
43
+ readonly id: string;
44
+ /** Parent identifier, or `null` if this row is the root. */
45
+ readonly parentId: string | null;
46
+ /** Integer distance from the root (root = `0`). */
47
+ readonly depth: number;
48
+ /** Discriminator slot in the CLEO 4-tier model. */
49
+ readonly kind: TreeNodeKind;
50
+ /** Lifecycle status. */
51
+ readonly status: TreeNodeStatus;
52
+ /** Display title — short enough to render on a single line. */
53
+ readonly title: string;
54
+ /** Caller-defined metadata payload. */
55
+ readonly metadata: T;
56
+ }
57
+
58
+ /**
59
+ * Top-level envelope returned by a tree-shaped renderer.
60
+ *
61
+ * @typeParam T — caller-defined metadata payload attached to each node.
62
+ */
63
+ export interface TreeResponse<T> {
64
+ /** Flattened list of nodes in pre-order traversal. */
65
+ readonly tree: ReadonlyArray<FlatTreeNode<T>>;
66
+ /** Identifier of the root node — must match a node in `tree` with `parentId === null`. */
67
+ readonly root: string;
68
+ /** Total number of nodes — equal to `tree.length`. */
69
+ readonly totalNodes: number;
70
+ /** Maximum `depth` seen across `tree`. `0` for a single-node tree. */
71
+ readonly maxDepth: number;
72
+ }
73
+
74
+ /**
75
+ * Caller-supplied options that shape a tree response.
76
+ *
77
+ * All fields are optional. Presenters interpret unset fields as "no constraint"
78
+ * (e.g. unset `depth` means render the full tree).
79
+ */
80
+ export interface RenderTreeOptions {
81
+ /** Cap the maximum depth rendered. */
82
+ readonly depth?: number;
83
+ /** Restrict the rendered node kinds. */
84
+ readonly kinds?: ReadonlyArray<TreeNodeKind>;
85
+ /** When `true`, presenters may emit unicode icons next to each row. */
86
+ readonly icons?: boolean;
87
+ /** When set, collapse subtrees beyond this child-count threshold. */
88
+ readonly foldAt?: number;
89
+ /** When `true`, presenters MAY annotate inter-task dependencies. */
90
+ readonly withDeps?: boolean;
91
+ /** When `true`, presenters MAY annotate blocker relationships. */
92
+ readonly withBlockers?: boolean;
93
+ }
94
+
95
+ /**
96
+ * Runtime type guard for `TreeResponse<T>`.
97
+ *
98
+ * Verifies the envelope shape without inspecting node payloads — the generic
99
+ * `T` cannot be checked at runtime, so callers must layer per-node validation
100
+ * separately if they need stronger guarantees.
101
+ *
102
+ * @param value — candidate value to inspect.
103
+ * @returns `true` iff `value` matches the `TreeResponse<T>` envelope shape.
104
+ */
105
+ export function isTreeResponse<T>(value: unknown): value is TreeResponse<T> {
106
+ if (typeof value !== 'object' || value === null) return false;
107
+ const v = value as Record<string, unknown>;
108
+ return (
109
+ Array.isArray(v.tree) &&
110
+ typeof v.root === 'string' &&
111
+ typeof v.totalNodes === 'number' &&
112
+ typeof v.maxDepth === 'number'
113
+ );
114
+ }