@svadmin/core 0.60.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 +43 -0
- 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
|
@@ -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';
|
|
@@ -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
|
+
}
|