@svadmin/core 0.59.0 → 0.61.0
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.
- package/package.json +21 -1
- package/src/index.ts +44 -1
- package/src/inferencer.ts +82 -1
- package/src/product-navigation.ts +145 -0
- package/src/product-pages.ts +45 -0
- package/src/product-pipeline.ts +43 -0
- package/src/product-plan.ts +359 -0
- package/src/product-review.ts +123 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"author": "zuohuadong",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -111,6 +111,26 @@
|
|
|
111
111
|
"types": "./src/inferencer-graphql.ts",
|
|
112
112
|
"default": "./src/inferencer-graphql.ts"
|
|
113
113
|
},
|
|
114
|
+
"./product-plan": {
|
|
115
|
+
"types": "./src/product-plan.ts",
|
|
116
|
+
"default": "./src/product-plan.ts"
|
|
117
|
+
},
|
|
118
|
+
"./product-navigation": {
|
|
119
|
+
"types": "./src/product-navigation.ts",
|
|
120
|
+
"default": "./src/product-navigation.ts"
|
|
121
|
+
},
|
|
122
|
+
"./product-pages": {
|
|
123
|
+
"types": "./src/product-pages.ts",
|
|
124
|
+
"default": "./src/product-pages.ts"
|
|
125
|
+
},
|
|
126
|
+
"./product-pipeline": {
|
|
127
|
+
"types": "./src/product-pipeline.ts",
|
|
128
|
+
"default": "./src/product-pipeline.ts"
|
|
129
|
+
},
|
|
130
|
+
"./product-review": {
|
|
131
|
+
"types": "./src/product-review.ts",
|
|
132
|
+
"default": "./src/product-review.ts"
|
|
133
|
+
},
|
|
114
134
|
"./admin-tool": {
|
|
115
135
|
"types": "./src/admin-tool.ts",
|
|
116
136
|
"default": "./src/admin-tool.ts"
|
package/src/index.ts
CHANGED
|
@@ -304,7 +304,7 @@ export {
|
|
|
304
304
|
generateComponentCode,
|
|
305
305
|
generateResourceBundle,
|
|
306
306
|
} from './inferencer';
|
|
307
|
-
export type { InferResult } from './inferencer';
|
|
307
|
+
export type { InferResult, InferReview, InferReviewWarning, InferReviewWarningCode } from './inferencer';
|
|
308
308
|
export { createWebSocketLiveProvider } from './live-websocket';
|
|
309
309
|
export type { WebSocketLiveProviderOptions } from './live-websocket';
|
|
310
310
|
export { createSSELiveProvider } from './live-sse';
|
|
@@ -348,3 +348,46 @@ export type {
|
|
|
348
348
|
// HTTP fetch utilities
|
|
349
349
|
export { createFetchWithInterceptor, fetchWithInterceptor } from './http';
|
|
350
350
|
export type { FetchWithInterceptor, FetchWithInterceptorOptions } from './http';
|
|
351
|
+
|
|
352
|
+
// Product planning contracts keep business decisions separate from resources, pages and menus.
|
|
353
|
+
export {
|
|
354
|
+
PRODUCT_PLAN_SCHEMA_VERSION,
|
|
355
|
+
ProductPlanValidationError,
|
|
356
|
+
parseProductPlan,
|
|
357
|
+
validateProductPlan,
|
|
358
|
+
} from './product-plan';
|
|
359
|
+
export type {
|
|
360
|
+
ProductAction,
|
|
361
|
+
ProductBrief,
|
|
362
|
+
ProductDisclosure,
|
|
363
|
+
ProductField,
|
|
364
|
+
ProductFrequency,
|
|
365
|
+
ProductId,
|
|
366
|
+
ProductMenuNode,
|
|
367
|
+
ProductMenuPlacement,
|
|
368
|
+
ProductNavigationNode,
|
|
369
|
+
ProductPageKind,
|
|
370
|
+
ProductPlan,
|
|
371
|
+
ProductPlanValidationIssue,
|
|
372
|
+
ProductPlanValidationResult,
|
|
373
|
+
ProductPriority,
|
|
374
|
+
ProductResource,
|
|
375
|
+
ProductRole,
|
|
376
|
+
ProductSurface,
|
|
377
|
+
ProductWorkflow,
|
|
378
|
+
} from './product-plan';
|
|
379
|
+
export {
|
|
380
|
+
buildProductNavigation,
|
|
381
|
+
flattenVisibleProductNavigation,
|
|
382
|
+
buildProductMenuItems,
|
|
383
|
+
} from './product-navigation';
|
|
384
|
+
export type {
|
|
385
|
+
ProductNavigationContext,
|
|
386
|
+
ProductMenuOptions,
|
|
387
|
+
} from './product-navigation';
|
|
388
|
+
export { buildProductPagePlan } from './product-pages';
|
|
389
|
+
export type { ProductPagePlan, ProductPageSection } from './product-pages';
|
|
390
|
+
export { formatProductPreview, runProductPipeline } from './product-pipeline';
|
|
391
|
+
export type { ProductPipelineInput, ProductPipelineOutput } from './product-pipeline';
|
|
392
|
+
export { reviewProductPlan } from './product-review';
|
|
393
|
+
export type { ProductReviewIssue, ProductReviewMetrics, ProductReviewReport, ProductReviewSeverity } from './product-review';
|
package/src/inferencer.ts
CHANGED
|
@@ -84,6 +84,7 @@ export interface InferResult {
|
|
|
84
84
|
resource: ResourceDefinition;
|
|
85
85
|
code: string;
|
|
86
86
|
typeboxCode: string;
|
|
87
|
+
review: InferReview;
|
|
87
88
|
componentCode: {
|
|
88
89
|
list: string;
|
|
89
90
|
create: string;
|
|
@@ -92,6 +93,26 @@ export interface InferResult {
|
|
|
92
93
|
};
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
export type InferReviewWarningCode =
|
|
97
|
+
| 'empty-sample'
|
|
98
|
+
| 'nullable-field'
|
|
99
|
+
| 'mixed-types'
|
|
100
|
+
| 'heuristic-relation'
|
|
101
|
+
| 'heuristic-select';
|
|
102
|
+
|
|
103
|
+
export interface InferReviewWarning {
|
|
104
|
+
code: InferReviewWarningCode;
|
|
105
|
+
field?: string;
|
|
106
|
+
message: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface InferReview {
|
|
110
|
+
needsReview: boolean;
|
|
111
|
+
confidence: 'high' | 'medium' | 'low';
|
|
112
|
+
sampleSize: number;
|
|
113
|
+
warnings: InferReviewWarning[];
|
|
114
|
+
}
|
|
115
|
+
|
|
95
116
|
/**
|
|
96
117
|
* Analyze a sample data array and produce a ResourceDefinition.
|
|
97
118
|
* @param resourceName - Name of the resource (e.g. "posts")
|
|
@@ -116,6 +137,15 @@ export function inferResource(
|
|
|
116
137
|
resource: emptyResource,
|
|
117
138
|
code: `// No data available to infer fields for "${resourceName}".`,
|
|
118
139
|
typeboxCode: generateTypeBoxSchemaCode(emptyResource),
|
|
140
|
+
review: {
|
|
141
|
+
needsReview: true,
|
|
142
|
+
confidence: 'low',
|
|
143
|
+
sampleSize: 0,
|
|
144
|
+
warnings: [{
|
|
145
|
+
code: 'empty-sample',
|
|
146
|
+
message: `No sample records were available for "${resourceName}".`,
|
|
147
|
+
}],
|
|
148
|
+
},
|
|
119
149
|
componentCode: {
|
|
120
150
|
list: generateListPageCode(emptyResource),
|
|
121
151
|
create: generateCreatePageCode(emptyResource),
|
|
@@ -133,6 +163,7 @@ export function inferResource(
|
|
|
133
163
|
|
|
134
164
|
// For each key, infer from all non-null values
|
|
135
165
|
const fields: FieldDefinition[] = [];
|
|
166
|
+
const warnings: InferReviewWarning[] = [];
|
|
136
167
|
for (const key of keySet) {
|
|
137
168
|
// Collect non-null values
|
|
138
169
|
const values = sampleData
|
|
@@ -141,6 +172,13 @@ export function inferResource(
|
|
|
141
172
|
|
|
142
173
|
const sampleValue = values[0];
|
|
143
174
|
let inferredType = inferFieldType(key, sampleValue);
|
|
175
|
+
if (values.length === 0) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
code: 'nullable-field',
|
|
178
|
+
field: key,
|
|
179
|
+
message: `Field "${key}" contains only null or undefined values in the sample.`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
144
182
|
|
|
145
183
|
// Cross-validate: if most values for this key are of a different type, use majority
|
|
146
184
|
const typeCounts = new Map<InferredType, number>();
|
|
@@ -155,13 +193,34 @@ export function inferResource(
|
|
|
155
193
|
inferredType = t;
|
|
156
194
|
}
|
|
157
195
|
}
|
|
196
|
+
if (typeCounts.size > 1) {
|
|
197
|
+
warnings.push({
|
|
198
|
+
code: 'mixed-types',
|
|
199
|
+
field: key,
|
|
200
|
+
message: `Field "${key}" contains multiple inferred types; the majority type was selected.`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
158
203
|
|
|
159
204
|
// Check for relation
|
|
160
205
|
const relatedResource = isLikelyRelation(key);
|
|
206
|
+
if (relatedResource) {
|
|
207
|
+
warnings.push({
|
|
208
|
+
code: 'heuristic-relation',
|
|
209
|
+
field: key,
|
|
210
|
+
message: `Relation "${key}" was inferred from its field name.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
161
213
|
|
|
162
214
|
// Check if it looks like a select (few unique string values)
|
|
163
215
|
const uniqueStrings = new Set(values.filter(v => typeof v === 'string') as string[]);
|
|
164
216
|
const isSelect = inferredType === 'text' && uniqueStrings.size > 1 && uniqueStrings.size <= 10 && values.length >= 5;
|
|
217
|
+
if (isSelect) {
|
|
218
|
+
warnings.push({
|
|
219
|
+
code: 'heuristic-select',
|
|
220
|
+
field: key,
|
|
221
|
+
message: `Select options for "${key}" were inferred from repeated sample values.`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
165
224
|
|
|
166
225
|
const field: FieldDefinition = {
|
|
167
226
|
key,
|
|
@@ -213,7 +272,23 @@ export function inferResource(
|
|
|
213
272
|
show: generateShowPageCode(resource),
|
|
214
273
|
};
|
|
215
274
|
|
|
216
|
-
return {
|
|
275
|
+
return {
|
|
276
|
+
fields,
|
|
277
|
+
resource,
|
|
278
|
+
code,
|
|
279
|
+
typeboxCode,
|
|
280
|
+
review: {
|
|
281
|
+
needsReview: warnings.length > 0,
|
|
282
|
+
confidence: warnings.some((warning) => warning.code === 'mixed-types' || warning.code === 'nullable-field')
|
|
283
|
+
? 'low'
|
|
284
|
+
: warnings.length > 0
|
|
285
|
+
? 'medium'
|
|
286
|
+
: 'high',
|
|
287
|
+
sampleSize: sampleData.length,
|
|
288
|
+
warnings,
|
|
289
|
+
},
|
|
290
|
+
componentCode,
|
|
291
|
+
};
|
|
217
292
|
}
|
|
218
293
|
|
|
219
294
|
// ─── Code Generation ─────────────────────────────────────────
|
|
@@ -462,6 +537,12 @@ export function generateResourceBundle(resource: ResourceDefinition): InferResul
|
|
|
462
537
|
resource,
|
|
463
538
|
code: generateResourceCode(resource),
|
|
464
539
|
typeboxCode: generateTypeBoxSchemaCode(resource),
|
|
540
|
+
review: {
|
|
541
|
+
needsReview: false,
|
|
542
|
+
confidence: 'high',
|
|
543
|
+
sampleSize: 0,
|
|
544
|
+
warnings: [],
|
|
545
|
+
},
|
|
465
546
|
componentCode: {
|
|
466
547
|
list: generateListPageCode(resource),
|
|
467
548
|
create: generateCreatePageCode(resource),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { MenuItem } from './types';
|
|
2
|
+
import type {
|
|
3
|
+
ProductMenuNode,
|
|
4
|
+
ProductMenuPlacement,
|
|
5
|
+
ProductNavigationNode,
|
|
6
|
+
ProductPlan,
|
|
7
|
+
ProductSurface,
|
|
8
|
+
} from './product-plan';
|
|
9
|
+
|
|
10
|
+
export interface ProductNavigationContext {
|
|
11
|
+
readonly roleIds: readonly string[];
|
|
12
|
+
readonly featureFlags?: Readonly<Record<string, boolean>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProductMenuOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Placements that may become sidebar menu items. Contextual and command
|
|
18
|
+
* entries stay out of the persistent navigation unless explicitly opted in.
|
|
19
|
+
*/
|
|
20
|
+
readonly includePlacements?: readonly ProductMenuPlacement[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MENU_PLACEMENTS: readonly ProductMenuPlacement[] = ['primary', 'secondary', 'settings'];
|
|
24
|
+
|
|
25
|
+
function canSeeByRole(item: ProductMenuNode, roleIds: readonly string[]): boolean {
|
|
26
|
+
// Empty roleIds means the entry is public at the UI hint layer. Backend authorization remains authoritative.
|
|
27
|
+
return item.roleIds.length === 0 || item.roleIds.some(roleId => roleIds.includes(roleId));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function conditionEnabled(condition: string | undefined, flags: Readonly<Record<string, boolean>> | undefined): boolean {
|
|
31
|
+
return condition === undefined ? true : flags?.[condition] === true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createNode(
|
|
35
|
+
item: ProductMenuNode,
|
|
36
|
+
allItems: readonly ProductMenuNode[],
|
|
37
|
+
context: ProductNavigationContext,
|
|
38
|
+
ancestry: ReadonlySet<string> = new Set(),
|
|
39
|
+
): ProductNavigationNode {
|
|
40
|
+
const visibleByRole = canSeeByRole(item, context.roleIds);
|
|
41
|
+
const visibleByCondition = conditionEnabled(item.condition, context.featureFlags);
|
|
42
|
+
const visible = visibleByRole && visibleByCondition && item.placement !== 'hidden';
|
|
43
|
+
const nextAncestry = new Set(ancestry);
|
|
44
|
+
nextAncestry.add(item.id);
|
|
45
|
+
const children = nextAncestry.has(item.id)
|
|
46
|
+
? allItems
|
|
47
|
+
.filter(candidate => candidate.parentId === item.id && !nextAncestry.has(candidate.id))
|
|
48
|
+
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
|
|
49
|
+
.map(candidate => createNode(candidate, allItems, context, nextAncestry))
|
|
50
|
+
: [];
|
|
51
|
+
return {
|
|
52
|
+
...item,
|
|
53
|
+
children,
|
|
54
|
+
visible,
|
|
55
|
+
...(visible ? {} : { reason: visibleByRole ? 'hidden by business condition or placement' : 'current role is not allowed to see this entry' }),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Builds a product navigation tree from product decisions. There is
|
|
61
|
+
* deliberately no fixed menu count: domain complexity determines the number
|
|
62
|
+
* of entries, while review checks whether each entry has a role, workflow,
|
|
63
|
+
* resource or surface target.
|
|
64
|
+
*/
|
|
65
|
+
export function buildProductNavigation(plan: ProductPlan, context: ProductNavigationContext): ProductNavigationNode[] {
|
|
66
|
+
return plan.menu
|
|
67
|
+
.filter(item => item.parentId === undefined)
|
|
68
|
+
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
|
|
69
|
+
.map(item => createNode(item, plan.menu, context));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function flattenVisibleProductNavigation(nodes: readonly ProductNavigationNode[]): ProductNavigationNode[] {
|
|
73
|
+
return nodes.flatMap(node => node.visible
|
|
74
|
+
? [node, ...flattenVisibleProductNavigation(node.children)]
|
|
75
|
+
: []);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function surfaceRoute(surface: ProductSurface | undefined): string | undefined {
|
|
79
|
+
if (surface === undefined) return undefined;
|
|
80
|
+
// route is optional so older product/v1 plans keep a stable generated URL.
|
|
81
|
+
const route = (surface as ProductSurface & { readonly route?: string }).route;
|
|
82
|
+
return route ?? `/workflows/${surface.id}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resourceName(plan: ProductPlan, resourceId: string | undefined): string | undefined {
|
|
86
|
+
if (resourceId === undefined) return undefined;
|
|
87
|
+
const resource = plan.resources.find(candidate => candidate.id === resourceId);
|
|
88
|
+
return resource?.runtimeName ?? resource?.name;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function menuHref(
|
|
92
|
+
plan: ProductPlan,
|
|
93
|
+
node: ProductNavigationNode,
|
|
94
|
+
surfaces: ReadonlyMap<string, ProductSurface>,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
if (node.href !== undefined) return node.href;
|
|
97
|
+
const bySurface = surfaceRoute(surfaces.get(node.surfaceId ?? ''));
|
|
98
|
+
if (bySurface !== undefined) return bySurface;
|
|
99
|
+
const byResource = resourceName(plan, node.resourceId);
|
|
100
|
+
return byResource === undefined ? undefined : `/${byResource}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toMenuItem(
|
|
104
|
+
plan: ProductPlan,
|
|
105
|
+
node: ProductNavigationNode,
|
|
106
|
+
surfaces: ReadonlyMap<string, ProductSurface>,
|
|
107
|
+
placements: readonly ProductMenuPlacement[],
|
|
108
|
+
): MenuItem | undefined {
|
|
109
|
+
// Do not promote a visible child when its parent is hidden or filtered out.
|
|
110
|
+
// Hidden parents represent an intentional product decision and their subtree
|
|
111
|
+
// should not become orphaned sidebar entries.
|
|
112
|
+
if (!node.visible || !placements.includes(node.placement)) return undefined;
|
|
113
|
+
|
|
114
|
+
const children = node.children
|
|
115
|
+
.map(child => toMenuItem(plan, child, surfaces, placements))
|
|
116
|
+
.filter((child): child is MenuItem => child !== undefined);
|
|
117
|
+
const href = menuHref(plan, node, surfaces);
|
|
118
|
+
const targetResource = resourceName(plan, node.resourceId);
|
|
119
|
+
const menuItem: MenuItem = {
|
|
120
|
+
name: node.id,
|
|
121
|
+
...(node.label.length > 0 ? { label: node.label } : {}),
|
|
122
|
+
...(node.icon !== undefined ? { icon: node.icon } : {}),
|
|
123
|
+
...(href !== undefined ? { href } : {}),
|
|
124
|
+
...(targetResource !== undefined ? { meta: { resource: targetResource } } : {}),
|
|
125
|
+
...(children.length > 0 ? { children } : {}),
|
|
126
|
+
};
|
|
127
|
+
return menuItem;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Adapts product navigation metadata to the core MenuItem contract used by
|
|
132
|
+
* AdminApp, Lite and the sidebar. Role and feature filtering happen before
|
|
133
|
+
* conversion, and only requested placements become persistent menu items.
|
|
134
|
+
*/
|
|
135
|
+
export function buildProductMenuItems(
|
|
136
|
+
plan: ProductPlan,
|
|
137
|
+
context: ProductNavigationContext,
|
|
138
|
+
options: ProductMenuOptions = {},
|
|
139
|
+
): MenuItem[] {
|
|
140
|
+
const placements = options.includePlacements ?? DEFAULT_MENU_PLACEMENTS;
|
|
141
|
+
const surfaces = new Map(plan.surfaces.map(surface => [surface.id, surface] as const));
|
|
142
|
+
return buildProductNavigation(plan, context)
|
|
143
|
+
.map(node => toMenuItem(plan, node, surfaces, placements))
|
|
144
|
+
.filter((item): item is MenuItem => item !== undefined);
|
|
145
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ProductAction, ProductPlan, ProductSurface } from './product-plan';
|
|
2
|
+
|
|
3
|
+
export interface ProductPageSection {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly title: string;
|
|
6
|
+
readonly resourceIds: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ProductPagePlan {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly kind: ProductSurface['kind'];
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly purpose: string;
|
|
14
|
+
readonly workflowIds: readonly string[];
|
|
15
|
+
readonly roleIds: readonly string[];
|
|
16
|
+
readonly primaryAction?: ProductAction;
|
|
17
|
+
readonly secondaryActions: readonly ProductAction[];
|
|
18
|
+
readonly sections: readonly ProductPageSection[];
|
|
19
|
+
readonly states: ProductSurface['states'];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Generates a small, renderer-neutral page plan for Svelte components. */
|
|
23
|
+
export function buildProductPagePlan(surface: ProductSurface, plan: ProductPlan): ProductPagePlan {
|
|
24
|
+
const actionById = new Map(plan.actions.map(action => [action.id, action]));
|
|
25
|
+
const primaryAction = surface.primaryActionId === undefined ? undefined : actionById.get(surface.primaryActionId);
|
|
26
|
+
const secondaryActions = surface.actionIds
|
|
27
|
+
.filter(id => id !== surface.primaryActionId)
|
|
28
|
+
.map(id => actionById.get(id))
|
|
29
|
+
.filter((action): action is ProductAction => action !== undefined);
|
|
30
|
+
const sections = surface.sections.length
|
|
31
|
+
? surface.sections.map((title, index) => ({ id: `${surface.id}.section.${index + 1}`, title, resourceIds: surface.resourceIds }))
|
|
32
|
+
: [{ id: `${surface.id}.content`, title: surface.name, resourceIds: surface.resourceIds }];
|
|
33
|
+
return {
|
|
34
|
+
id: surface.id,
|
|
35
|
+
kind: surface.kind,
|
|
36
|
+
title: surface.name,
|
|
37
|
+
purpose: surface.purpose,
|
|
38
|
+
workflowIds: surface.workflowIds,
|
|
39
|
+
roleIds: surface.roleIds,
|
|
40
|
+
...(primaryAction === undefined ? {} : { primaryAction }),
|
|
41
|
+
secondaryActions,
|
|
42
|
+
sections,
|
|
43
|
+
states: surface.states,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ProductNavigationNode, ProductPlan } from './product-plan';
|
|
2
|
+
import { buildProductNavigation, type ProductNavigationContext } from './product-navigation';
|
|
3
|
+
import { buildProductPagePlan, type ProductPagePlan } from './product-pages';
|
|
4
|
+
import { reviewProductPlan, type ProductReviewReport } from './product-review';
|
|
5
|
+
|
|
6
|
+
export interface ProductPipelineInput {
|
|
7
|
+
readonly plan: ProductPlan;
|
|
8
|
+
readonly navigation: ProductNavigationContext;
|
|
9
|
+
readonly surfaceIds?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ProductPipelineOutput {
|
|
13
|
+
readonly navigation: readonly ProductNavigationNode[];
|
|
14
|
+
readonly pages: readonly ProductPagePlan[];
|
|
15
|
+
readonly productReview: ProductReviewReport;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function runProductPipeline(input: ProductPipelineInput): ProductPipelineOutput {
|
|
19
|
+
const navigation = buildProductNavigation(input.plan, input.navigation);
|
|
20
|
+
const visibleSurfaceIds = input.surfaceIds === undefined
|
|
21
|
+
? undefined
|
|
22
|
+
: new Set(input.surfaceIds);
|
|
23
|
+
const pages = input.plan.surfaces
|
|
24
|
+
.filter(surface => visibleSurfaceIds === undefined || visibleSurfaceIds.has(surface.id))
|
|
25
|
+
.filter(surface => surface.roleIds.length === 0 || surface.roleIds.some(roleId => input.navigation.roleIds.includes(roleId)))
|
|
26
|
+
.map(surface => buildProductPagePlan(surface, input.plan));
|
|
27
|
+
return { navigation, pages, productReview: reviewProductPlan(input.plan) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatProductPreview(output: ProductPipelineOutput): string {
|
|
31
|
+
const lines = ['Navigation:'];
|
|
32
|
+
for (const node of output.navigation) {
|
|
33
|
+
// A hidden parent owns its subtree; do not promote role-filtered children
|
|
34
|
+
// into a misleading top-level preview entry.
|
|
35
|
+
if (!node.visible) continue;
|
|
36
|
+
lines.push(`- ${node.label}`);
|
|
37
|
+
for (const child of node.children) if (child.visible) lines.push(` - ${child.label}`);
|
|
38
|
+
}
|
|
39
|
+
lines.push('', 'Pages:');
|
|
40
|
+
output.pages.forEach(page => lines.push(`- ${page.title}: ${page.purpose}`));
|
|
41
|
+
lines.push('', `Product review: ${output.productReview.ok ? 'pass' : 'fail'}`);
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product decisions used by svadmin generation.
|
|
3
|
+
*
|
|
4
|
+
* Resource, surface, action and menu are intentionally separate concepts.
|
|
5
|
+
* A backend resource may be contextual data without becoming a page or menu item.
|
|
6
|
+
*/
|
|
7
|
+
export const PRODUCT_PLAN_SCHEMA_VERSION = 'product/v1' as const;
|
|
8
|
+
|
|
9
|
+
export type ProductId = string;
|
|
10
|
+
export type ProductPriority = 'core' | 'secondary' | 'future';
|
|
11
|
+
export type ProductMenuPlacement = 'primary' | 'secondary' | 'settings' | 'context' | 'command' | 'hidden';
|
|
12
|
+
export type ProductPageKind = 'workbench' | 'list' | 'detail' | 'form' | 'approval' | 'import' | 'settings' | 'custom';
|
|
13
|
+
export type ProductDisclosure = 'visible' | 'collapsed' | 'more' | 'context' | 'hidden';
|
|
14
|
+
export type ProductFrequency = 'high' | 'medium' | 'low' | 'unknown';
|
|
15
|
+
|
|
16
|
+
export interface ProductBrief {
|
|
17
|
+
readonly id: ProductId;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly description: string;
|
|
20
|
+
readonly primaryOutcome: string;
|
|
21
|
+
readonly primaryAudience: string;
|
|
22
|
+
readonly constraints: readonly string[];
|
|
23
|
+
readonly nonGoals: readonly string[];
|
|
24
|
+
readonly assumptions: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ProductRole {
|
|
28
|
+
readonly id: ProductId;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly description?: string;
|
|
31
|
+
readonly permissions: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProductWorkflow {
|
|
35
|
+
readonly id: ProductId;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly actorRoleIds: readonly ProductId[];
|
|
38
|
+
readonly trigger: string;
|
|
39
|
+
readonly goal: string;
|
|
40
|
+
readonly steps: readonly string[];
|
|
41
|
+
readonly successCriteria: readonly string[];
|
|
42
|
+
readonly exceptions: readonly string[];
|
|
43
|
+
readonly priority: ProductPriority;
|
|
44
|
+
readonly frequency: ProductFrequency;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ProductField {
|
|
48
|
+
readonly id: ProductId;
|
|
49
|
+
readonly label: string;
|
|
50
|
+
readonly type: string;
|
|
51
|
+
readonly required?: boolean;
|
|
52
|
+
readonly readableBy: readonly ProductId[];
|
|
53
|
+
readonly writableBy: readonly ProductId[];
|
|
54
|
+
readonly usedByWorkflowIds: readonly ProductId[];
|
|
55
|
+
readonly disclosure?: ProductDisclosure;
|
|
56
|
+
readonly sensitive?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ProductResource {
|
|
60
|
+
readonly id: ProductId;
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly label: string;
|
|
63
|
+
readonly description?: string;
|
|
64
|
+
readonly runtimeName?: string;
|
|
65
|
+
readonly fields: readonly ProductField[];
|
|
66
|
+
readonly menuPlacement?: ProductMenuPlacement;
|
|
67
|
+
readonly roleIds: readonly ProductId[];
|
|
68
|
+
readonly workflowIds: readonly ProductId[];
|
|
69
|
+
readonly internal?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ProductAction {
|
|
73
|
+
readonly id: ProductId;
|
|
74
|
+
readonly label: string;
|
|
75
|
+
readonly description?: string;
|
|
76
|
+
readonly workflowId: ProductId;
|
|
77
|
+
readonly roleIds: readonly ProductId[];
|
|
78
|
+
readonly disclosure?: ProductDisclosure;
|
|
79
|
+
readonly destructive?: boolean;
|
|
80
|
+
readonly condition?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ProductSurface {
|
|
84
|
+
readonly id: ProductId;
|
|
85
|
+
readonly name: string;
|
|
86
|
+
readonly kind: ProductPageKind;
|
|
87
|
+
readonly purpose: string;
|
|
88
|
+
/** Optional stable route. Generators derive one when omitted. */
|
|
89
|
+
readonly route?: string;
|
|
90
|
+
readonly workflowIds: readonly ProductId[];
|
|
91
|
+
readonly resourceIds: readonly ProductId[];
|
|
92
|
+
readonly roleIds: readonly ProductId[];
|
|
93
|
+
readonly menuPlacement?: ProductMenuPlacement;
|
|
94
|
+
readonly parentSurfaceId?: ProductId;
|
|
95
|
+
readonly primaryActionId?: ProductId;
|
|
96
|
+
readonly actionIds: readonly ProductId[];
|
|
97
|
+
readonly sections: readonly string[];
|
|
98
|
+
readonly states: readonly ('empty' | 'loading' | 'error' | 'forbidden' | 'success')[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ProductMenuNode {
|
|
102
|
+
readonly id: ProductId;
|
|
103
|
+
readonly label: string;
|
|
104
|
+
readonly icon?: string;
|
|
105
|
+
readonly href?: string;
|
|
106
|
+
readonly surfaceId?: ProductId;
|
|
107
|
+
readonly resourceId?: ProductId;
|
|
108
|
+
readonly workflowIds: readonly ProductId[];
|
|
109
|
+
readonly roleIds: readonly ProductId[];
|
|
110
|
+
readonly placement: ProductMenuPlacement;
|
|
111
|
+
readonly parentId?: ProductId;
|
|
112
|
+
readonly condition?: string;
|
|
113
|
+
readonly order?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ProductPlan {
|
|
117
|
+
readonly schemaVersion: typeof PRODUCT_PLAN_SCHEMA_VERSION;
|
|
118
|
+
readonly brief: ProductBrief;
|
|
119
|
+
readonly roles: readonly ProductRole[];
|
|
120
|
+
readonly workflows: readonly ProductWorkflow[];
|
|
121
|
+
readonly resources: readonly ProductResource[];
|
|
122
|
+
readonly actions: readonly ProductAction[];
|
|
123
|
+
readonly surfaces: readonly ProductSurface[];
|
|
124
|
+
readonly menu: readonly ProductMenuNode[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ProductPlanValidationIssue {
|
|
128
|
+
readonly code: 'invalid-root' | 'invalid-version' | 'missing-array' | 'invalid-id' | 'invalid-field' | 'invalid-enum' | 'duplicate-id';
|
|
129
|
+
readonly path: string;
|
|
130
|
+
readonly message: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type ProductPlanValidationResult =
|
|
134
|
+
| { readonly ok: true; readonly value: ProductPlan }
|
|
135
|
+
| { readonly ok: false; readonly issues: readonly ProductPlanValidationIssue[] };
|
|
136
|
+
|
|
137
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
138
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasStringId(value: unknown): value is { readonly id: string } {
|
|
142
|
+
return isRecord(value) && typeof value['id'] === 'string' && value['id'].trim().length > 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const PRODUCT_DISCLOSURES = ['visible', 'collapsed', 'more', 'context', 'hidden'] as const;
|
|
146
|
+
const PRODUCT_PRIORITIES = ['core', 'secondary', 'future'] as const;
|
|
147
|
+
const PRODUCT_FREQUENCIES = ['high', 'medium', 'low', 'unknown'] as const;
|
|
148
|
+
const PRODUCT_PLACEMENTS = ['primary', 'secondary', 'settings', 'context', 'command', 'hidden'] as const;
|
|
149
|
+
const PRODUCT_PAGE_KINDS = ['workbench', 'list', 'detail', 'form', 'approval', 'import', 'settings', 'custom'] as const;
|
|
150
|
+
const PRODUCT_STATES = ['empty', 'loading', 'error', 'forbidden', 'success'] as const;
|
|
151
|
+
|
|
152
|
+
function pushEnumIssue(
|
|
153
|
+
issues: ProductPlanValidationIssue[],
|
|
154
|
+
value: unknown,
|
|
155
|
+
path: string,
|
|
156
|
+
allowed: readonly string[],
|
|
157
|
+
): void {
|
|
158
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
159
|
+
issues.push({ code: 'invalid-enum', path, message: path + ' must be one of ' + allowed.join(', ') + '.' });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pushProductFieldIssues(
|
|
164
|
+
issues: ProductPlanValidationIssue[],
|
|
165
|
+
value: unknown,
|
|
166
|
+
path: string,
|
|
167
|
+
): void {
|
|
168
|
+
if (!Array.isArray(value)) {
|
|
169
|
+
issues.push({ code: 'missing-array', path, message: path + ' must be an array.' });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const seen = new Set<string>();
|
|
173
|
+
value.forEach((item, index) => {
|
|
174
|
+
const itemPath = path + '.' + index;
|
|
175
|
+
if (!isRecord(item)) {
|
|
176
|
+
issues.push({ code: 'invalid-field', path: itemPath, message: itemPath + ' must be an object.' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const fieldId = item['id'];
|
|
180
|
+
if (typeof fieldId !== 'string' || fieldId.trim().length === 0) {
|
|
181
|
+
issues.push({ code: 'invalid-id', path: itemPath + '.id', message: 'Every product field needs a non-empty string id.' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (seen.has(fieldId)) {
|
|
185
|
+
issues.push({ code: 'duplicate-id', path: itemPath + '.id', message: 'Product field id ' + fieldId + ' is duplicated within its resource.' });
|
|
186
|
+
}
|
|
187
|
+
seen.add(fieldId);
|
|
188
|
+
for (const key of ['label', 'type'] as const) {
|
|
189
|
+
if (typeof item[key] !== 'string') issues.push({ code: 'invalid-field', path: itemPath + '.' + key, message: itemPath + '.' + key + ' must be a string.' });
|
|
190
|
+
}
|
|
191
|
+
for (const key of ['readableBy', 'writableBy', 'usedByWorkflowIds'] as const) {
|
|
192
|
+
const candidate = item[key];
|
|
193
|
+
if (!Array.isArray(candidate) || !candidate.every(entry => typeof entry === 'string')) {
|
|
194
|
+
issues.push({ code: 'invalid-field', path: itemPath + '.' + key, message: itemPath + '.' + key + ' must be an array of strings.' });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const key of ['required', 'sensitive'] as const) {
|
|
198
|
+
if (item[key] !== undefined && typeof item[key] !== 'boolean') {
|
|
199
|
+
issues.push({ code: 'invalid-field', path: itemPath + '.' + key, message: itemPath + '.' + key + ' must be a boolean when provided.' });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (item['disclosure'] !== undefined) pushEnumIssue(issues, item['disclosure'], itemPath + '.disclosure', PRODUCT_DISCLOSURES);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface EntityShape {
|
|
207
|
+
readonly strings?: readonly string[];
|
|
208
|
+
readonly arrays?: readonly string[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pushEntityIssues(
|
|
212
|
+
issues: ProductPlanValidationIssue[],
|
|
213
|
+
value: unknown,
|
|
214
|
+
path: string,
|
|
215
|
+
seen: Set<string>,
|
|
216
|
+
shape: EntityShape = {},
|
|
217
|
+
): void {
|
|
218
|
+
if (!Array.isArray(value)) {
|
|
219
|
+
issues.push({ code: 'missing-array', path, message: `${path} must be an array.` });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
value.forEach((item, index) => {
|
|
223
|
+
const itemPath = `${path}.${index}`;
|
|
224
|
+
if (!hasStringId(item)) {
|
|
225
|
+
issues.push({ code: 'invalid-id', path: `${itemPath}.id`, message: 'Every product entity needs a non-empty string id.' });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (seen.has(item.id)) {
|
|
229
|
+
issues.push({ code: 'duplicate-id', path: `${itemPath}.id`, message: `Product id "${item.id}" is duplicated.` });
|
|
230
|
+
}
|
|
231
|
+
seen.add(item.id);
|
|
232
|
+
const record: Record<string, unknown> | undefined = isRecord(item) ? item : undefined;
|
|
233
|
+
if (!record) return;
|
|
234
|
+
for (const key of shape.strings ?? []) {
|
|
235
|
+
if (typeof record[key] !== 'string') issues.push({ code: 'invalid-field', path: `${itemPath}.${key}`, message: `${itemPath}.${key} must be a string.` });
|
|
236
|
+
}
|
|
237
|
+
for (const key of shape.arrays ?? []) {
|
|
238
|
+
const candidate = record[key];
|
|
239
|
+
if (!Array.isArray(candidate) || !candidate.every(value => typeof value === 'string')) {
|
|
240
|
+
issues.push({ code: 'invalid-field', path: `${itemPath}.${key}`, message: `${itemPath}.${key} must be an array of strings.` });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Validates the JSON boundary before review or generation consumes a plan. */
|
|
247
|
+
export function validateProductPlan(input: unknown): ProductPlanValidationResult {
|
|
248
|
+
const issues: ProductPlanValidationIssue[] = [];
|
|
249
|
+
if (!isRecord(input)) return { ok: false, issues: [{ code: 'invalid-root', path: '', message: 'Product plan must be a JSON object.' }] };
|
|
250
|
+
if (input['schemaVersion'] !== PRODUCT_PLAN_SCHEMA_VERSION) {
|
|
251
|
+
issues.push({ code: 'invalid-version', path: 'schemaVersion', message: `Expected ${PRODUCT_PLAN_SCHEMA_VERSION}.` });
|
|
252
|
+
}
|
|
253
|
+
if (!isRecord(input['brief'])) {
|
|
254
|
+
issues.push({ code: 'invalid-root', path: 'brief', message: 'Product plan brief must be an object.' });
|
|
255
|
+
} else {
|
|
256
|
+
const brief = input['brief'] as Record<string, unknown>;
|
|
257
|
+
for (const key of ['id', 'name', 'description', 'primaryOutcome', 'primaryAudience'] as const) {
|
|
258
|
+
if (typeof brief[key] !== 'string') issues.push({ code: 'invalid-field', path: `brief.${key}`, message: `brief.${key} must be a string.` });
|
|
259
|
+
}
|
|
260
|
+
for (const key of ['constraints', 'nonGoals', 'assumptions'] as const) {
|
|
261
|
+
const candidate = brief[key];
|
|
262
|
+
if (!Array.isArray(candidate) || !candidate.every((value: unknown) => typeof value === 'string')) issues.push({ code: 'invalid-field', path: `brief.${key}`, message: `brief.${key} must be an array of strings.` });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const shapes: Record<string, EntityShape> = {
|
|
266
|
+
roles: { strings: ['name'], arrays: ['permissions'] },
|
|
267
|
+
workflows: { strings: ['name', 'trigger', 'goal', 'priority', 'frequency'], arrays: ['actorRoleIds', 'steps', 'successCriteria', 'exceptions'] },
|
|
268
|
+
resources: { strings: ['name', 'label'], arrays: ['roleIds', 'workflowIds'] },
|
|
269
|
+
actions: { strings: ['label', 'workflowId'], arrays: ['roleIds'] },
|
|
270
|
+
surfaces: { strings: ['name', 'kind', 'purpose'], arrays: ['workflowIds', 'resourceIds', 'roleIds', 'actionIds', 'sections', 'states'] },
|
|
271
|
+
menu: { strings: ['label', 'placement'], arrays: ['workflowIds', 'roleIds'] },
|
|
272
|
+
};
|
|
273
|
+
for (const key of ['roles', 'workflows', 'resources', 'actions', 'surfaces', 'menu'] as const) {
|
|
274
|
+
// IDs are scoped to their entity collection. Cross-entity references are
|
|
275
|
+
// explicit (for example resourceIds and workflowIds), so a menu entry may
|
|
276
|
+
// intentionally share a label-like ID with its target resource or surface.
|
|
277
|
+
pushEntityIssues(issues, input[key], key, new Set<string>(), shapes[key]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const workflows = input['workflows'];
|
|
281
|
+
if (Array.isArray(workflows)) workflows.forEach((workflow, index) => {
|
|
282
|
+
if (!isRecord(workflow)) return;
|
|
283
|
+
if (typeof workflow['priority'] === 'string') pushEnumIssue(issues, workflow['priority'], 'workflows.' + index + '.priority', PRODUCT_PRIORITIES);
|
|
284
|
+
if (typeof workflow['frequency'] === 'string') pushEnumIssue(issues, workflow['frequency'], 'workflows.' + index + '.frequency', PRODUCT_FREQUENCIES);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const resources = input['resources'];
|
|
288
|
+
if (Array.isArray(resources)) resources.forEach((resource, index) => {
|
|
289
|
+
if (!isRecord(resource)) return;
|
|
290
|
+
pushProductFieldIssues(issues, resource['fields'], 'resources.' + index + '.fields');
|
|
291
|
+
if (resource['menuPlacement'] !== undefined) pushEnumIssue(issues, resource['menuPlacement'], 'resources.' + index + '.menuPlacement', PRODUCT_PLACEMENTS);
|
|
292
|
+
if (resource['internal'] !== undefined && typeof resource['internal'] !== 'boolean') {
|
|
293
|
+
issues.push({ code: 'invalid-field', path: 'resources.' + index + '.internal', message: 'resources.' + index + '.internal must be a boolean when provided.' });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const actions = input['actions'];
|
|
298
|
+
if (Array.isArray(actions)) actions.forEach((action, index) => {
|
|
299
|
+
if (!isRecord(action)) return;
|
|
300
|
+
if (action['disclosure'] !== undefined) pushEnumIssue(issues, action['disclosure'], 'actions.' + index + '.disclosure', PRODUCT_DISCLOSURES);
|
|
301
|
+
if (action['destructive'] !== undefined && typeof action['destructive'] !== 'boolean') {
|
|
302
|
+
issues.push({ code: 'invalid-field', path: 'actions.' + index + '.destructive', message: 'actions.' + index + '.destructive must be a boolean when provided.' });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const surfaces = input['surfaces'];
|
|
307
|
+
if (Array.isArray(surfaces)) surfaces.forEach((surface, index) => {
|
|
308
|
+
if (!isRecord(surface)) return;
|
|
309
|
+
if (typeof surface['kind'] === 'string') pushEnumIssue(issues, surface['kind'], 'surfaces.' + index + '.kind', PRODUCT_PAGE_KINDS);
|
|
310
|
+
if (surface['menuPlacement'] !== undefined) pushEnumIssue(issues, surface['menuPlacement'], 'surfaces.' + index + '.menuPlacement', PRODUCT_PLACEMENTS);
|
|
311
|
+
if (surface['route'] !== undefined && (typeof surface['route'] !== 'string' || surface['route'].trim().length === 0)) {
|
|
312
|
+
issues.push({ code: 'invalid-field', path: 'surfaces.' + index + '.route', message: 'surfaces.' + index + '.route must be a non-empty string when provided.' });
|
|
313
|
+
}
|
|
314
|
+
const states = surface['states'];
|
|
315
|
+
if (Array.isArray(states)) states.forEach((state, stateIndex) => {
|
|
316
|
+
if (typeof state === 'string') pushEnumIssue(issues, state, 'surfaces.' + index + '.states.' + stateIndex, PRODUCT_STATES);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const menu = input['menu'];
|
|
321
|
+
if (Array.isArray(menu)) menu.forEach((entry, index) => {
|
|
322
|
+
if (!isRecord(entry)) return;
|
|
323
|
+
if (typeof entry['placement'] === 'string') pushEnumIssue(issues, entry['placement'], 'menu.' + index + '.placement', PRODUCT_PLACEMENTS);
|
|
324
|
+
for (const key of ['href', 'parentId', 'surfaceId', 'resourceId', 'condition'] as const) {
|
|
325
|
+
if (entry[key] !== undefined && typeof entry[key] !== 'string') {
|
|
326
|
+
issues.push({ code: 'invalid-field', path: 'menu.' + index + '.' + key, message: 'menu.' + index + '.' + key + ' must be a string when provided.' });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (entry['order'] !== undefined && (typeof entry['order'] !== 'number' || !Number.isFinite(entry['order']))) {
|
|
330
|
+
issues.push({ code: 'invalid-field', path: 'menu.' + index + '.order', message: 'menu.' + index + '.order must be a finite number when provided.' });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return issues.length === 0
|
|
335
|
+
? { ok: true, value: input as unknown as ProductPlan }
|
|
336
|
+
: { ok: false, issues };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export class ProductPlanValidationError extends Error {
|
|
340
|
+
readonly issues: readonly ProductPlanValidationIssue[];
|
|
341
|
+
|
|
342
|
+
constructor(issues: readonly ProductPlanValidationIssue[]) {
|
|
343
|
+
super(`Invalid product plan (${issues.length} issue${issues.length === 1 ? '' : 's'})`);
|
|
344
|
+
this.name = 'ProductPlanValidationError';
|
|
345
|
+
this.issues = issues;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function parseProductPlan(input: unknown): ProductPlan {
|
|
350
|
+
const result = validateProductPlan(input);
|
|
351
|
+
if (!result.ok) throw new ProductPlanValidationError(result.issues);
|
|
352
|
+
return result.value;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface ProductNavigationNode extends ProductMenuNode {
|
|
356
|
+
readonly children: readonly ProductNavigationNode[];
|
|
357
|
+
readonly visible: boolean;
|
|
358
|
+
readonly reason?: string;
|
|
359
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ProductPlan, ProductSurface } from './product-plan';
|
|
2
|
+
|
|
3
|
+
export type ProductReviewSeverity = 'error' | 'warning' | 'info';
|
|
4
|
+
|
|
5
|
+
export interface ProductReviewIssue {
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly severity: ProductReviewSeverity;
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly path?: string;
|
|
10
|
+
readonly suggestion?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ProductReviewMetrics {
|
|
14
|
+
readonly resourceCount: number;
|
|
15
|
+
readonly workflowCount: number;
|
|
16
|
+
readonly surfaceCount: number;
|
|
17
|
+
readonly menuNodeCount: number;
|
|
18
|
+
readonly orphanResourceCount: number;
|
|
19
|
+
readonly unservedWorkflowCount: number;
|
|
20
|
+
readonly roleVisibilityGaps: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProductReviewReport {
|
|
24
|
+
readonly ok: boolean;
|
|
25
|
+
readonly issues: readonly ProductReviewIssue[];
|
|
26
|
+
readonly metrics: ProductReviewMetrics;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function has<T>(set: ReadonlySet<T>, value: T): boolean { return set.has(value); }
|
|
30
|
+
function addIssue(issues: ProductReviewIssue[], issue: ProductReviewIssue): void { issues.push(issue); }
|
|
31
|
+
|
|
32
|
+
export function reviewProductPlan(plan: ProductPlan): ProductReviewReport {
|
|
33
|
+
const issues: ProductReviewIssue[] = [];
|
|
34
|
+
const roleIds = new Set(plan.roles.map(role => role.id));
|
|
35
|
+
const workflowIds = new Set(plan.workflows.map(workflow => workflow.id));
|
|
36
|
+
const resourceIds = new Set(plan.resources.map(resource => resource.id));
|
|
37
|
+
const surfaceIds = new Set(plan.surfaces.map(surface => surface.id));
|
|
38
|
+
const actionIds = new Set(plan.actions.map(action => action.id));
|
|
39
|
+
|
|
40
|
+
for (const workflow of plan.workflows) {
|
|
41
|
+
if (!workflow.actorRoleIds.length) addIssue(issues, { code: 'workflow.missing-actor', severity: 'error', message: `Workflow "${workflow.name}" has no actor role.`, path: `workflows.${workflow.id}.actorRoleIds` });
|
|
42
|
+
for (const roleId of workflow.actorRoleIds) if (!has(roleIds, roleId)) addIssue(issues, { code: 'workflow.unknown-role', severity: 'error', message: `Workflow "${workflow.name}" references unknown role "${roleId}".`, path: `workflows.${workflow.id}.actorRoleIds` });
|
|
43
|
+
if (!workflow.goal.trim()) addIssue(issues, { code: 'workflow.missing-goal', severity: 'error', message: `Workflow "${workflow.name}" has no user goal.`, path: `workflows.${workflow.id}.goal` });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const resource of plan.resources) {
|
|
47
|
+
if (!resource.workflowIds.length && !resource.internal) addIssue(issues, { code: 'resource.no-workflow', severity: 'warning', message: `Resource "${resource.label}" is not attached to a workflow.`, path: `resources.${resource.id}.workflowIds`, suggestion: 'Make it contextual/internal or attach it to a real task.' });
|
|
48
|
+
for (const workflowId of resource.workflowIds) if (!has(workflowIds, workflowId)) addIssue(issues, { code: 'resource.unknown-workflow', severity: 'error', message: `Resource "${resource.label}" references unknown workflow "${workflowId}".`, path: `resources.${resource.id}.workflowIds` });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const surface of plan.surfaces) {
|
|
52
|
+
reviewSurface(surface, { workflowIds, actionIds, resourceIds }, issues);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const menuById = new Map(plan.menu.map(item => [item.id, item] as const));
|
|
56
|
+
for (const item of plan.menu) {
|
|
57
|
+
if (item.parentId !== undefined && !menuById.has(item.parentId)) {
|
|
58
|
+
addIssue(issues, { code: 'menu.unknown-parent', severity: 'error', message: `Menu item "${item.label}" references unknown parent "${item.parentId}".`, path: `menu.${item.id}.parentId` });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const visiting = new Set<string>();
|
|
62
|
+
const visited = new Set<string>();
|
|
63
|
+
const visitMenu = (id: string): void => {
|
|
64
|
+
if (visiting.has(id)) {
|
|
65
|
+
addIssue(issues, { code: 'menu.cycle', severity: 'error', message: `Menu hierarchy contains a cycle at "${id}".`, path: `menu.${id}.parentId` });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (visited.has(id)) return;
|
|
69
|
+
visiting.add(id);
|
|
70
|
+
const parentId = menuById.get(id)?.parentId;
|
|
71
|
+
if (parentId !== undefined && menuById.has(parentId)) visitMenu(parentId);
|
|
72
|
+
visiting.delete(id);
|
|
73
|
+
visited.add(id);
|
|
74
|
+
};
|
|
75
|
+
for (const item of plan.menu) visitMenu(item.id);
|
|
76
|
+
|
|
77
|
+
for (const item of plan.menu) {
|
|
78
|
+
if (!item.workflowIds.length && !item.resourceId && !item.surfaceId) addIssue(issues, { code: 'menu.no-business-target', severity: 'warning', message: `Menu item "${item.label}" has no workflow, resource or surface target.`, path: `menu.${item.id}`, suggestion: 'Keep it only for an intentional external/system entry.' });
|
|
79
|
+
if (item.surfaceId && !has(surfaceIds, item.surfaceId)) addIssue(issues, { code: 'menu.unknown-surface', severity: 'error', message: `Menu item "${item.label}" references unknown surface.`, path: `menu.${item.id}.surfaceId` });
|
|
80
|
+
if (item.resourceId && !has(resourceIds, item.resourceId)) addIssue(issues, { code: 'menu.unknown-resource', severity: 'error', message: `Menu item "${item.label}" references unknown resource.`, path: `menu.${item.id}.resourceId` });
|
|
81
|
+
if (!item.roleIds.length && item.placement !== 'hidden') addIssue(issues, { code: 'menu.public-entry', severity: 'info', message: `Menu item "${item.label}" is public at the UI hint layer.`, path: `menu.${item.id}.roleIds`, suggestion: 'Keep server authorization independent from menu visibility.' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const action of plan.actions) {
|
|
85
|
+
if (!has(workflowIds, action.workflowId)) addIssue(issues, { code: 'action.unknown-workflow', severity: 'error', message: `Action "${action.label}" references unknown workflow.`, path: `actions.${action.id}.workflowId` });
|
|
86
|
+
if (!action.roleIds.length) addIssue(issues, { code: 'action.missing-roles', severity: 'warning', message: `Action "${action.label}" has no role visibility contract.`, path: `actions.${action.id}.roleIds` });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const served = new Set<string>();
|
|
90
|
+
plan.surfaces.forEach(surface => surface.workflowIds.forEach(id => served.add(id)));
|
|
91
|
+
plan.menu.forEach(item => item.workflowIds.forEach(id => served.add(id)));
|
|
92
|
+
const unserved = plan.workflows.filter(workflow => !served.has(workflow.id));
|
|
93
|
+
unserved.forEach(workflow => addIssue(issues, { code: 'workflow.unserved', severity: workflow.priority === 'core' ? 'error' : 'warning', message: `Workflow "${workflow.name}" has no page or menu entry.`, path: `workflows.${workflow.id}`, suggestion: 'Add a surface or contextual entry, or mark the workflow as deferred.' }));
|
|
94
|
+
|
|
95
|
+
const roleVisibilityGaps = plan.workflows.filter(workflow => workflow.actorRoleIds.length > 0 && !plan.surfaces.some(surface => surface.workflowIds.includes(workflow.id) && (surface.roleIds.length === 0 || workflow.actorRoleIds.some(roleId => surface.roleIds.includes(roleId))))).length;
|
|
96
|
+
const orphanResourceCount = plan.resources.filter(resource => resource.workflowIds.length === 0 && !resource.internal).length;
|
|
97
|
+
const errors = issues.filter(issue => issue.severity === 'error');
|
|
98
|
+
return {
|
|
99
|
+
ok: errors.length === 0,
|
|
100
|
+
issues,
|
|
101
|
+
metrics: {
|
|
102
|
+
resourceCount: plan.resources.length,
|
|
103
|
+
workflowCount: plan.workflows.length,
|
|
104
|
+
surfaceCount: plan.surfaces.length,
|
|
105
|
+
menuNodeCount: plan.menu.length,
|
|
106
|
+
orphanResourceCount,
|
|
107
|
+
unservedWorkflowCount: unserved.length,
|
|
108
|
+
roleVisibilityGaps,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function reviewSurface(surface: ProductSurface, refs: { workflowIds: ReadonlySet<string>; actionIds: ReadonlySet<string>; resourceIds: ReadonlySet<string> }, issues: ProductReviewIssue[]): void {
|
|
114
|
+
if (!surface.purpose.trim()) addIssue(issues, { code: 'surface.missing-purpose', severity: 'error', message: `Surface "${surface.name}" has no purpose.`, path: `surfaces.${surface.id}.purpose` });
|
|
115
|
+
if (!surface.workflowIds.length) addIssue(issues, { code: 'surface.no-workflow', severity: 'warning', message: `Surface "${surface.name}" is not attached to a workflow.`, path: `surfaces.${surface.id}.workflowIds` });
|
|
116
|
+
surface.workflowIds.forEach(id => { if (!refs.workflowIds.has(id)) addIssue(issues, { code: 'surface.unknown-workflow', severity: 'error', message: `Surface "${surface.name}" references unknown workflow "${id}".`, path: `surfaces.${surface.id}.workflowIds` }); });
|
|
117
|
+
surface.resourceIds.forEach(id => { if (!refs.resourceIds.has(id)) addIssue(issues, { code: 'surface.unknown-resource', severity: 'error', message: `Surface "${surface.name}" references unknown resource "${id}".`, path: `surfaces.${surface.id}.resourceIds` }); });
|
|
118
|
+
surface.actionIds.forEach(id => { if (!refs.actionIds.has(id)) addIssue(issues, { code: 'surface.unknown-action', severity: 'error', message: `Surface "${surface.name}" references unknown action "${id}".`, path: `surfaces.${surface.id}.actionIds` }); });
|
|
119
|
+
if (surface.primaryActionId && !refs.actionIds.has(surface.primaryActionId)) addIssue(issues, { code: 'surface.unknown-primary-action', severity: 'error', message: `Surface "${surface.name}" references unknown primary action.`, path: `surfaces.${surface.id}.primaryActionId` });
|
|
120
|
+
if (!surface.states.length) addIssue(issues, { code: 'surface.missing-states', severity: 'warning', message: `Surface "${surface.name}" does not declare empty/loading/error/permission states.`, path: `surfaces.${surface.id}.states`, suggestion: 'Declare the states the Svelte view must implement.' });
|
|
121
|
+
if (surface.sections.length > 6) addIssue(issues, { code: 'surface.section-density', severity: 'warning', message: `Surface "${surface.name}" contains ${surface.sections.length} sections.`, path: `surfaces.${surface.id}.sections`, suggestion: 'Group related information or use progressive disclosure.' });
|
|
122
|
+
if (surface.actionIds.length > 4) addIssue(issues, { code: 'surface.action-density', severity: 'warning', message: `Surface "${surface.name}" exposes ${surface.actionIds.length} actions.`, path: `surfaces.${surface.id}.actionIds`, suggestion: 'Keep one primary action and move secondary actions into context or More.' });
|
|
123
|
+
}
|