@plannotator/pi-extension 0.28.48 → 0.28.49
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/generated/config.ts +117 -17
- package/generated/guide-viewer-manifest.ts +2 -2
- package/generated/inbox/connection.ts +54 -1
- package/generated/inbox/project-key.ts +10 -0
- package/generated/shape/core.ts +1070 -0
- package/generated/shape/format.ts +5 -0
- package/generated/shape/routes.ts +283 -0
- package/generated/shape/store.ts +687 -0
- package/generated/tailnet-audience.ts +13 -6
- package/generated/ulid.ts +21 -0
- package/package.json +1 -1
- package/plannotator.html +431 -425
- package/release.json +1 -1
- package/review-editor.html +406 -400
- package/server/code-nav-runtime.ts +47 -0
- package/server/serverReview.ts +2 -42
- package/server/serverShape.test.ts +147 -0
- package/server/serverShape.ts +118 -0
- package/skills/plannotator/SKILL.md +9 -0
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/core/shape.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shape: the format of a board an agent draws, and the rules on it.
|
|
4
|
+
*
|
|
5
|
+
* A shape is one thing being worked out with one agent session. Each time the
|
|
6
|
+
* agent draws it, the store keeps a full snapshot: a version. A version holds
|
|
7
|
+
* four views (Picture, How it works, Requirements, Code), the numbered
|
|
8
|
+
* references the views cite, and the parts the person can point at.
|
|
9
|
+
*
|
|
10
|
+
* Browser-safe and dependency-free: the store (packages/shared/shape), the
|
|
11
|
+
* CLI and the board's UI (packages/ui/components/shape) all read it.
|
|
12
|
+
*
|
|
13
|
+
* - `checkShapeBoard` validates what an agent writes (`plannotator shape
|
|
14
|
+
* update --file`) and names the exact field that is wrong.
|
|
15
|
+
* - `shapeParts` lists the parts of a board: every `data-mark` of a drawn
|
|
16
|
+
* view, every requirement, every Code step, the title and the claim. Ids
|
|
17
|
+
* stay the same across versions, which is what lets the board redraw only
|
|
18
|
+
* what changed.
|
|
19
|
+
* - `diffShapeVersions` compares two snapshots by those ids ("what changed
|
|
20
|
+
* since you looked"). The agent never writes what changed; it is worked
|
|
21
|
+
* out here.
|
|
22
|
+
*
|
|
23
|
+
* Spec: the approved Shape design record ("shape-living-board"), sections 3 and 4.
|
|
24
|
+
*/
|
|
25
|
+
import type { CodeGuideOutput } from '../guide.ts';
|
|
26
|
+
import { ulid } from '../ulid.ts';
|
|
27
|
+
|
|
28
|
+
export const SHAPE_FORMAT_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
export type ShapeViewId = 'picture' | 'how' | 'reqs' | 'code';
|
|
31
|
+
|
|
32
|
+
/** The four views, in the dock's order. Their labels are the product's words. */
|
|
33
|
+
export const SHAPE_VIEWS: readonly { id: ShapeViewId; label: string }[] = [
|
|
34
|
+
{ id: 'picture', label: 'Picture' },
|
|
35
|
+
{ id: 'how', label: 'How it works' },
|
|
36
|
+
{ id: 'reqs', label: 'Requirements' },
|
|
37
|
+
{ id: 'code', label: 'Code' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export function shapeViewLabel(view: ShapeViewId): string {
|
|
41
|
+
return SHAPE_VIEWS.find((v) => v.id === view)?.label ?? view;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ───────────────────────────── the board an agent writes ─────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A drawn view (Picture, How it works): HTML and inline SVG the agent writes,
|
|
48
|
+
* drawn in its own style scope.
|
|
49
|
+
*
|
|
50
|
+
* - `data-step="N"`: parts with the same N arrive together, in number order.
|
|
51
|
+
* A `path` or `line` with `class="draw"` and `pathLength="1"` draws on.
|
|
52
|
+
* - `data-mark="id"`: a part the person can point at (Change it / Ask). Keep
|
|
53
|
+
* the id across versions for the same part.
|
|
54
|
+
* - `data-ref="n"`: an empty element that becomes reference bubble n.
|
|
55
|
+
* - Colours come from the frame's variables (`--ink`, `--paper`, `--mark`,
|
|
56
|
+
* `--grey`, `--rule`), so light and dark both work.
|
|
57
|
+
*/
|
|
58
|
+
export interface ShapeDrawnView {
|
|
59
|
+
html: string;
|
|
60
|
+
/** Rules for this view only (scoped to it). No `@import`, no outside `url()`. */
|
|
61
|
+
css?: string;
|
|
62
|
+
/**
|
|
63
|
+
* A fixed canvas, in CSS pixels, that the board scales to fit. Absent: the
|
|
64
|
+
* HTML flows like a page.
|
|
65
|
+
*/
|
|
66
|
+
width?: number;
|
|
67
|
+
height?: number;
|
|
68
|
+
/** A stacked drawing for a narrow board (a phone), shown below 700 px. */
|
|
69
|
+
tall?: { html: string; css?: string };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ShapeRequirement {
|
|
73
|
+
/** Stable across versions. */
|
|
74
|
+
id: string;
|
|
75
|
+
/** One terse line. Inline Markdown: links, `code`, **bold**, and `[^n]` for reference n. */
|
|
76
|
+
text: string;
|
|
77
|
+
/** Where it is built, for "open the code at this line". */
|
|
78
|
+
code?: { path: string; line: number };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ShapeRequirementGroup {
|
|
82
|
+
id: string;
|
|
83
|
+
/** "Who gets it", "When it shows"… in reading order. */
|
|
84
|
+
label: string;
|
|
85
|
+
items: ShapeRequirement[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ShapeRequirements {
|
|
89
|
+
/** The line over the groups. */
|
|
90
|
+
heading?: string;
|
|
91
|
+
groups: ShapeRequirementGroup[];
|
|
92
|
+
/** Short labelled lines at the foot (Open, Next). Inline Markdown. */
|
|
93
|
+
foot?: { label: string; text: string }[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One file a Code step plans to add or change, before the change exists. */
|
|
97
|
+
export interface ShapePlannedFile {
|
|
98
|
+
path: string;
|
|
99
|
+
kind: 'new' | 'change';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The Code view: a Guided Review (the guide format the review app's guides use).
|
|
104
|
+
* After implementation, `patch` is the real diff and each step lists the files
|
|
105
|
+
* it changed. Before, the steps list the files they plan (`planned`), and a
|
|
106
|
+
* `patch` holds only proposed diffs, which the board marks as not applied.
|
|
107
|
+
*/
|
|
108
|
+
export interface ShapeCodeView {
|
|
109
|
+
stage: 'before' | 'after';
|
|
110
|
+
guide: CodeGuideOutput;
|
|
111
|
+
patch?: string;
|
|
112
|
+
/** Before implementation: per step, the files it will add or change. */
|
|
113
|
+
planned?: ShapePlannedFile[][];
|
|
114
|
+
/** The provenance line under the guide's title. Inline Markdown. */
|
|
115
|
+
source?: string;
|
|
116
|
+
/** Files the whole-file viewer may open, each at a line. The host reads them. */
|
|
117
|
+
files?: { path: string; line?: number }[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type ShapeRefKind = 'message' | 'image' | 'file' | 'pr' | 'diff' | 'link';
|
|
121
|
+
export const SHAPE_REF_KINDS: readonly ShapeRefKind[] = ['message', 'image', 'file', 'pr', 'diff', 'link'];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A numbered reference: the views cite it as bubble `n`, and the References
|
|
125
|
+
* list opens it in the real viewer.
|
|
126
|
+
*
|
|
127
|
+
* - message: `target` is a thread message id (opens in the Thread tab).
|
|
128
|
+
* - image: `target` is the image (a file the store keeps, under `files/`); `images` steps through several.
|
|
129
|
+
* - file: `target` is a path in the project; `line` opens it there.
|
|
130
|
+
* - pr, link: `target` is the URL.
|
|
131
|
+
* - diff: `target` is a file path in the Code view's patch.
|
|
132
|
+
*/
|
|
133
|
+
export interface ShapeRef {
|
|
134
|
+
n: number;
|
|
135
|
+
kind: ShapeRefKind;
|
|
136
|
+
title: string;
|
|
137
|
+
sub?: string;
|
|
138
|
+
target: string;
|
|
139
|
+
line?: number;
|
|
140
|
+
images?: { src: string; alt: string }[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What an agent writes: one whole board. */
|
|
144
|
+
export interface ShapeBoard {
|
|
145
|
+
title: string;
|
|
146
|
+
/** One line: where it stands, and whether anything waits on the person. Inline Markdown. */
|
|
147
|
+
claim: string;
|
|
148
|
+
/** When the facts were current (ISO 8601). */
|
|
149
|
+
asOf?: string;
|
|
150
|
+
/** The agent's one line about this version. */
|
|
151
|
+
note?: string;
|
|
152
|
+
views: {
|
|
153
|
+
picture?: ShapeDrawnView;
|
|
154
|
+
how?: ShapeDrawnView;
|
|
155
|
+
reqs?: ShapeRequirements;
|
|
156
|
+
code?: ShapeCodeView;
|
|
157
|
+
};
|
|
158
|
+
refs?: ShapeRef[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ───────────────────────────── what the store keeps ─────────────────────────────
|
|
162
|
+
|
|
163
|
+
/** Where a part lives: a view, or the header (title, claim). */
|
|
164
|
+
export type ShapePartView = ShapeViewId | 'header';
|
|
165
|
+
|
|
166
|
+
export interface ShapePart {
|
|
167
|
+
view: ShapePartView;
|
|
168
|
+
id: string;
|
|
169
|
+
/** Its words, whitespace folded. What a change is compared on. */
|
|
170
|
+
text: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ShapeSession {
|
|
174
|
+
host: string;
|
|
175
|
+
id: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One stored snapshot. */
|
|
179
|
+
export interface ShapeVersion extends ShapeBoard {
|
|
180
|
+
n: number;
|
|
181
|
+
at: string;
|
|
182
|
+
/** The version the agent started from (null for the first). */
|
|
183
|
+
base: number | null;
|
|
184
|
+
/** The last thread message this version took in (Inbox shapes). */
|
|
185
|
+
through: string | null;
|
|
186
|
+
by: ShapeSession | null;
|
|
187
|
+
parts: ShapePart[];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type ShapeState = 'active' | 'finished';
|
|
191
|
+
|
|
192
|
+
/** `shape.json`: the shape itself. */
|
|
193
|
+
export interface ShapeRecord {
|
|
194
|
+
v: typeof SHAPE_FORMAT_VERSION;
|
|
195
|
+
id: string;
|
|
196
|
+
title: string;
|
|
197
|
+
project: { name: string; root: string };
|
|
198
|
+
session: ShapeSession | null;
|
|
199
|
+
/** The Inbox thread it hangs off (`msg_…`), or null for a standalone shape. */
|
|
200
|
+
thread: string | null;
|
|
201
|
+
state: ShapeState;
|
|
202
|
+
created: string;
|
|
203
|
+
updated: string;
|
|
204
|
+
latest: number;
|
|
205
|
+
/** The agent said it is drawing: until the next version, or `until`. */
|
|
206
|
+
drawing: { at: string; until: string } | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export type ShapeNoteKind = 'change' | 'ask' | 'answer';
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A line of a shape's chat: the person's Change it and Ask, and the agent's
|
|
213
|
+
* answers. A note with no `part` is on the whole board (`view` null) or on a
|
|
214
|
+
* whole view: a general comment.
|
|
215
|
+
*/
|
|
216
|
+
export interface ShapeNote {
|
|
217
|
+
id: string;
|
|
218
|
+
at: string;
|
|
219
|
+
kind: ShapeNoteKind;
|
|
220
|
+
/** The version it was written on. */
|
|
221
|
+
v: number;
|
|
222
|
+
view: ShapeViewId | null;
|
|
223
|
+
part: string | null;
|
|
224
|
+
/** The marked words, as shown. */
|
|
225
|
+
quote: string | null;
|
|
226
|
+
text: string;
|
|
227
|
+
/** `you`, or the agent session that answered. */
|
|
228
|
+
from: 'you' | ShapeSession;
|
|
229
|
+
/** An answer's question or change. */
|
|
230
|
+
re: string | null;
|
|
231
|
+
/** When an agent picked it up (`shape read`); null while it waits. */
|
|
232
|
+
delivered: string | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A Change it that no agent has picked up: "1 change waits for the agent". */
|
|
236
|
+
export function shapeNoteWaits(note: ShapeNote): boolean {
|
|
237
|
+
return note.kind === 'change' && note.from === 'you' && !note.delivered;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ───────────────────────────── ids ─────────────────────────────
|
|
241
|
+
|
|
242
|
+
const SHAPE_ID_RE = /^shp_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
243
|
+
const NOTE_ID_RE = /^shn_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
244
|
+
|
|
245
|
+
export function shapeId(now?: number): string {
|
|
246
|
+
return `shp_${ulid(now)}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function shapeNoteId(now?: number): string {
|
|
250
|
+
return `shn_${ulid(now)}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function isShapeId(value: unknown): value is string {
|
|
254
|
+
return typeof value === 'string' && SHAPE_ID_RE.test(value);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function isShapeNoteId(value: unknown): value is string {
|
|
258
|
+
return typeof value === 'string' && NOTE_ID_RE.test(value);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ───────────────────────────── limits ─────────────────────────────
|
|
262
|
+
|
|
263
|
+
/** The most a board may hold. A refusal names the field and the limit. */
|
|
264
|
+
export const SHAPE_LIMITS = {
|
|
265
|
+
title: 140,
|
|
266
|
+
claim: 400,
|
|
267
|
+
note: 280,
|
|
268
|
+
html: 300_000,
|
|
269
|
+
css: 60_000,
|
|
270
|
+
canvas: { min: 200, max: 4000 },
|
|
271
|
+
groups: 12,
|
|
272
|
+
items: 40,
|
|
273
|
+
line: 400,
|
|
274
|
+
sections: 20,
|
|
275
|
+
patch: 3_000_000,
|
|
276
|
+
files: 100,
|
|
277
|
+
refs: 60,
|
|
278
|
+
refN: 999,
|
|
279
|
+
refText: 300,
|
|
280
|
+
target: 2000,
|
|
281
|
+
images: 12,
|
|
282
|
+
noteText: 4000,
|
|
283
|
+
quote: 600,
|
|
284
|
+
} as const;
|
|
285
|
+
|
|
286
|
+
const PART_ID_RE = /^[A-Za-z][\w.-]{0,63}$/;
|
|
287
|
+
|
|
288
|
+
// ───────────────────────────── validation ─────────────────────────────
|
|
289
|
+
|
|
290
|
+
export type ShapeCheck<T> = { ok: true; value: T } | { ok: false; errors: string[] };
|
|
291
|
+
|
|
292
|
+
// ─── what a drawing may load: only what the board holds ───
|
|
293
|
+
//
|
|
294
|
+
// One allowlist, used twice: here on the store side, so the agent hears at
|
|
295
|
+
// once which field is wrong, and by the board's sanitizer when it draws.
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Tags a drawing may not hold. `animate` and `set` can rewrite an href after
|
|
299
|
+
* the sanitizer; `dialog` and `select` (its picker) open in the top layer,
|
|
300
|
+
* over the whole app, past the frame's containment and clip.
|
|
301
|
+
*/
|
|
302
|
+
// `area` and `map`: an image map's `<area href>` is a link the board's guard would have to know too; a drawing has `<a>`.
|
|
303
|
+
const FORBIDDEN_TAGS = ['script', 'iframe', 'frame', 'object', 'embed', 'link', 'meta', 'base', 'form', 'style', 'template', 'portal', 'source', 'animate', 'set', 'animatemotion', 'animatetransform', 'dialog', 'select', 'area', 'map'];
|
|
304
|
+
export const SHAPE_FORBIDDEN_TAGS: readonly string[] = FORBIDDEN_TAGS;
|
|
305
|
+
|
|
306
|
+
/** Attributes that lift a part into the top layer (a popover, a modal), over the app: dropped. */
|
|
307
|
+
export const SHAPE_TOP_LAYER_ATTRS: readonly string[] = ['popover', 'popovertarget', 'popovertargetaction', 'command', 'commandfor', 'interestfor', 'interesttarget'];
|
|
308
|
+
|
|
309
|
+
/** Attributes that load something and have no safe use on a board: dropped. */
|
|
310
|
+
export const SHAPE_DROPPED_ATTRS: readonly string[] = ['srcset', 'imagesrcset', 'poster', 'ping', 'lowsrc', 'dynsrc', 'longdesc'];
|
|
311
|
+
|
|
312
|
+
/** Attributes that hold a URL: each must pass `shapeUrlAllowed`. */
|
|
313
|
+
export const SHAPE_URL_ATTRS: readonly string[] = ['src', 'href', 'xlink:href', 'action', 'formaction', 'background', 'data', 'cite', 'codebase', 'manifest', 'icon', 'usemap', 'profile', 'archive', 'classid'];
|
|
314
|
+
|
|
315
|
+
/** A URL as a URL parser reads it: the parser drops tabs and newlines anywhere, so `ht\ttps:` is `https:`. */
|
|
316
|
+
function urlKey(value: string): string {
|
|
317
|
+
return value.replace(/[\u0000-\u0020\u007f]/g, '').toLowerCase();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const DATA_IMAGE_RE = /^data:image\/(?:png|jpeg|gif|webp)[;,]/;
|
|
321
|
+
/** A file the board keeps: `files/<name>`, one name (the store's own rule: no folder, no dot first) and no escapes. */
|
|
322
|
+
const BOARD_FILE_RE = /^files\/[^/\\%?#.][^/\\%?#]*$/;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* May a drawing's URL attribute hold this value? Only what the board holds:
|
|
326
|
+
* `files/<name>`, `#fragment`, or a `data:image/` PNG, JPEG, GIF or WebP. A
|
|
327
|
+
* link (`<a href>`) may also point out, over https. Anything else (another
|
|
328
|
+
* scheme, `//host`, `\\host`, `https:host`, a path) is refused.
|
|
329
|
+
*/
|
|
330
|
+
export function shapeUrlAllowed(value: string, link = false): boolean {
|
|
331
|
+
const key = urlKey(value);
|
|
332
|
+
if (key.startsWith('#') || DATA_IMAGE_RE.test(key) || BOARD_FILE_RE.test(key)) return true;
|
|
333
|
+
if (!link || !key.startsWith('https://')) return false;
|
|
334
|
+
try {
|
|
335
|
+
const url = new URL(value.trim());
|
|
336
|
+
return url.protocol === 'https:' && !!url.hostname;
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* A reference's URL as the board opens it: http(s) with a host, else null.
|
|
344
|
+
* The store checks this too; the board checks again where it opens one,
|
|
345
|
+
* since a host can hand it a board from anywhere.
|
|
346
|
+
*/
|
|
347
|
+
export function shapeLinkUrl(target: string): string | null {
|
|
348
|
+
if (!/^https?:\/\//.test(urlKey(target))) return null;
|
|
349
|
+
try {
|
|
350
|
+
const url = new URL(target.trim());
|
|
351
|
+
return (url.protocol === 'https:' || url.protocol === 'http:') && url.hostname ? url.href : null;
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function codePoint(hex: string): string {
|
|
358
|
+
const n = parseInt(hex, 16);
|
|
359
|
+
return n > 0 && n <= 0x10ffff && (n < 0xd800 || n > 0xdfff) ? String.fromCodePoint(n) : '\ufffd';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* CSS without its comments. A `/*` inside a string is text, not a comment,
|
|
364
|
+
* so strings are stepped over (with their escapes) while comments go.
|
|
365
|
+
*/
|
|
366
|
+
function stripCssComments(css: string): string {
|
|
367
|
+
let out = '';
|
|
368
|
+
let quote: string | null = null;
|
|
369
|
+
for (let i = 0; i < css.length; i++) {
|
|
370
|
+
const ch = css[i]!;
|
|
371
|
+
if (ch === '\\') {
|
|
372
|
+
out += css.slice(i, i + 2);
|
|
373
|
+
i++;
|
|
374
|
+
} else if (quote) {
|
|
375
|
+
out += ch;
|
|
376
|
+
if (ch === quote || ch === '\n') quote = null;
|
|
377
|
+
} else if (ch === '"' || ch === "'") {
|
|
378
|
+
quote = ch;
|
|
379
|
+
out += ch;
|
|
380
|
+
} else if (ch === '/' && css[i + 1] === '*') {
|
|
381
|
+
const end = css.indexOf('*/', i + 2);
|
|
382
|
+
i = end < 0 ? css.length : end + 1;
|
|
383
|
+
} else out += ch;
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* CSS as its tokenizer reads it: escapes read (`u\72l(` is `url(`), lower
|
|
390
|
+
* case, and comments gone outside strings. `keepComments` reads it with them
|
|
391
|
+
* left in, a second look the checks also take, so no comment reading of ours
|
|
392
|
+
* can hide a url.
|
|
393
|
+
*/
|
|
394
|
+
function cssKey(css: string, keepComments = false): string {
|
|
395
|
+
return (keepComments ? css : stripCssComments(css))
|
|
396
|
+
.replace(/\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\n\r\f])?|(\r\n|[\n\r\f])|([\s\S]))/g, (_, hex?: string, _nl?: string, ch?: string) =>
|
|
397
|
+
hex ? codePoint(hex) : ch ?? '',
|
|
398
|
+
)
|
|
399
|
+
.toLowerCase();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The values a CSS text hands to `url(…)`, read as a URL parser reads them. */
|
|
403
|
+
function cssUrls(key: string): string[] {
|
|
404
|
+
return [...key.matchAll(/url\s*\(\s*["']?([^)"']*)/g)].map((m) => urlKey(m[1] ?? ''));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* What a view's CSS (or a `style` attribute) may not hold: imports, fonts,
|
|
409
|
+
* scripted CSS and anything that loads from outside. `url()` may name only a
|
|
410
|
+
* `#fragment` or a data:image. Escapes and comments are read first.
|
|
411
|
+
*/
|
|
412
|
+
export function shapeCssProblems(css: string): string[] {
|
|
413
|
+
const out: string[] = [];
|
|
414
|
+
const keys = [cssKey(css), cssKey(css, true)];
|
|
415
|
+
const any = (test: (key: string) => boolean) => keys.some(test);
|
|
416
|
+
if (any((k) => /@import/.test(k))) out.push('@import is not allowed');
|
|
417
|
+
if (any((k) => /@font-face/.test(k))) out.push('@font-face is not allowed');
|
|
418
|
+
if (any((k) => /image-set\s*\(/.test(k))) out.push('image-set() is not allowed; use url() with a data:image');
|
|
419
|
+
if (any((k) => /(?:^|[^\w-])src\s*\(/.test(k))) out.push('src() is not allowed; use url() with a data:image');
|
|
420
|
+
if (any((k) => cssUrls(k).some((u) => !u.startsWith('#') && !DATA_IMAGE_RE.test(u)))) out.push('url() may name only a #fragment or a data:image (png, jpeg, gif, webp)');
|
|
421
|
+
if (any((k) => /expression\s*\(|-moz-binding|behavior\s*:/.test(k))) out.push('scripted CSS is not allowed');
|
|
422
|
+
// A customizable select's picker opens in the top layer, over the app.
|
|
423
|
+
if (any((k) => /base-select|::?picker\s*\(/.test(k))) out.push('a select picker (base-select, ::picker) is not allowed');
|
|
424
|
+
if (/<\/style/i.test(css)) out.push('</style> is not allowed');
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* A presentation attribute (`fill`, `mask`, `filter`, `clip-path`,
|
|
430
|
+
* `marker-*`…) may use `url(#id)` and nothing else that loads.
|
|
431
|
+
*/
|
|
432
|
+
export function shapePaintAllowed(value: string): boolean {
|
|
433
|
+
return [cssKey(value), cssKey(value, true)].every(
|
|
434
|
+
(key) => !/image-set\s*\(|(?:^|[^\w-])src\s*\(/.test(key) && cssUrls(key).every((u) => u.startsWith('#')),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Attributes that only ever hold words. */
|
|
439
|
+
const TEXT_ATTRS = new Set(['title', 'alt', 'class', 'id', 'role', 'lang', 'dir']);
|
|
440
|
+
|
|
441
|
+
const TAG_RE = /<([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
|
442
|
+
const ATTR_RE = /([^\s"'=<>/`]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* What a drawn view's HTML may not hold: scripts, event handlers, and anything
|
|
446
|
+
* that loads from outside the board. The board sanitizes again when it draws;
|
|
447
|
+
* this refusal tells the agent at once, by field.
|
|
448
|
+
*/
|
|
449
|
+
export function shapeHtmlProblems(html: string): string[] {
|
|
450
|
+
const out: string[] = [];
|
|
451
|
+
const add = (p: string) => {
|
|
452
|
+
if (!out.includes(p)) out.push(p);
|
|
453
|
+
};
|
|
454
|
+
const lower = html.toLowerCase();
|
|
455
|
+
for (const tag of FORBIDDEN_TAGS) {
|
|
456
|
+
if (new RegExp(`<${tag}[\\s>/]`).test(lower)) {
|
|
457
|
+
add(
|
|
458
|
+
tag === 'style'
|
|
459
|
+
? '<style> is not allowed here; put rules in the view\'s css'
|
|
460
|
+
: tag === 'dialog' || tag === 'select'
|
|
461
|
+
? `<${tag}> is not allowed (it opens over the whole app); draw it`
|
|
462
|
+
: `<${tag}> is not allowed`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (/<[^>]*\son[a-z]+\s*=/i.test(html)) add('event handler attributes (on…=) are not allowed');
|
|
467
|
+
if (/javascript\s*:/i.test(html)) add('javascript: URLs are not allowed');
|
|
468
|
+
for (const t of html.matchAll(TAG_RE)) {
|
|
469
|
+
const tag = t[1]!.toLowerCase();
|
|
470
|
+
const attrs = [...(t[2] ?? '').matchAll(ATTR_RE)].map((a) => ({ name: a[1]!.toLowerCase(), raw: a[2] ?? a[3] ?? a[4] ?? '' }));
|
|
471
|
+
if (tag === 'input' && attrs.some((a) => a.name === 'type' && decodeEntities(a.raw).trim().toLowerCase() === 'image')) add('<input type="image"> is not allowed');
|
|
472
|
+
for (const { name, raw } of attrs) {
|
|
473
|
+
if (name.startsWith('data-') || name.startsWith('aria-') || name.startsWith('on') || TEXT_ATTRS.has(name)) continue;
|
|
474
|
+
const value = decodeEntities(raw);
|
|
475
|
+
const shown = value.slice(0, 80);
|
|
476
|
+
const checked = name === 'style' || SHAPE_URL_ATTRS.includes(name) || /url|image-set|src|\\/i.test(value);
|
|
477
|
+
// A named character reference this check cannot read (`:`) could hide a scheme or a `(`.
|
|
478
|
+
if (checked && /&[a-z][a-z0-9]*;/i.test(value)) {
|
|
479
|
+
add(`${tag} ${name}: write characters plainly, not as named references like :`);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (SHAPE_TOP_LAYER_ATTRS.includes(name)) add(`${name} is not allowed (it lifts a part over the whole app)`);
|
|
483
|
+
else if (SHAPE_DROPPED_ATTRS.includes(name)) add(`${name} is not allowed (it loads from outside the board); use one src`);
|
|
484
|
+
else if (SHAPE_URL_ATTRS.includes(name)) {
|
|
485
|
+
const link = tag === 'a' && (name === 'href' || name === 'xlink:href');
|
|
486
|
+
if (!shapeUrlAllowed(value, link)) {
|
|
487
|
+
add(`<${tag} ${name}="${shown}">: nothing may load from outside the board; use files/<name>, #id or a data:image${link ? ' (a link may be https://)' : ''}`);
|
|
488
|
+
}
|
|
489
|
+
} else if (name === 'style') {
|
|
490
|
+
for (const p of shapeCssProblems(value)) add(`<${tag} style>: ${p}`);
|
|
491
|
+
} else if (!shapePaintAllowed(value)) add(`<${tag} ${name}="${shown}">: only url(#id) is allowed here`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const m of html.matchAll(/\sdata-code\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
495
|
+
const value = m[1] ?? m[2] ?? '';
|
|
496
|
+
if (!parseShapeCodeTarget(value)) add(`data-code="${value.slice(0, 80)}" is not a file and line in the project (write path#L42)`);
|
|
497
|
+
}
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* A path in the project, relative to its root: `src/a.ts`. Not absolute
|
|
503
|
+
* (`/x`, `C:/x`, `~/x`), no `.` or `..` part, no backslash (a UNC path or a
|
|
504
|
+
* Windows `..\`), no `%` escape, no URL scheme and no control character.
|
|
505
|
+
*/
|
|
506
|
+
export function isShapeProjectPath(path: string): boolean {
|
|
507
|
+
if (!path || path.length > 500 || path !== path.trim()) return false;
|
|
508
|
+
if (/[\u0000-\u001f\u007f\\%]/.test(path)) return false;
|
|
509
|
+
if (path.startsWith('/') || path.startsWith('~') || /^[a-z][a-z0-9+.-]*:/i.test(path)) return false;
|
|
510
|
+
return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const PROJECT_PATH_RULE = 'must be a path in the project, relative to its root (src/a.ts): no leading / or ~, no drive or scheme, no . or .. part, no backslash or %';
|
|
514
|
+
|
|
515
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
516
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
class Checker {
|
|
520
|
+
errors: string[] = [];
|
|
521
|
+
fail(field: string, message: string) {
|
|
522
|
+
this.errors.push(`${field}: ${message}`);
|
|
523
|
+
}
|
|
524
|
+
text(value: unknown, field: string, max: number, required = true): string | undefined {
|
|
525
|
+
if (value === undefined || value === null) {
|
|
526
|
+
if (required) this.fail(field, 'is required');
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
if (typeof value !== 'string') {
|
|
530
|
+
this.fail(field, 'must be a string');
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
if (required && !value.trim()) {
|
|
534
|
+
this.fail(field, 'is empty');
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
if (value.length > max) {
|
|
538
|
+
this.fail(field, `is ${value.length} characters; at most ${max}`);
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
return value;
|
|
542
|
+
}
|
|
543
|
+
int(value: unknown, field: string, min: number, max: number, required = true): number | undefined {
|
|
544
|
+
if (value === undefined || value === null) {
|
|
545
|
+
if (required) this.fail(field, 'is required');
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
|
|
549
|
+
this.fail(field, `must be a whole number from ${min} to ${max}`);
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
return value;
|
|
553
|
+
}
|
|
554
|
+
list(value: unknown, field: string, max: number): unknown[] | undefined {
|
|
555
|
+
if (value === undefined || value === null) return undefined;
|
|
556
|
+
if (!Array.isArray(value)) {
|
|
557
|
+
this.fail(field, 'must be a list');
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
if (value.length > max) {
|
|
561
|
+
this.fail(field, `has ${value.length} entries; at most ${max}`);
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
return value;
|
|
565
|
+
}
|
|
566
|
+
/** A path in the project (`isShapeProjectPath`). */
|
|
567
|
+
path(value: unknown, field: string): string | undefined {
|
|
568
|
+
const path = this.text(value, field, 500);
|
|
569
|
+
if (path === undefined) return undefined;
|
|
570
|
+
if (!isShapeProjectPath(path)) {
|
|
571
|
+
this.fail(field, `"${path.slice(0, 80)}" ${PROJECT_PATH_RULE}`);
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
return path;
|
|
575
|
+
}
|
|
576
|
+
id(value: unknown, field: string): string | undefined {
|
|
577
|
+
if (typeof value !== 'string' || !PART_ID_RE.test(value)) {
|
|
578
|
+
this.fail(field, 'must be an id: a letter, then letters, digits, ".", "_" or "-" (at most 64)');
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
return value;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function checkDrawn(c: Checker, value: unknown, field: string): ShapeDrawnView | undefined {
|
|
586
|
+
if (value === undefined || value === null) return undefined;
|
|
587
|
+
if (!isRecord(value)) {
|
|
588
|
+
c.fail(field, 'must be an object with html');
|
|
589
|
+
return undefined;
|
|
590
|
+
}
|
|
591
|
+
const html = c.text(value.html, `${field}.html`, SHAPE_LIMITS.html);
|
|
592
|
+
if (html !== undefined) for (const p of shapeHtmlProblems(html)) c.fail(`${field}.html`, p);
|
|
593
|
+
const css = c.text(value.css, `${field}.css`, SHAPE_LIMITS.css, false);
|
|
594
|
+
if (css !== undefined) for (const p of shapeCssProblems(css)) c.fail(`${field}.css`, p);
|
|
595
|
+
const width = c.int(value.width, `${field}.width`, SHAPE_LIMITS.canvas.min, SHAPE_LIMITS.canvas.max, false);
|
|
596
|
+
const height = c.int(value.height, `${field}.height`, SHAPE_LIMITS.canvas.min, SHAPE_LIMITS.canvas.max, false);
|
|
597
|
+
if ((width === undefined) !== (height === undefined) && !c.errors.some((e) => e.startsWith(`${field}.width`) || e.startsWith(`${field}.height`))) {
|
|
598
|
+
c.fail(field, 'width and height go together (a fixed canvas), or neither');
|
|
599
|
+
}
|
|
600
|
+
let tall: ShapeDrawnView['tall'];
|
|
601
|
+
if (value.tall !== undefined && value.tall !== null) {
|
|
602
|
+
if (!isRecord(value.tall)) c.fail(`${field}.tall`, 'must be an object with html');
|
|
603
|
+
else {
|
|
604
|
+
const th = c.text(value.tall.html, `${field}.tall.html`, SHAPE_LIMITS.html);
|
|
605
|
+
if (th !== undefined) for (const p of shapeHtmlProblems(th)) c.fail(`${field}.tall.html`, p);
|
|
606
|
+
const tc = c.text(value.tall.css, `${field}.tall.css`, SHAPE_LIMITS.css, false);
|
|
607
|
+
if (tc !== undefined) for (const p of shapeCssProblems(tc)) c.fail(`${field}.tall.css`, p);
|
|
608
|
+
if (th !== undefined) tall = { html: th, ...(tc ? { css: tc } : {}) };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (html === undefined) return undefined;
|
|
612
|
+
return {
|
|
613
|
+
html,
|
|
614
|
+
...(css ? { css } : {}),
|
|
615
|
+
...(width !== undefined && height !== undefined ? { width, height } : {}),
|
|
616
|
+
...(tall ? { tall } : {}),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function checkReqs(c: Checker, value: unknown, field: string): ShapeRequirements | undefined {
|
|
621
|
+
if (value === undefined || value === null) return undefined;
|
|
622
|
+
if (!isRecord(value)) {
|
|
623
|
+
c.fail(field, 'must be an object with groups');
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
const heading = c.text(value.heading, `${field}.heading`, SHAPE_LIMITS.line, false);
|
|
627
|
+
const groups = c.list(value.groups, `${field}.groups`, SHAPE_LIMITS.groups);
|
|
628
|
+
if (!groups) {
|
|
629
|
+
if (value.groups === undefined) c.fail(`${field}.groups`, 'is required');
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
const ids = new Set<string>();
|
|
633
|
+
const out: ShapeRequirementGroup[] = [];
|
|
634
|
+
groups.forEach((g, gi) => {
|
|
635
|
+
const gf = `${field}.groups[${gi}]`;
|
|
636
|
+
if (!isRecord(g)) return c.fail(gf, 'must be an object');
|
|
637
|
+
const id = c.id(g.id, `${gf}.id`);
|
|
638
|
+
const label = c.text(g.label, `${gf}.label`, 80);
|
|
639
|
+
const items = c.list(g.items, `${gf}.items`, SHAPE_LIMITS.items) ?? [];
|
|
640
|
+
if (g.items === undefined) c.fail(`${gf}.items`, 'is required');
|
|
641
|
+
const rows: ShapeRequirement[] = [];
|
|
642
|
+
items.forEach((it, ii) => {
|
|
643
|
+
const f = `${gf}.items[${ii}]`;
|
|
644
|
+
if (!isRecord(it)) return c.fail(f, 'must be an object');
|
|
645
|
+
const iid = c.id(it.id, `${f}.id`);
|
|
646
|
+
if (iid && ids.has(iid)) c.fail(`${f}.id`, `"${iid}" is used twice`);
|
|
647
|
+
if (iid) ids.add(iid);
|
|
648
|
+
const text = c.text(it.text, `${f}.text`, SHAPE_LIMITS.line);
|
|
649
|
+
let code: ShapeRequirement['code'];
|
|
650
|
+
if (it.code !== undefined && it.code !== null) {
|
|
651
|
+
if (!isRecord(it.code)) c.fail(`${f}.code`, 'must be { path, line }');
|
|
652
|
+
else {
|
|
653
|
+
const path = c.path(it.code.path, `${f}.code.path`);
|
|
654
|
+
const line = c.int(it.code.line, `${f}.code.line`, 1, 10_000_000);
|
|
655
|
+
if (path && line) code = { path, line };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (iid && text) rows.push({ id: iid, text, ...(code ? { code } : {}) });
|
|
659
|
+
});
|
|
660
|
+
if (id && label) out.push({ id, label, items: rows });
|
|
661
|
+
});
|
|
662
|
+
let foot: ShapeRequirements['foot'];
|
|
663
|
+
const feet = c.list(value.foot, `${field}.foot`, 6);
|
|
664
|
+
if (feet) {
|
|
665
|
+
foot = [];
|
|
666
|
+
feet.forEach((f, i) => {
|
|
667
|
+
if (!isRecord(f)) return c.fail(`${field}.foot[${i}]`, 'must be { label, text }');
|
|
668
|
+
const label = c.text(f.label, `${field}.foot[${i}].label`, 24);
|
|
669
|
+
const text = c.text(f.text, `${field}.foot[${i}].text`, SHAPE_LIMITS.line);
|
|
670
|
+
if (label && text) foot!.push({ label, text });
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return { ...(heading ? { heading } : {}), groups: out, ...(foot?.length ? { foot } : {}) };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function checkCode(c: Checker, value: unknown, field: string): ShapeCodeView | undefined {
|
|
677
|
+
if (value === undefined || value === null) return undefined;
|
|
678
|
+
if (!isRecord(value)) {
|
|
679
|
+
c.fail(field, 'must be an object with stage and guide');
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
682
|
+
const stage = value.stage === 'before' || value.stage === 'after' ? value.stage : undefined;
|
|
683
|
+
if (!stage) c.fail(`${field}.stage`, 'must be "before" or "after"');
|
|
684
|
+
const g = value.guide;
|
|
685
|
+
let guide: CodeGuideOutput | undefined;
|
|
686
|
+
if (!isRecord(g)) c.fail(`${field}.guide`, 'must be a guide: { title, intent, sections }');
|
|
687
|
+
else {
|
|
688
|
+
const title = c.text(g.title, `${field}.guide.title`, 200);
|
|
689
|
+
const intent = c.text(g.intent, `${field}.guide.intent`, 1000, false) ?? '';
|
|
690
|
+
const sections = c.list(g.sections, `${field}.guide.sections`, SHAPE_LIMITS.sections);
|
|
691
|
+
if (g.sections === undefined) c.fail(`${field}.guide.sections`, 'is required');
|
|
692
|
+
const outSections: CodeGuideOutput['sections'] = [];
|
|
693
|
+
(sections ?? []).forEach((s, i) => {
|
|
694
|
+
const f = `${field}.guide.sections[${i}]`;
|
|
695
|
+
if (!isRecord(s)) return c.fail(f, 'must be { title, overview, diffs }');
|
|
696
|
+
const st = c.text(s.title, `${f}.title`, 200);
|
|
697
|
+
const ov = c.text(s.overview, `${f}.overview`, 8000, false) ?? '';
|
|
698
|
+
const diffs = c.list(s.diffs, `${f}.diffs`, SHAPE_LIMITS.files) ?? [];
|
|
699
|
+
const outDiffs: CodeGuideOutput['sections'][number]['diffs'] = [];
|
|
700
|
+
diffs.forEach((d, j) => {
|
|
701
|
+
if (!isRecord(d)) return c.fail(`${f}.diffs[${j}]`, 'must be { file, summary }');
|
|
702
|
+
const file = c.path(d.file, `${f}.diffs[${j}].file`);
|
|
703
|
+
const summary = c.text(d.summary, `${f}.diffs[${j}].summary`, 600, false);
|
|
704
|
+
if (file) outDiffs.push({ file, ...(summary ? { summary } : {}) });
|
|
705
|
+
});
|
|
706
|
+
if (st) outSections.push({ title: st, overview: ov, diffs: outDiffs });
|
|
707
|
+
});
|
|
708
|
+
const unplaced = c.list(g.unplacedFiles, `${field}.guide.unplacedFiles`, 500);
|
|
709
|
+
const unplacedFiles = unplaced?.filter((p, i): p is string => typeof p === 'string' && c.path(p, `${field}.guide.unplacedFiles[${i}]`) !== undefined);
|
|
710
|
+
if (title) guide = { title, intent, sections: outSections, ...(unplacedFiles?.length ? { unplacedFiles } : {}) };
|
|
711
|
+
}
|
|
712
|
+
const patch = c.text(value.patch, `${field}.patch`, SHAPE_LIMITS.patch, false);
|
|
713
|
+
let planned: ShapePlannedFile[][] | undefined;
|
|
714
|
+
const pl = c.list(value.planned, `${field}.planned`, SHAPE_LIMITS.sections);
|
|
715
|
+
if (pl) {
|
|
716
|
+
planned = pl.map((step, i) => {
|
|
717
|
+
const files = c.list(step, `${field}.planned[${i}]`, SHAPE_LIMITS.files) ?? [];
|
|
718
|
+
return files.flatMap((f, j) => {
|
|
719
|
+
const ff = `${field}.planned[${i}][${j}]`;
|
|
720
|
+
if (!isRecord(f)) {
|
|
721
|
+
c.fail(ff, 'must be { path, kind }');
|
|
722
|
+
return [];
|
|
723
|
+
}
|
|
724
|
+
const path = c.path(f.path, `${ff}.path`);
|
|
725
|
+
if (f.kind !== 'new' && f.kind !== 'change') c.fail(`${ff}.kind`, 'must be "new" or "change"');
|
|
726
|
+
return path && (f.kind === 'new' || f.kind === 'change') ? [{ path, kind: f.kind }] : [];
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const source = c.text(value.source, `${field}.source`, SHAPE_LIMITS.line, false);
|
|
731
|
+
let files: ShapeCodeView['files'];
|
|
732
|
+
const fl = c.list(value.files, `${field}.files`, SHAPE_LIMITS.files);
|
|
733
|
+
if (fl) {
|
|
734
|
+
files = fl.flatMap((f, i) => {
|
|
735
|
+
if (!isRecord(f)) {
|
|
736
|
+
c.fail(`${field}.files[${i}]`, 'must be { path, line? }');
|
|
737
|
+
return [];
|
|
738
|
+
}
|
|
739
|
+
const path = c.path(f.path, `${field}.files[${i}].path`);
|
|
740
|
+
const line = c.int(f.line, `${field}.files[${i}].line`, 1, 10_000_000, false);
|
|
741
|
+
return path ? [{ path, ...(line ? { line } : {}) }] : [];
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
if (!stage || !guide) return undefined;
|
|
745
|
+
return {
|
|
746
|
+
stage,
|
|
747
|
+
guide,
|
|
748
|
+
...(patch ? { patch } : {}),
|
|
749
|
+
...(planned ? { planned } : {}),
|
|
750
|
+
...(source ? { source } : {}),
|
|
751
|
+
...(files?.length ? { files } : {}),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function checkRefs(c: Checker, value: unknown): ShapeRef[] | undefined {
|
|
756
|
+
const refs = c.list(value, 'refs', SHAPE_LIMITS.refs);
|
|
757
|
+
if (!refs) return undefined;
|
|
758
|
+
const seen = new Set<number>();
|
|
759
|
+
const out: ShapeRef[] = [];
|
|
760
|
+
refs.forEach((r, i) => {
|
|
761
|
+
const f = `refs[${i}]`;
|
|
762
|
+
if (!isRecord(r)) return c.fail(f, 'must be an object');
|
|
763
|
+
const n = c.int(r.n, `${f}.n`, 1, SHAPE_LIMITS.refN);
|
|
764
|
+
if (n !== undefined && seen.has(n)) c.fail(`${f}.n`, `${n} is used twice`);
|
|
765
|
+
if (n !== undefined) seen.add(n);
|
|
766
|
+
const kind = SHAPE_REF_KINDS.includes(r.kind as ShapeRefKind) ? (r.kind as ShapeRefKind) : undefined;
|
|
767
|
+
if (!kind) c.fail(`${f}.kind`, `must be one of ${SHAPE_REF_KINDS.join(', ')}`);
|
|
768
|
+
const title = c.text(r.title, `${f}.title`, SHAPE_LIMITS.refText);
|
|
769
|
+
const sub = c.text(r.sub, `${f}.sub`, SHAPE_LIMITS.refText, false);
|
|
770
|
+
const target = c.text(r.target, `${f}.target`, SHAPE_LIMITS.target);
|
|
771
|
+
if (target && (kind === 'pr' || kind === 'link') && !shapeLinkUrl(target)) c.fail(`${f}.target`, 'must be an http(s) URL');
|
|
772
|
+
if (target && /^\s*javascript:/i.test(target)) c.fail(`${f}.target`, 'javascript: is not allowed');
|
|
773
|
+
if (target && (kind === 'file' || kind === 'diff') && !isShapeProjectPath(target)) c.fail(`${f}.target`, `"${target.slice(0, 80)}" ${PROJECT_PATH_RULE}`);
|
|
774
|
+
const line = c.int(r.line, `${f}.line`, 1, 10_000_000, false);
|
|
775
|
+
let images: ShapeRef['images'];
|
|
776
|
+
const imgs = c.list(r.images, `${f}.images`, SHAPE_LIMITS.images);
|
|
777
|
+
if (imgs) {
|
|
778
|
+
images = imgs.flatMap((im, j) => {
|
|
779
|
+
if (!isRecord(im)) {
|
|
780
|
+
c.fail(`${f}.images[${j}]`, 'must be { src, alt }');
|
|
781
|
+
return [];
|
|
782
|
+
}
|
|
783
|
+
const src = c.text(im.src, `${f}.images[${j}].src`, SHAPE_LIMITS.target);
|
|
784
|
+
const alt = c.text(im.alt, `${f}.images[${j}].alt`, SHAPE_LIMITS.refText, false) ?? '';
|
|
785
|
+
return src ? [{ src, alt }] : [];
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
if (n !== undefined && kind && title && target) {
|
|
789
|
+
out.push({ n, kind, title, ...(sub ? { sub } : {}), target, ...(line ? { line } : {}), ...(images?.length ? { images } : {}) });
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
return out.sort((a, b) => a.n - b.n);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Validate what an agent writes. Every problem is named by its field. */
|
|
796
|
+
export function checkShapeBoard(input: unknown): ShapeCheck<ShapeBoard> {
|
|
797
|
+
const c = new Checker();
|
|
798
|
+
if (!isRecord(input)) return { ok: false, errors: ['the board must be a JSON object: { title, claim, views, refs }'] };
|
|
799
|
+
const title = c.text(input.title, 'title', SHAPE_LIMITS.title);
|
|
800
|
+
const claim = c.text(input.claim, 'claim', SHAPE_LIMITS.claim);
|
|
801
|
+
const note = c.text(input.note, 'note', SHAPE_LIMITS.note, false);
|
|
802
|
+
let asOf: string | undefined;
|
|
803
|
+
if (input.asOf !== undefined && input.asOf !== null) {
|
|
804
|
+
if (typeof input.asOf !== 'string' || Number.isNaN(Date.parse(input.asOf))) c.fail('asOf', 'must be an ISO 8601 date and time');
|
|
805
|
+
else asOf = new Date(input.asOf).toISOString();
|
|
806
|
+
}
|
|
807
|
+
const views: ShapeBoard['views'] = {};
|
|
808
|
+
if (!isRecord(input.views)) c.fail('views', 'is required: { picture, how, reqs, code }, at least one');
|
|
809
|
+
else {
|
|
810
|
+
for (const key of Object.keys(input.views)) {
|
|
811
|
+
if (!SHAPE_VIEWS.some((v) => v.id === key)) c.fail(`views.${key}`, 'is not a view (picture, how, reqs, code)');
|
|
812
|
+
}
|
|
813
|
+
const picture = checkDrawn(c, input.views.picture, 'views.picture');
|
|
814
|
+
const how = checkDrawn(c, input.views.how, 'views.how');
|
|
815
|
+
const reqs = checkReqs(c, input.views.reqs, 'views.reqs');
|
|
816
|
+
const code = checkCode(c, input.views.code, 'views.code');
|
|
817
|
+
if (picture) views.picture = picture;
|
|
818
|
+
if (how) views.how = how;
|
|
819
|
+
if (reqs) views.reqs = reqs;
|
|
820
|
+
if (code) views.code = code;
|
|
821
|
+
if (!input.views.picture && !input.views.how && !input.views.reqs && !input.views.code) c.fail('views', 'needs at least one view');
|
|
822
|
+
}
|
|
823
|
+
const refs = checkRefs(c, input.refs);
|
|
824
|
+
if (c.errors.length || !title || !claim) return { ok: false, errors: c.errors.length ? c.errors : ['title and claim are required'] };
|
|
825
|
+
const board: ShapeBoard = { title, claim, ...(asOf ? { asOf } : {}), ...(note ? { note } : {}), views, ...(refs?.length ? { refs } : {}) };
|
|
826
|
+
// Every cited reference must exist.
|
|
827
|
+
const known = new Set((refs ?? []).map((r) => r.n));
|
|
828
|
+
const cited = shapeCitedRefs(board);
|
|
829
|
+
const missing = [...cited].filter((n) => !known.has(n)).sort((a, b) => a - b);
|
|
830
|
+
if (missing.length) return { ok: false, errors: [`refs: the board cites ${missing.map((n) => `[${n}]`).join(', ')} but refs has no entry with that n`] };
|
|
831
|
+
return { ok: true, value: board };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ───────────────────────────── parts ─────────────────────────────
|
|
835
|
+
|
|
836
|
+
const VOID = new Set(['area', 'br', 'col', 'hr', 'img', 'input', 'source', 'track', 'wbr', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse', 'use', 'stop']);
|
|
837
|
+
|
|
838
|
+
function decodeEntities(text: string): string {
|
|
839
|
+
return text
|
|
840
|
+
.replace(/ /g, ' ')
|
|
841
|
+
.replace(/</g, '<')
|
|
842
|
+
.replace(/>/g, '>')
|
|
843
|
+
.replace(/"/g, '"')
|
|
844
|
+
.replace(/'|'/g, "'")
|
|
845
|
+
.replace(/→/g, '→')
|
|
846
|
+
// A browser reads a numeric reference with or without its `;`.
|
|
847
|
+
.replace(/&#(\d+);?/g, (_, d: string) => codePoint(Number(d).toString(16)))
|
|
848
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_, h: string) => codePoint(h))
|
|
849
|
+
.replace(/&/g, '&');
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const fold = (text: string) => text.replace(/\s+/g, ' ').trim();
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* The marked parts of a drawn view's HTML, in document order: each element
|
|
856
|
+
* with `data-mark`, and the words inside it (a nested mark's words count for
|
|
857
|
+
* both). A mark id seen again (the wide and the tall drawing name the same
|
|
858
|
+
* part) keeps its first words. No DOM: the store runs in Node.
|
|
859
|
+
*/
|
|
860
|
+
export function shapeHtmlMarks(html: string): { id: string; text: string }[] {
|
|
861
|
+
const out = new Map<string, string>();
|
|
862
|
+
const stack: { tag: string; mark: string | null; text: string[] }[] = [];
|
|
863
|
+
// `<[!]--`, not the bare comment opener: some JS parsers (Semgrep's) read `<!--` as an HTML-like comment.
|
|
864
|
+
const re = /<[!]--[\s\S]*?-->|<(\/?)([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|([^<]+)/g;
|
|
865
|
+
let m: RegExpExecArray | null;
|
|
866
|
+
const close = (frame: { mark: string | null; text: string[] }) => {
|
|
867
|
+
if (frame.mark && !out.has(frame.mark)) out.set(frame.mark, fold(frame.text.join(' ')));
|
|
868
|
+
};
|
|
869
|
+
while ((m = re.exec(html))) {
|
|
870
|
+
if (m[0].startsWith('<!--')) continue;
|
|
871
|
+
if (m[5] !== undefined) {
|
|
872
|
+
const text = decodeEntities(m[5]);
|
|
873
|
+
for (const f of stack) if (f.mark) f.text.push(text);
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
const tag = m[2]!.toLowerCase();
|
|
877
|
+
if (m[1]) {
|
|
878
|
+
const at = stack.map((f) => f.tag).lastIndexOf(tag);
|
|
879
|
+
if (at < 0) continue;
|
|
880
|
+
while (stack.length > at) close(stack.pop()!);
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const attrs = m[3] ?? '';
|
|
884
|
+
const mark = /\sdata-mark\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i.exec(` ${attrs}`);
|
|
885
|
+
const id = mark ? (mark[1] ?? mark[2] ?? mark[3] ?? '').trim() : '';
|
|
886
|
+
const selfClosing = !!m[4] || VOID.has(tag);
|
|
887
|
+
const frame = { tag, mark: id || null, text: [] as string[] };
|
|
888
|
+
if (selfClosing) close(frame);
|
|
889
|
+
else stack.push(frame);
|
|
890
|
+
}
|
|
891
|
+
while (stack.length) close(stack.pop()!);
|
|
892
|
+
return [...out].map(([id, text]) => ({ id, text }));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Every part of a board, by view. The diff and the redraw key on `view` + `id`. */
|
|
896
|
+
export function shapeParts(board: ShapeBoard): ShapePart[] {
|
|
897
|
+
const parts: ShapePart[] = [
|
|
898
|
+
{ view: 'header', id: 'title', text: fold(board.title) },
|
|
899
|
+
{ view: 'header', id: 'claim', text: fold(board.claim) },
|
|
900
|
+
];
|
|
901
|
+
for (const view of ['picture', 'how'] as const) {
|
|
902
|
+
const drawn = board.views[view];
|
|
903
|
+
if (!drawn) continue;
|
|
904
|
+
const seen = new Set<string>();
|
|
905
|
+
for (const html of [drawn.html, drawn.tall?.html ?? '']) {
|
|
906
|
+
for (const mark of shapeHtmlMarks(html)) {
|
|
907
|
+
if (seen.has(mark.id)) continue;
|
|
908
|
+
seen.add(mark.id);
|
|
909
|
+
parts.push({ view, id: mark.id, text: mark.text });
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
for (const group of board.views.reqs?.groups ?? []) {
|
|
914
|
+
for (const item of group.items) parts.push({ view: 'reqs', id: item.id, text: fold(`${group.label}: ${item.text}`) });
|
|
915
|
+
}
|
|
916
|
+
board.views.code?.guide.sections.forEach((section, i) => {
|
|
917
|
+
parts.push({ view: 'code', id: `step-${i + 1}`, text: fold(`${section.title}. ${section.overview} ${section.diffs.map((d) => d.file).join(' ')}`) });
|
|
918
|
+
});
|
|
919
|
+
return parts;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** A place in code a drawn part points at. */
|
|
923
|
+
export interface ShapeCodeTarget {
|
|
924
|
+
path: string;
|
|
925
|
+
line: number;
|
|
926
|
+
lineEnd?: number;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Reads a part's `data-code` ("src/snooze.ts#L42", "src/snooze.ts#L42-L48"
|
|
931
|
+
* or "src/snooze.ts:42"). Null when it is not one: a path outside the
|
|
932
|
+
* project (`isShapeProjectPath`: absolute, `..`, a backslash), a URL or no line.
|
|
933
|
+
*/
|
|
934
|
+
export function parseShapeCodeTarget(value: string | null | undefined): ShapeCodeTarget | null {
|
|
935
|
+
const m = (value ?? '').trim().match(/^([^#:\s][^#:]*?)(?:#L(\d+)(?:-L?(\d+))?|:(\d+)(?:-(\d+))?)$/);
|
|
936
|
+
if (!m) return null;
|
|
937
|
+
const path = m[1]!;
|
|
938
|
+
if (!isShapeProjectPath(path)) return null;
|
|
939
|
+
const line = Number(m[2] ?? m[4]);
|
|
940
|
+
const end = m[3] ?? m[5];
|
|
941
|
+
if (!Number.isSafeInteger(line) || line < 1) return null;
|
|
942
|
+
const lineEnd = end ? Number(end) : undefined;
|
|
943
|
+
return lineEnd && lineEnd > line ? { path, line, lineEnd } : { path, line };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** The reference numbers a board cites: `data-ref` in drawn views, `[^n]` in text. */
|
|
947
|
+
export function shapeCitedRefs(board: ShapeBoard): Set<number> {
|
|
948
|
+
const out = new Set<number>();
|
|
949
|
+
const fromText = (text: string | undefined) => {
|
|
950
|
+
for (const m of (text ?? '').matchAll(/\[\^(\d{1,3})\]/g)) out.add(Number(m[1]));
|
|
951
|
+
};
|
|
952
|
+
const fromHtml = (html: string | undefined) => {
|
|
953
|
+
for (const m of (html ?? '').matchAll(/\sdata-ref\s*=\s*["']?(\d{1,3})/g)) out.add(Number(m[1]));
|
|
954
|
+
};
|
|
955
|
+
fromText(board.claim);
|
|
956
|
+
for (const view of ['picture', 'how'] as const) {
|
|
957
|
+
fromHtml(board.views[view]?.html);
|
|
958
|
+
fromHtml(board.views[view]?.tall?.html);
|
|
959
|
+
}
|
|
960
|
+
for (const group of board.views.reqs?.groups ?? []) for (const item of group.items) fromText(item.text);
|
|
961
|
+
for (const foot of board.views.reqs?.foot ?? []) fromText(foot.text);
|
|
962
|
+
fromText(board.views.code?.source);
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ───────────────────────────── diff ─────────────────────────────
|
|
967
|
+
|
|
968
|
+
export interface ShapeChange {
|
|
969
|
+
view: ShapePartView;
|
|
970
|
+
id: string;
|
|
971
|
+
kind: 'added' | 'removed' | 'changed';
|
|
972
|
+
before?: string;
|
|
973
|
+
after?: string;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export interface ShapeDiff {
|
|
977
|
+
from: number;
|
|
978
|
+
to: number;
|
|
979
|
+
changes: ShapeChange[];
|
|
980
|
+
/** Views whose content changed at all (a part, or anything else in the view). In the dock's order. */
|
|
981
|
+
views: ShapeViewId[];
|
|
982
|
+
refs: { added: number[]; removed: number[]; changed: number[] };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
type Diffable = Pick<ShapeVersion, 'n' | 'views' | 'parts' | 'refs'> & Partial<Pick<ShapeVersion, 'title' | 'claim'>>;
|
|
986
|
+
|
|
987
|
+
/** What changed from `a` to `b`, by part ids. Snapshots, so any two versions compare. */
|
|
988
|
+
export function diffShapeVersions(a: Diffable, b: Diffable): ShapeDiff {
|
|
989
|
+
const key = (p: ShapePart) => `${p.view}\u0000${p.id}`;
|
|
990
|
+
const before = new Map(a.parts.map((p) => [key(p), p]));
|
|
991
|
+
const after = new Map(b.parts.map((p) => [key(p), p]));
|
|
992
|
+
const changes: ShapeChange[] = [];
|
|
993
|
+
for (const p of b.parts) {
|
|
994
|
+
const old = before.get(key(p));
|
|
995
|
+
if (!old) changes.push({ view: p.view, id: p.id, kind: 'added', after: p.text });
|
|
996
|
+
else if (old.text !== p.text) changes.push({ view: p.view, id: p.id, kind: 'changed', before: old.text, after: p.text });
|
|
997
|
+
}
|
|
998
|
+
for (const p of a.parts) if (!after.has(key(p))) changes.push({ view: p.view, id: p.id, kind: 'removed', before: p.text });
|
|
999
|
+
|
|
1000
|
+
const views = SHAPE_VIEWS.map((v) => v.id).filter((view) => {
|
|
1001
|
+
if (changes.some((c) => c.view === view)) return true;
|
|
1002
|
+
return JSON.stringify(a.views[view] ?? null) !== JSON.stringify(b.views[view] ?? null);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
const refsA = new Map((a.refs ?? []).map((r) => [r.n, JSON.stringify(r)]));
|
|
1006
|
+
const refsB = new Map((b.refs ?? []).map((r) => [r.n, JSON.stringify(r)]));
|
|
1007
|
+
const refs = {
|
|
1008
|
+
added: [...refsB.keys()].filter((n) => !refsA.has(n)),
|
|
1009
|
+
removed: [...refsA.keys()].filter((n) => !refsB.has(n)),
|
|
1010
|
+
changed: [...refsB.keys()].filter((n) => refsA.has(n) && refsA.get(n) !== refsB.get(n)),
|
|
1011
|
+
};
|
|
1012
|
+
return { from: a.n, to: b.n, changes, views, refs };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** "What changed since you looked", as short lines: one per view, then references. */
|
|
1016
|
+
export function summarizeShapeDiff(diff: ShapeDiff): string[] {
|
|
1017
|
+
const lines: string[] = [];
|
|
1018
|
+
const header = diff.changes.filter((c) => c.view === 'header');
|
|
1019
|
+
if (header.some((c) => c.id === 'title')) lines.push('The title changed');
|
|
1020
|
+
if (header.some((c) => c.id === 'claim')) lines.push('The claim changed');
|
|
1021
|
+
for (const view of diff.views) {
|
|
1022
|
+
const here = diff.changes.filter((c) => c.view === view);
|
|
1023
|
+
const count = (kind: ShapeChange['kind']) => here.filter((c) => c.kind === kind).length;
|
|
1024
|
+
const bits = [
|
|
1025
|
+
count('changed') && `${count('changed')} changed`,
|
|
1026
|
+
count('added') && `${count('added')} added`,
|
|
1027
|
+
count('removed') && `${count('removed')} removed`,
|
|
1028
|
+
].filter(Boolean);
|
|
1029
|
+
lines.push(`${shapeViewLabel(view)}: ${bits.length ? bits.join(', ') : 'redrawn'}`);
|
|
1030
|
+
}
|
|
1031
|
+
const r = diff.refs;
|
|
1032
|
+
const refBits = [r.added.length && `${r.added.length} added`, r.removed.length && `${r.removed.length} removed`, r.changed.length && `${r.changed.length} changed`].filter(Boolean);
|
|
1033
|
+
if (refBits.length) lines.push(`References: ${refBits.join(', ')}`);
|
|
1034
|
+
return lines;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ───────────────────────────── notes ─────────────────────────────
|
|
1038
|
+
|
|
1039
|
+
export interface ShapeNoteInput {
|
|
1040
|
+
kind: 'change' | 'ask';
|
|
1041
|
+
v: number;
|
|
1042
|
+
view?: ShapeViewId | null;
|
|
1043
|
+
part?: string | null;
|
|
1044
|
+
quote?: string | null;
|
|
1045
|
+
text: string;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Validate a person's Change it or Ask. No part: a general comment (on a view, or the whole board). */
|
|
1049
|
+
export function checkShapeNoteInput(input: unknown): ShapeCheck<ShapeNoteInput> {
|
|
1050
|
+
const c = new Checker();
|
|
1051
|
+
if (!isRecord(input)) return { ok: false, errors: ['a note must be an object'] };
|
|
1052
|
+
const kind = input.kind === 'change' || input.kind === 'ask' ? input.kind : undefined;
|
|
1053
|
+
if (!kind) c.fail('kind', 'must be "change" or "ask"');
|
|
1054
|
+
const v = c.int(input.v, 'v', 1, 1_000_000);
|
|
1055
|
+
let view: ShapeViewId | null = null;
|
|
1056
|
+
if (input.view !== undefined && input.view !== null) {
|
|
1057
|
+
if (!SHAPE_VIEWS.some((x) => x.id === input.view)) c.fail('view', 'must be picture, how, reqs or code');
|
|
1058
|
+
else view = input.view as ShapeViewId;
|
|
1059
|
+
}
|
|
1060
|
+
let part: string | null = null;
|
|
1061
|
+
if (input.part !== undefined && input.part !== null) {
|
|
1062
|
+
if (typeof input.part !== 'string' || !input.part.trim() || input.part.length > 200) c.fail('part', 'must be a part id');
|
|
1063
|
+
else part = input.part;
|
|
1064
|
+
}
|
|
1065
|
+
if (part && !view) c.fail('view', 'is required with a part');
|
|
1066
|
+
const quote = c.text(input.quote, 'quote', SHAPE_LIMITS.quote, false) ?? null;
|
|
1067
|
+
const text = c.text(input.text, 'text', SHAPE_LIMITS.noteText);
|
|
1068
|
+
if (c.errors.length || !kind || !v || !text) return { ok: false, errors: c.errors };
|
|
1069
|
+
return { ok: true, value: { kind, v, view, part, quote, text } };
|
|
1070
|
+
}
|