@sketchscreens/core-schema 0.1.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/LICENSE +21 -0
- package/dist/audit.d.ts +28 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +42 -0
- package/dist/audit.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +625 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +231 -0
- package/dist/schema.js.map +1 -0
- package/dist/validate.d.ts +36 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +111 -0
- package/dist/validate.js.map +1 -0
- package/package.json +40 -0
- package/src/audit.ts +67 -0
- package/src/index.ts +47 -0
- package/src/schema.ts +256 -0
- package/src/validate.ts +165 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The SketchScreens contract.
|
|
5
|
+
*
|
|
6
|
+
* One JSON shape, produced by the extractor and consumed by the renderer.
|
|
7
|
+
* It is deliberately stack-agnostic: a Next.js screen and a Flutter screen
|
|
8
|
+
* both reduce to the same `ScreenSpec`, so the renderer draws them identically.
|
|
9
|
+
*
|
|
10
|
+
* Design note: `ElementType` is a CLOSED enum on purpose. Each value maps 1:1
|
|
11
|
+
* onto a renderable hand-drawn primitive (mostly wired-elements). A closed set
|
|
12
|
+
* keeps every map renderable and stops the LLM extractor from inventing element
|
|
13
|
+
* types the renderer can't draw.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Elements — the things that appear on a screen, in top-to-bottom order.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** The closed set of wireframe primitives. Each maps to a renderable widget. */
|
|
21
|
+
export const ElementType = z.enum([
|
|
22
|
+
"heading", // a title / section header
|
|
23
|
+
"text", // a paragraph or helper/caption text
|
|
24
|
+
"input", // a single-line text field
|
|
25
|
+
"textarea", // a multi-line text field
|
|
26
|
+
"button", // a clickable button (may own a nav edge via its label)
|
|
27
|
+
"checkbox", // a single checkbox
|
|
28
|
+
"radio", // a radio option (usually one of a group)
|
|
29
|
+
"toggle", // an on/off switch
|
|
30
|
+
"select", // a dropdown / combo
|
|
31
|
+
"listbox", // a scrollable option list
|
|
32
|
+
"card", // a bordered container / tile
|
|
33
|
+
"image", // an image or avatar placeholder
|
|
34
|
+
"list", // a repeated row list (feeds, tables-as-list)
|
|
35
|
+
"divider", // a visual separator
|
|
36
|
+
"nav", // a nav bar / tab bar / menu
|
|
37
|
+
"tabs", // a tab strip
|
|
38
|
+
]);
|
|
39
|
+
export type ElementType = z.infer<typeof ElementType>;
|
|
40
|
+
|
|
41
|
+
/** Optional visual role for buttons, so the renderer can vary emphasis. */
|
|
42
|
+
export const ElementVariant = z.enum([
|
|
43
|
+
"primary",
|
|
44
|
+
"secondary",
|
|
45
|
+
"destructive",
|
|
46
|
+
"ghost",
|
|
47
|
+
"link",
|
|
48
|
+
]);
|
|
49
|
+
export type ElementVariant = z.infer<typeof ElementVariant>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Which vertical band of the screen an element sits in. `top` = a header / nav
|
|
53
|
+
* bar pinned to the top; `bottom` = a footer / sticky action bar; `main` = the
|
|
54
|
+
* scrollable body (the default). Lets the sketch mirror the real screen's
|
|
55
|
+
* coarse layout without pixel positions.
|
|
56
|
+
*/
|
|
57
|
+
export const ElementRegion = z.enum(["top", "main", "bottom"]);
|
|
58
|
+
export type ElementRegion = z.infer<typeof ElementRegion>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Horizontal placement of an element within its band. `full` stretches edge to
|
|
62
|
+
* edge (inputs, nav bars, lists); `center` is a centered block (a hero button,
|
|
63
|
+
* a centered card); `left`/`right` hug a side. Default is `full` for fields and
|
|
64
|
+
* `left` for text.
|
|
65
|
+
*/
|
|
66
|
+
export const ElementAlign = z.enum(["left", "center", "right", "full"]);
|
|
67
|
+
export type ElementAlign = z.infer<typeof ElementAlign>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One UI element on a screen. `label` is the visible text (button caption,
|
|
71
|
+
* field label, heading text). Order in the `elements` array = top-to-bottom
|
|
72
|
+
* position in the sketch (structural fidelity, not pixel-exact).
|
|
73
|
+
*/
|
|
74
|
+
export const ScreenElement = z.object({
|
|
75
|
+
type: ElementType,
|
|
76
|
+
/** Visible text: caption, label, or heading. Optional for dividers/images. */
|
|
77
|
+
label: z.string().optional(),
|
|
78
|
+
/** Placeholder / hint text for inputs. */
|
|
79
|
+
placeholder: z.string().optional(),
|
|
80
|
+
/** Visual emphasis (mainly for buttons). */
|
|
81
|
+
variant: ElementVariant.optional(),
|
|
82
|
+
/** True for password / masked inputs. */
|
|
83
|
+
secure: z.boolean().optional(),
|
|
84
|
+
/** True if the element is marked required in the source. */
|
|
85
|
+
required: z.boolean().optional(),
|
|
86
|
+
/**
|
|
87
|
+
* Optional grouping key. Elements sharing a group render inside one
|
|
88
|
+
* card/section, preserving the source's visual grouping.
|
|
89
|
+
*/
|
|
90
|
+
group: z.string().optional(),
|
|
91
|
+
/**
|
|
92
|
+
* Which band of the screen this element sits in (top nav / main / bottom
|
|
93
|
+
* bar). Defaults to "main". Lets the sketch mirror the real layout.
|
|
94
|
+
*/
|
|
95
|
+
region: ElementRegion.optional(),
|
|
96
|
+
/**
|
|
97
|
+
* Horizontal placement within the band. Defaults sensibly by type (fields
|
|
98
|
+
* full-width, text left). Set "center" for a centered hero button/card.
|
|
99
|
+
*/
|
|
100
|
+
align: ElementAlign.optional(),
|
|
101
|
+
/**
|
|
102
|
+
* Whether `label` is the app's verbatim text or the extractor's SHAPE
|
|
103
|
+
* description of a dynamic region (a list/table it couldn't read literally).
|
|
104
|
+
* Defaults to "verbatim". The renderer styles "descriptive" labels distinctly
|
|
105
|
+
* so a reviewer sees "this is the tool's summary, not the app's words."
|
|
106
|
+
*/
|
|
107
|
+
labelKind: z.enum(["verbatim", "descriptive"]).optional(),
|
|
108
|
+
/** Free-form note the extractor wants to surface (e.g. "conditionally shown"). */
|
|
109
|
+
note: z.string().optional(),
|
|
110
|
+
});
|
|
111
|
+
export type ScreenElement = z.infer<typeof ScreenElement>;
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Screens
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** One screen = one node in the map. */
|
|
118
|
+
export const ScreenSpec = z.object({
|
|
119
|
+
/** Stable id, unique within the map. Referenced by edges. */
|
|
120
|
+
id: z.string().min(1),
|
|
121
|
+
/** Human-friendly screen name (e.g. "Login"). */
|
|
122
|
+
name: z.string().min(1),
|
|
123
|
+
/**
|
|
124
|
+
* The route or address of this screen: a URL path (`/settings/profile`),
|
|
125
|
+
* a Flutter widget class (`SettingsScreen`), or an API path group. Optional
|
|
126
|
+
* because not every screen is addressable (e.g. a modal).
|
|
127
|
+
*/
|
|
128
|
+
route: z.string().optional(),
|
|
129
|
+
/** Repo-relative path of the file this screen was extracted from. */
|
|
130
|
+
sourceFile: z.string().optional(),
|
|
131
|
+
/** The elements on this screen, in top-to-bottom order. */
|
|
132
|
+
elements: z.array(ScreenElement).default([]),
|
|
133
|
+
/** Optional short description / purpose of the screen. */
|
|
134
|
+
description: z.string().optional(),
|
|
135
|
+
/**
|
|
136
|
+
* Optional label-path for this screen's section, most-general first, using
|
|
137
|
+
* " › " as the separator — e.g. "Settings › AI Settings". Groups cluster and
|
|
138
|
+
* label screens; the renderer nests group labels. Deriveable from the route.
|
|
139
|
+
* (For the overall journey SHAPE, prefer `parent` below.)
|
|
140
|
+
*/
|
|
141
|
+
group: z.string().optional(),
|
|
142
|
+
/**
|
|
143
|
+
* Optional id of the screen this one hangs beneath in the JOURNEY tree — the
|
|
144
|
+
* screen a user reaches this one *from*. This is what roots the map as a real
|
|
145
|
+
* flow: an entry screen has no parent; the auth screen's parent is the entry;
|
|
146
|
+
* each feature-section hub's parent is the dashboard; etc. When set, it
|
|
147
|
+
* overrides route-derived nesting so the tree matches how users actually
|
|
148
|
+
* navigate. Must reference another screen's `id`.
|
|
149
|
+
*/
|
|
150
|
+
parent: z.string().optional(),
|
|
151
|
+
/**
|
|
152
|
+
* Optional hint that this screen is the app's entry point / root of the
|
|
153
|
+
* journey (what the user sees first). At most one screen should set this; the
|
|
154
|
+
* renderer roots the tree here. If none is set, the renderer infers a root.
|
|
155
|
+
*/
|
|
156
|
+
isEntry: z.boolean().optional(),
|
|
157
|
+
/**
|
|
158
|
+
* How this screen is presented. "screen" (default) is a full page/route; the
|
|
159
|
+
* others are overlays. The renderer draws non-"screen" presentations with a
|
|
160
|
+
* distinct overlay frame and attaches them to their opener via an edge rather
|
|
161
|
+
* than the journey backbone.
|
|
162
|
+
*/
|
|
163
|
+
presentation: z.enum(["screen", "modal", "drawer", "sheet"]).optional(),
|
|
164
|
+
/**
|
|
165
|
+
* Optional state variant this screen represents (an empty/error/loading view
|
|
166
|
+
* of another screen). The renderer can cluster it beside its base screen.
|
|
167
|
+
*/
|
|
168
|
+
state: z.enum(["default", "empty", "error", "loading"]).optional(),
|
|
169
|
+
});
|
|
170
|
+
export type ScreenSpec = z.infer<typeof ScreenSpec>;
|
|
171
|
+
|
|
172
|
+
/** Split a group path ("Settings › AI Settings") into its segments. */
|
|
173
|
+
export function groupSegments(group: string | undefined): string[] {
|
|
174
|
+
if (!group) return [];
|
|
175
|
+
return group
|
|
176
|
+
.split("›")
|
|
177
|
+
.map((s) => s.trim())
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Edges — navigation between screens
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/** A navigation transition from one screen to another. */
|
|
186
|
+
export const Edge = z.object({
|
|
187
|
+
/** Source screen id. */
|
|
188
|
+
from: z.string().min(1),
|
|
189
|
+
/** Destination screen id. */
|
|
190
|
+
to: z.string().min(1),
|
|
191
|
+
/**
|
|
192
|
+
* What triggers the transition — usually a button/link label
|
|
193
|
+
* ("Continue", "Forgot?"). Rendered as the edge label.
|
|
194
|
+
*/
|
|
195
|
+
trigger: z.string().optional(),
|
|
196
|
+
/** How the transition is expressed, if useful (e.g. "redirect", "push", "link"). */
|
|
197
|
+
kind: z.string().optional(),
|
|
198
|
+
});
|
|
199
|
+
export type Edge = z.infer<typeof Edge>;
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// The map
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Which surface this map represents. Each surface is its own map — a web app,
|
|
207
|
+
* a mobile app, a CRM, and a backend are mapped separately, not tangled into one.
|
|
208
|
+
*/
|
|
209
|
+
export const Surface = z.enum([
|
|
210
|
+
"web",
|
|
211
|
+
"mobile",
|
|
212
|
+
"crm",
|
|
213
|
+
"admin",
|
|
214
|
+
"backend",
|
|
215
|
+
"desktop",
|
|
216
|
+
"other",
|
|
217
|
+
]);
|
|
218
|
+
export type Surface = z.infer<typeof Surface>;
|
|
219
|
+
|
|
220
|
+
/** Optional provenance / metadata about how this map was produced. */
|
|
221
|
+
export const ProjectMapMeta = z.object({
|
|
222
|
+
/** The tool version that produced the map. */
|
|
223
|
+
generator: z.string().optional(),
|
|
224
|
+
/** Repo-relative or absolute root the map was extracted from. */
|
|
225
|
+
repoRoot: z.string().optional(),
|
|
226
|
+
/** ISO timestamp string (set by the producer; schema stays time-agnostic). */
|
|
227
|
+
generatedAt: z.string().optional(),
|
|
228
|
+
/** How elements were derived: "agent", "static", or "hybrid". */
|
|
229
|
+
extraction: z.enum(["agent", "static", "hybrid", "manual"]).optional(),
|
|
230
|
+
});
|
|
231
|
+
export type ProjectMapMeta = z.infer<typeof ProjectMapMeta>;
|
|
232
|
+
|
|
233
|
+
/** The contract version this build of SketchScreens speaks. */
|
|
234
|
+
export const CONTRACT_VERSION = 1;
|
|
235
|
+
|
|
236
|
+
/** The whole thing: one surface's screens + the flow between them. */
|
|
237
|
+
export const ProjectMap = z.object({
|
|
238
|
+
/**
|
|
239
|
+
* Contract version. Accepts any positive integer so an OLD renderer reading a
|
|
240
|
+
* NEWER map fails with a clear "upgrade" message (see validate.ts) rather than
|
|
241
|
+
* a generic zod "invalid literal". New fields are additive; bump only on a
|
|
242
|
+
* breaking change.
|
|
243
|
+
*/
|
|
244
|
+
version: z.number().int().min(1).default(CONTRACT_VERSION),
|
|
245
|
+
/** Display name for this map (e.g. "AiPhone 360 — Web App"). */
|
|
246
|
+
name: z.string().min(1),
|
|
247
|
+
/** Which surface this represents. */
|
|
248
|
+
surface: Surface,
|
|
249
|
+
/** The screens (nodes). */
|
|
250
|
+
screens: z.array(ScreenSpec).default([]),
|
|
251
|
+
/** The navigation transitions (edges). */
|
|
252
|
+
edges: z.array(Edge).default([]),
|
|
253
|
+
/** Optional provenance. */
|
|
254
|
+
meta: ProjectMapMeta.optional(),
|
|
255
|
+
});
|
|
256
|
+
export type ProjectMap = z.infer<typeof ProjectMap>;
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { ProjectMap, CONTRACT_VERSION } from "./schema.js";
|
|
2
|
+
|
|
3
|
+
/** How serious a validation issue is. Errors fail the gate; warnings don't. */
|
|
4
|
+
export type IssueSeverity = "error" | "warning";
|
|
5
|
+
|
|
6
|
+
/** A validation problem found in a ProjectMap. */
|
|
7
|
+
export interface ValidationIssue {
|
|
8
|
+
/** Machine-readable code. */
|
|
9
|
+
code:
|
|
10
|
+
// errors — the map is malformed and must not render
|
|
11
|
+
| "schema"
|
|
12
|
+
| "duplicate_screen_id"
|
|
13
|
+
| "edge_unknown_from"
|
|
14
|
+
| "edge_unknown_to"
|
|
15
|
+
| "self_edge"
|
|
16
|
+
| "parent_unknown"
|
|
17
|
+
| "parent_self"
|
|
18
|
+
| "parent_cycle"
|
|
19
|
+
// warnings — the map renders but something's off
|
|
20
|
+
| "no_screens"
|
|
21
|
+
| "entry_count"
|
|
22
|
+
| "duplicate_edge"
|
|
23
|
+
| "sourcefile_missing"
|
|
24
|
+
| "unsupported_version";
|
|
25
|
+
/** Error (fails the gate) or warning (renders anyway). Defaults to error. */
|
|
26
|
+
severity: IssueSeverity;
|
|
27
|
+
/** Human-readable message. */
|
|
28
|
+
message: string;
|
|
29
|
+
/** JSON-ish path to the offending value, when known. */
|
|
30
|
+
path?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ValidationResult {
|
|
34
|
+
/** True when there are no ERROR-severity issues (warnings don't fail it). */
|
|
35
|
+
ok: boolean;
|
|
36
|
+
/** The parsed map when the shape is valid (defaults applied), else undefined. */
|
|
37
|
+
map?: ProjectMap;
|
|
38
|
+
issues: ValidationIssue[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate a raw object as a ProjectMap.
|
|
43
|
+
*
|
|
44
|
+
* Runs the zod schema first (shape + enums + defaults), then referential
|
|
45
|
+
* checks that zod can't express: unique screen ids and edges that point at
|
|
46
|
+
* screens that actually exist. This is the gate the extractor's output must
|
|
47
|
+
* pass before the renderer will draw it.
|
|
48
|
+
*/
|
|
49
|
+
export function validateProjectMap(input: unknown): ValidationResult {
|
|
50
|
+
const parsed = ProjectMap.safeParse(input);
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
const issues: ValidationIssue[] = parsed.error.issues.map((i) => ({
|
|
53
|
+
code: "schema",
|
|
54
|
+
severity: "error",
|
|
55
|
+
message: i.message,
|
|
56
|
+
path: i.path.join("."),
|
|
57
|
+
}));
|
|
58
|
+
return { ok: false, issues };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const map = parsed.data;
|
|
62
|
+
const issues: ValidationIssue[] = [];
|
|
63
|
+
const err = (code: ValidationIssue["code"], message: string, path?: string) =>
|
|
64
|
+
issues.push({ code, severity: "error", message, path });
|
|
65
|
+
const warn = (code: ValidationIssue["code"], message: string, path?: string) =>
|
|
66
|
+
issues.push({ code, severity: "warning", message, path });
|
|
67
|
+
|
|
68
|
+
// A map from a newer contract version — render what we can, but flag it.
|
|
69
|
+
if (map.version > CONTRACT_VERSION) {
|
|
70
|
+
warn(
|
|
71
|
+
"unsupported_version",
|
|
72
|
+
`Map is contract version ${map.version} but this SketchScreens speaks ${CONTRACT_VERSION} — some things may not render. Upgrade SketchScreens.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A map with no screens renders as a blank canvas — warn rather than crash.
|
|
77
|
+
if (map.screens.length === 0) {
|
|
78
|
+
warn("no_screens", "The map has no screens.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Unique screen ids.
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
for (const screen of map.screens) {
|
|
84
|
+
if (seen.has(screen.id)) {
|
|
85
|
+
err("duplicate_screen_id", `Duplicate screen id "${screen.id}".`, `screens[id=${screen.id}]`);
|
|
86
|
+
}
|
|
87
|
+
seen.add(screen.id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// At most one entry screen; ideally exactly one (the renderer infers if 0).
|
|
91
|
+
const entryCount = map.screens.filter((s) => s.isEntry).length;
|
|
92
|
+
if (entryCount > 1) {
|
|
93
|
+
warn(
|
|
94
|
+
"entry_count",
|
|
95
|
+
`${entryCount} screens are marked isEntry; there should be exactly one (the app's first screen).`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Edges must reference existing screens, and not point a screen at itself.
|
|
100
|
+
const edgeKeys = new Set<string>();
|
|
101
|
+
map.edges.forEach((edge, idx) => {
|
|
102
|
+
if (!seen.has(edge.from)) {
|
|
103
|
+
err("edge_unknown_from", `Edge #${idx} references unknown source screen "${edge.from}".`, `edges[${idx}].from`);
|
|
104
|
+
}
|
|
105
|
+
if (!seen.has(edge.to)) {
|
|
106
|
+
err("edge_unknown_to", `Edge #${idx} references unknown target screen "${edge.to}".`, `edges[${idx}].to`);
|
|
107
|
+
}
|
|
108
|
+
if (edge.from === edge.to) {
|
|
109
|
+
err("self_edge", `Edge #${idx} points screen "${edge.from}" at itself.`, `edges[${idx}]`);
|
|
110
|
+
}
|
|
111
|
+
const key = `${edge.from}->${edge.to}`;
|
|
112
|
+
if (edgeKeys.has(key)) {
|
|
113
|
+
warn("duplicate_edge", `Edge #${idx} duplicates ${key}.`, `edges[${idx}]`);
|
|
114
|
+
}
|
|
115
|
+
edgeKeys.add(key);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Journey `parent` links: must reference an existing screen, not self, and
|
|
119
|
+
// must not form a cycle (walking parents from any screen must terminate).
|
|
120
|
+
const parentOf = new Map<string, string>();
|
|
121
|
+
for (const screen of map.screens) {
|
|
122
|
+
if (screen.parent === undefined) continue;
|
|
123
|
+
if (screen.parent === screen.id) {
|
|
124
|
+
err("parent_self", `Screen "${screen.id}" lists itself as its parent.`, `screens[id=${screen.id}].parent`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!seen.has(screen.parent)) {
|
|
128
|
+
err("parent_unknown", `Screen "${screen.id}" has unknown parent "${screen.parent}".`, `screens[id=${screen.id}].parent`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
parentOf.set(screen.id, screen.parent);
|
|
132
|
+
}
|
|
133
|
+
// Cycle detection over the parent chains.
|
|
134
|
+
for (const start of parentOf.keys()) {
|
|
135
|
+
const visited = new Set<string>([start]);
|
|
136
|
+
let cur = parentOf.get(start);
|
|
137
|
+
while (cur !== undefined) {
|
|
138
|
+
if (visited.has(cur)) {
|
|
139
|
+
err("parent_cycle", `Parent chain from "${start}" forms a cycle at "${cur}".`, `screens[id=${start}].parent`);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
visited.add(cur);
|
|
143
|
+
cur = parentOf.get(cur);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The gate fails only on ERROR-severity issues; warnings render anyway.
|
|
148
|
+
const ok = !issues.some((i) => i.severity === "error");
|
|
149
|
+
return { ok, map, issues };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Like {@link validateProjectMap} but throws on failure. Useful in scripts
|
|
154
|
+
* and tests where an invalid map should hard-stop.
|
|
155
|
+
*/
|
|
156
|
+
export function parseProjectMap(input: unknown): ProjectMap {
|
|
157
|
+
const result = validateProjectMap(input);
|
|
158
|
+
if (!result.ok || !result.map) {
|
|
159
|
+
const detail = result.issues
|
|
160
|
+
.map((i) => ` - [${i.code}] ${i.message}${i.path ? ` (${i.path})` : ""}`)
|
|
161
|
+
.join("\n");
|
|
162
|
+
throw new Error(`Invalid ProjectMap:\n${detail}`);
|
|
163
|
+
}
|
|
164
|
+
return result.map;
|
|
165
|
+
}
|