@marver-design/marver 0.20.0 → 0.22.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/CHANGELOG.md +123 -0
- package/README.md +17 -4
- package/dist/{bake-tAb0D6Rc.mjs → bake-BSX4XR_U.mjs} +1 -1
- package/dist/board-status-CWGHIdo_.mjs +1307 -0
- package/dist/boards-BTGNPVMx.mjs +187 -0
- package/dist/{build-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
- package/dist/cli.mjs +67 -8
- package/dist/config-DJxMRVD8.mjs +373 -0
- package/dist/context-DUAENlJ6.mjs +600 -0
- package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
- package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
- package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
- package/dist/managed-write-Bo-oPc-i.mjs +71 -0
- package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
- package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
- package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
- package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
- package/dist/{shot-iw3SEcpn.mjs → shot-C22Ues04.mjs} +2 -2
- package/docs/boards-and-folders.md +161 -0
- package/docs/context.md +117 -0
- package/docs/sharing.md +12 -2
- package/package.json +1 -1
- package/src/client/shell/BoardList.tsx +143 -54
- package/src/client/shell/ContextMenu.tsx +16 -5
- package/src/client/shell/StatusPicker.tsx +91 -0
- package/src/client/shell/board-icons.tsx +75 -0
- package/src/client/shell/store.ts +93 -14
- package/src/client/shell/styles.css +41 -9
- package/src/shared/board-tree.ts +291 -143
- package/src/shared/board-types.ts +103 -0
- package/src/shared/context.ts +265 -0
- package/src/shared/status.ts +157 -0
- package/templates/AGENTS-embedded.md +4 -3
- package/templates/AGENTS-studio.md +4 -3
- package/templates/context/INDEX.md +50 -0
- package/templates/context/map.json +6 -0
- package/templates/context/shipped-knowledge.md +19 -0
- package/templates/context/shipped.md +28 -0
- package/templates/instructions/boards.md +78 -22
- package/templates/instructions/context.md +114 -0
- package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
- package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
- package/templates/playbooks/reorganize-context/eval.md +93 -0
- package/dist/boards-BmxcT3Lc.mjs +0 -290
- package/dist/boards-PuVzw5Wp.mjs +0 -62
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
//#region src/server/hash.ts
|
|
5
|
+
/** sha256 hex of a string - the CAS token for board files, the registry, the manifest. */
|
|
6
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/shared/board-types.ts
|
|
9
|
+
/**
|
|
10
|
+
* Board types (spec 20) - what a board is FOR in the workspace, one vocabulary every canvas
|
|
11
|
+
* shares so any sidebar reads at a glance. A board states its own `"type"`, or wears the type of
|
|
12
|
+
* the nearest typed folder above it (its folder, then that folder's parent), else it is plain.
|
|
13
|
+
* Moving a board changes an inherited type, never one the board states itself.
|
|
14
|
+
*
|
|
15
|
+
* Pure and shared: the dev API, the manifest, the build, the shell and the CLI all read types
|
|
16
|
+
* through here, so a type means one thing everywhere.
|
|
17
|
+
*/
|
|
18
|
+
const BOARD_TYPES = [
|
|
19
|
+
"start",
|
|
20
|
+
"feature",
|
|
21
|
+
"surface",
|
|
22
|
+
"project",
|
|
23
|
+
"feedback",
|
|
24
|
+
"context",
|
|
25
|
+
"deck",
|
|
26
|
+
"archive"
|
|
27
|
+
];
|
|
28
|
+
/** The grammar a stored type keeps to. Any on-grammar word survives a write - a newer Marver's
|
|
29
|
+
* type is never dropped by an older shell - while only the known ones are drawn. */
|
|
30
|
+
const TYPE_GRAMMAR = /^[a-z][a-z-]{0,31}$/;
|
|
31
|
+
/** A type off a file: the word when it is on-grammar, else absent. */
|
|
32
|
+
const readType = (v) => typeof v === "string" && TYPE_GRAMMAR.test(v) ? v : void 0;
|
|
33
|
+
/** A type this Marver draws. */
|
|
34
|
+
const knownType = (v) => BOARD_TYPES.includes(v) ? v : void 0;
|
|
35
|
+
/** A board's type: its own when it states one (an unknown word reads as plain - the board spoke
|
|
36
|
+
* for itself), else its folder's, else that folder's parent's, else plain. */
|
|
37
|
+
function resolveType(own, folder, parent) {
|
|
38
|
+
if (readType(own)) return knownType(own) ?? "plain";
|
|
39
|
+
return knownType(folder) ?? knownType(parent) ?? "plain";
|
|
40
|
+
}
|
|
41
|
+
/** The publish type a board's type proposes when its publish row names none - how a board
|
|
42
|
+
* PRESENTS when published, a separate question from what it is for. */
|
|
43
|
+
const PROPOSED_PUBLISH = {
|
|
44
|
+
deck: "slides",
|
|
45
|
+
context: "refs",
|
|
46
|
+
project: "doc",
|
|
47
|
+
feature: "mix",
|
|
48
|
+
surface: "mix"
|
|
49
|
+
};
|
|
50
|
+
/** The types that carry a status (spec 20, open question 2: features and projects - the others
|
|
51
|
+
* have no lifecycle of their own). */
|
|
52
|
+
const HAS_STATUS = ["feature", "project"];
|
|
53
|
+
/** The decisions a board may state by hand. `done` is never one: Done comes only from the shipped
|
|
54
|
+
* record. `todo`, `backlog` and `in-progress` count only where there is no `context/`. */
|
|
55
|
+
const DECISIONS = [
|
|
56
|
+
"archived",
|
|
57
|
+
"paused",
|
|
58
|
+
"blocked"
|
|
59
|
+
];
|
|
60
|
+
const BY_HAND = [
|
|
61
|
+
"in-progress",
|
|
62
|
+
"todo",
|
|
63
|
+
"backlog"
|
|
64
|
+
];
|
|
65
|
+
const STATUS_WORDS = [
|
|
66
|
+
...DECISIONS,
|
|
67
|
+
...BY_HAND,
|
|
68
|
+
"done"
|
|
69
|
+
];
|
|
70
|
+
const readStatusWord = (v) => STATUS_WORDS.includes(v) ? v : void 0;
|
|
71
|
+
/** The statuses a person may set on a feature or project board, in the order a picker lists them:
|
|
72
|
+
* the three decisions always; Backlog, To do and In progress only where there is no `context/`
|
|
73
|
+
* (with it they are read from the evidence). Never Done - that is the shipped record's alone. */
|
|
74
|
+
const settableStatuses = (contextPresent) => contextPresent ? [
|
|
75
|
+
"blocked",
|
|
76
|
+
"paused",
|
|
77
|
+
"archived"
|
|
78
|
+
] : [
|
|
79
|
+
"backlog",
|
|
80
|
+
"todo",
|
|
81
|
+
"in-progress",
|
|
82
|
+
"blocked",
|
|
83
|
+
"paused",
|
|
84
|
+
"archived"
|
|
85
|
+
];
|
|
86
|
+
/** A capability slug - the same grammar as a board name, so a feature board and its contract
|
|
87
|
+
* share it. */
|
|
88
|
+
const readCapability = (v) => typeof v === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(v) ? v : void 0;
|
|
89
|
+
const readReason = (v) => {
|
|
90
|
+
if (typeof v !== "string") return void 0;
|
|
91
|
+
return v.trim().replace(/\s+/g, " ").slice(0, 300) || void 0;
|
|
92
|
+
};
|
|
93
|
+
/** The sidebar every canvas shares (spec 19, The canvas): folder modules, each typed, so a board
|
|
94
|
+
* made inside one wears its type. `marver init --kind` creates a kind's set on a fresh canvas;
|
|
95
|
+
* `marver folders add <module>` adds one later. Names are slugs; only Start here needs a title. */
|
|
96
|
+
const FOLDER_MODULES = {
|
|
97
|
+
start: {
|
|
98
|
+
name: "start-here",
|
|
99
|
+
title: "Start here",
|
|
100
|
+
type: "start"
|
|
101
|
+
},
|
|
102
|
+
features: {
|
|
103
|
+
name: "features",
|
|
104
|
+
type: "feature"
|
|
105
|
+
},
|
|
106
|
+
surfaces: {
|
|
107
|
+
name: "surfaces",
|
|
108
|
+
type: "surface"
|
|
109
|
+
},
|
|
110
|
+
projects: {
|
|
111
|
+
name: "projects",
|
|
112
|
+
type: "project"
|
|
113
|
+
},
|
|
114
|
+
feedback: {
|
|
115
|
+
name: "feedback",
|
|
116
|
+
type: "feedback"
|
|
117
|
+
},
|
|
118
|
+
context: {
|
|
119
|
+
name: "context",
|
|
120
|
+
type: "context"
|
|
121
|
+
},
|
|
122
|
+
decks: {
|
|
123
|
+
name: "decks",
|
|
124
|
+
type: "deck"
|
|
125
|
+
},
|
|
126
|
+
archive: {
|
|
127
|
+
name: "archive",
|
|
128
|
+
type: "archive"
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/** A kind of work's folders, in sidebar order: the core (Start here, Feedback, Context, Archive)
|
|
132
|
+
* around the kind's own modules. Decks are an add-on for either. */
|
|
133
|
+
const KIND_FOLDERS = {
|
|
134
|
+
product: [
|
|
135
|
+
"start",
|
|
136
|
+
"features",
|
|
137
|
+
"surfaces",
|
|
138
|
+
"feedback",
|
|
139
|
+
"context",
|
|
140
|
+
"archive"
|
|
141
|
+
],
|
|
142
|
+
knowledge: [
|
|
143
|
+
"start",
|
|
144
|
+
"projects",
|
|
145
|
+
"feedback",
|
|
146
|
+
"context",
|
|
147
|
+
"archive"
|
|
148
|
+
]
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/shared/board-tree.ts
|
|
152
|
+
/**
|
|
153
|
+
* Board folders - the pure tree shared by the sidebar, the dev API, the build and the
|
|
154
|
+
* tests. Files are the truth: a board says which folder it sits in (`folder` on the
|
|
155
|
+
* board file - one slug, the folder it sits in directly), ranked among its siblings by
|
|
156
|
+
* `order`, and `design/boards/_folders.json` says which folders exist, where they rank, and
|
|
157
|
+
* which folder holds which (`parent`). Two levels: a folder holds boards and folders, a
|
|
158
|
+
* folder inside a folder (a sub-folder) holds boards only. `all-scenes` never enters the
|
|
159
|
+
* tree - callers pin it last.
|
|
160
|
+
*/
|
|
161
|
+
/** The on-disk name grammar shared by boards and folders (a board name is a filename). */
|
|
162
|
+
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
163
|
+
const isBoardName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
|
|
164
|
+
/** The folder registry beside the boards - underscore = infrastructure, never a board. */
|
|
165
|
+
const FOLDERS_FILE = "_folders.json";
|
|
166
|
+
/** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
|
|
167
|
+
* off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
|
|
168
|
+
const isBoardFile = (f) => f.endsWith(".json") && isBoardName(f.slice(0, -5));
|
|
169
|
+
const readDescription = (v) => {
|
|
170
|
+
if (typeof v !== "string") return void 0;
|
|
171
|
+
return v.trim().replace(/\s+/g, " ").slice(0, 300) || void 0;
|
|
172
|
+
};
|
|
173
|
+
const readTitle = (v) => {
|
|
174
|
+
if (typeof v !== "string") return void 0;
|
|
175
|
+
return Array.from(v.replace(/[\u0000-\u001f\u007f]/g, " ").trim().replace(/\s+/g, " ")).slice(0, 120).join("").trim() || void 0;
|
|
176
|
+
};
|
|
177
|
+
/** Title Case off a slug - the display when no title is set ("old-stuff" → "Old Stuff"). */
|
|
178
|
+
const humanize = (s) => s.replace(/-/g, " ").replace(/(^|\s)\S/g, (c) => c.toUpperCase());
|
|
179
|
+
const rank = (o) => typeof o === "number" && Number.isFinite(o) ? o : Infinity;
|
|
180
|
+
/** A folder's title, description and type, present only when set. */
|
|
181
|
+
const folderExtras = (it) => ({
|
|
182
|
+
...it.title ? { title: it.title } : {},
|
|
183
|
+
...it.description ? { description: it.description } : {},
|
|
184
|
+
...readType(it.type) ? { type: it.type } : {}
|
|
185
|
+
});
|
|
186
|
+
/** The registry file's shape. Returns the rows, or a string naming what is wrong - a
|
|
187
|
+
* malformed registry is an ERROR the human must fix (silently reading it as empty, or
|
|
188
|
+
* flattening a broken nesting, would let the next drag overwrite their folders), while a
|
|
189
|
+
* missing file is simply no folders. */
|
|
190
|
+
function parseFolders(raw) {
|
|
191
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "expected an object";
|
|
192
|
+
const { version, folders } = raw;
|
|
193
|
+
if (version !== void 0 && version !== 1 && version !== 2) return `unsupported version ${String(version)}`;
|
|
194
|
+
if (!Array.isArray(folders)) return "expected a \"folders\" array";
|
|
195
|
+
const out = [];
|
|
196
|
+
const seen = /* @__PURE__ */ new Set();
|
|
197
|
+
for (const f of folders) {
|
|
198
|
+
const name = f?.name;
|
|
199
|
+
if (!isBoardName(name)) return "a folder needs a name - lowercase letters, numbers and dashes";
|
|
200
|
+
if (seen.has(name)) return `folder "${name}" is listed twice`;
|
|
201
|
+
seen.add(name);
|
|
202
|
+
const o = f.order;
|
|
203
|
+
const p = f.parent;
|
|
204
|
+
if (p !== void 0 && !isBoardName(p)) return `folder "${name}" has an invalid parent - a folder name`;
|
|
205
|
+
const t = readTitle(f.title);
|
|
206
|
+
const d = readDescription(f.description);
|
|
207
|
+
const ty = readType(f.type);
|
|
208
|
+
out.push({
|
|
209
|
+
name,
|
|
210
|
+
...typeof o === "number" && Number.isFinite(o) ? { order: o } : {},
|
|
211
|
+
...p !== void 0 ? { parent: p } : {},
|
|
212
|
+
...t ? { title: t } : {},
|
|
213
|
+
...d ? { description: d } : {},
|
|
214
|
+
...ty ? { type: ty } : {}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const byName = new Map(out.map((f) => [f.name, f]));
|
|
218
|
+
for (const f of out) {
|
|
219
|
+
if (f.parent === void 0) continue;
|
|
220
|
+
if (version !== 2) return `folder "${f.name}" names a parent - nested folders need "version": 2`;
|
|
221
|
+
if (f.parent === f.name) return `folder "${f.name}" cannot be its own parent`;
|
|
222
|
+
const p = byName.get(f.parent);
|
|
223
|
+
if (!p) return `folder "${f.name}" names an unknown parent "${f.parent}"`;
|
|
224
|
+
if (p.parent !== void 0) return `folder "${f.name}" would sit three levels deep - folders nest one level only`;
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
/** Sidebar order from the files. At every level the boards and folders there share one
|
|
229
|
+
* sequence, ranked by `order`, then kind (board before folder), then name; unranked sorts
|
|
230
|
+
* after ranked. The root holds root boards and top-level folders (registered without a
|
|
231
|
+
* parent, or implied by a board that names an unregistered folder); a top-level folder holds
|
|
232
|
+
* its boards and its sub-folders; a sub-folder holds its boards. A folder's title and
|
|
233
|
+
* description and type ride on its item (they live in the registry a tree write rewrites); a board's
|
|
234
|
+
* title stays with its row - it lives in the board's own file. */
|
|
235
|
+
function buildTree(boards, folders) {
|
|
236
|
+
const reg = /* @__PURE__ */ new Map();
|
|
237
|
+
for (const f of folders) if (isBoardName(f.name) && !reg.has(f.name)) reg.set(f.name, f);
|
|
238
|
+
const parentOf = (n) => {
|
|
239
|
+
const p = reg.get(n)?.parent;
|
|
240
|
+
return p !== void 0 && p !== n && reg.has(p) && reg.get(p).parent === void 0 ? p : void 0;
|
|
241
|
+
};
|
|
242
|
+
const members = /* @__PURE__ */ new Map();
|
|
243
|
+
const implied = [];
|
|
244
|
+
const rootBoards = [];
|
|
245
|
+
for (const b of boards) {
|
|
246
|
+
if (!isBoardName(b.name) || b.name === "all-scenes") continue;
|
|
247
|
+
const folder = isBoardName(b.folder) ? b.folder : void 0;
|
|
248
|
+
if (!folder) {
|
|
249
|
+
rootBoards.push(b);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!reg.has(folder) && !implied.includes(folder)) implied.push(folder);
|
|
253
|
+
const list = members.get(folder) ?? [];
|
|
254
|
+
list.push(b);
|
|
255
|
+
members.set(folder, list);
|
|
256
|
+
}
|
|
257
|
+
const sorted = (xs) => xs.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === "board" ? -1 : 1) || a.item.name.localeCompare(b.item.name)).map((r) => r.item);
|
|
258
|
+
const boardsHere = (name) => (name === null ? rootBoards : members.get(name) ?? []).map((b) => ({
|
|
259
|
+
item: {
|
|
260
|
+
kind: "board",
|
|
261
|
+
name: b.name
|
|
262
|
+
},
|
|
263
|
+
order: b.order
|
|
264
|
+
}));
|
|
265
|
+
const folder = (name, kids) => ({
|
|
266
|
+
item: {
|
|
267
|
+
kind: "folder",
|
|
268
|
+
name,
|
|
269
|
+
items: sorted(kids),
|
|
270
|
+
...folderExtras(reg.get(name) ?? {})
|
|
271
|
+
},
|
|
272
|
+
order: reg.get(name)?.order
|
|
273
|
+
});
|
|
274
|
+
const subsOf = (top) => [...reg.keys()].filter((n) => parentOf(n) === top);
|
|
275
|
+
const tops = [...[...reg.keys()].filter((n) => !parentOf(n)), ...implied];
|
|
276
|
+
return sorted([...boardsHere(null), ...tops.map((t) => folder(t, [...boardsHere(t), ...subsOf(t).map((s) => folder(s, boardsHere(s)))]))]);
|
|
277
|
+
}
|
|
278
|
+
/** Every board in reading order, depth-first - the order the switchers and the landing pick use. */
|
|
279
|
+
function flatten(tree) {
|
|
280
|
+
const out = [];
|
|
281
|
+
const walk = (items) => {
|
|
282
|
+
for (const it of items) if (it.kind === "board") out.push(it.name);
|
|
283
|
+
else walk(it.items);
|
|
284
|
+
};
|
|
285
|
+
walk(tree);
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
const wireKids = (w) => w.items ?? w.boards ?? [];
|
|
289
|
+
function validateWire(wire) {
|
|
290
|
+
if (!Array.isArray(wire)) return "invalid tree";
|
|
291
|
+
const boards = /* @__PURE__ */ new Set(), folders = /* @__PURE__ */ new Set();
|
|
292
|
+
const board = (n) => {
|
|
293
|
+
if (!isBoardName(n) || n === "all-scenes") return "invalid board name in tree";
|
|
294
|
+
if (boards.has(n)) return `board "${n}" appears twice`;
|
|
295
|
+
boards.add(n);
|
|
296
|
+
return null;
|
|
297
|
+
};
|
|
298
|
+
const walk = (list, depth) => {
|
|
299
|
+
for (const w of list) {
|
|
300
|
+
if (typeof w === "string") {
|
|
301
|
+
const e = board(w);
|
|
302
|
+
if (e) return e;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (!w || typeof w !== "object" || Array.isArray(w)) return "invalid tree item";
|
|
306
|
+
if (depth >= 2) return "folders nest one level only";
|
|
307
|
+
const { folder, items, boards: legacy, title, description, type } = w;
|
|
308
|
+
if (!isBoardName(folder)) return "invalid folder name in tree";
|
|
309
|
+
if (title !== void 0 && (typeof title !== "string" || Array.from(title).length > 120)) return "invalid folder title";
|
|
310
|
+
if (description !== void 0 && (typeof description !== "string" || description.length > 300)) return "invalid folder description";
|
|
311
|
+
if (type !== void 0 && !readType(type)) return "invalid folder type";
|
|
312
|
+
if (folders.has(folder)) return `folder "${folder}" appears twice`;
|
|
313
|
+
folders.add(folder);
|
|
314
|
+
const kids = items ?? legacy;
|
|
315
|
+
if (!Array.isArray(kids)) return "invalid folder in tree";
|
|
316
|
+
if (items === void 0 && kids.some((k) => typeof k !== "string")) return "invalid folder in tree";
|
|
317
|
+
const e = walk(kids, depth + 1);
|
|
318
|
+
if (e) return e;
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
};
|
|
322
|
+
const e = walk(wire, 0);
|
|
323
|
+
if (e) return e;
|
|
324
|
+
if (boards.size > 200 || folders.size > 50) return "tree too large";
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
/** Every folder with the folder it sits in (null = the root), top-level folders first in
|
|
328
|
+
* reading order, each followed by its sub-folders. */
|
|
329
|
+
function folderEntries(t) {
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const it of t) {
|
|
332
|
+
if (it.kind !== "folder") continue;
|
|
333
|
+
out.push({
|
|
334
|
+
folder: it,
|
|
335
|
+
parent: null
|
|
336
|
+
});
|
|
337
|
+
for (const k of it.items) if (k.kind === "folder") out.push({
|
|
338
|
+
folder: k,
|
|
339
|
+
parent: it.name
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
/** Every board's folder, in one pass - for callers that ask for many boards (folderOf walks the tree). */
|
|
345
|
+
function folderMap(t) {
|
|
346
|
+
const out = /* @__PURE__ */ new Map();
|
|
347
|
+
for (const { folder } of folderEntries(t)) for (const k of folder.items) if (k.kind === "board") out.set(k.name, folder.name);
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/server/boards.ts
|
|
352
|
+
/**
|
|
353
|
+
* Reading design/boards/ safely - the one enumerator the dev API, the build and the tests
|
|
354
|
+
* share. Only REGULAR files on the board-name grammar count as boards (a symlink, dangling or
|
|
355
|
+
* live, could read or publish JSON from outside the project - it is skipped, and reported so
|
|
356
|
+
* the build can fail closed). The folder registry is read the same way: absent = no folders,
|
|
357
|
+
* malformed = an error the human must fix, never a silently empty registry.
|
|
358
|
+
*/
|
|
359
|
+
/** Does realpath(dir) stay inside realpath(root)? A symlinked design/boards can't escape. */
|
|
360
|
+
function underRoot(root, dir) {
|
|
361
|
+
try {
|
|
362
|
+
const rr = realpathSync(root);
|
|
363
|
+
const rd = realpathSync(dir);
|
|
364
|
+
return rd === rr || rd.startsWith(rr + sep);
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Is design/boards a directory we may read and write? It must not be a symlink at all (a
|
|
370
|
+
* link to the repo root would list package.json as a board and let a tree write rewrite
|
|
371
|
+
* it; a link outside would publish foreign JSON) and must resolve inside the root. Absent
|
|
372
|
+
* is fine (no boards yet). Returns the error, or null. */
|
|
373
|
+
function checkBoardsDir(root, boardsDir) {
|
|
374
|
+
return checkRealDirs(root, [[join(boardsDir, ".."), "design"], [boardsDir, "design/boards"]]);
|
|
375
|
+
}
|
|
376
|
+
/** Every EXISTING path in `dirs` (root-down order) must be a real directory inside the root -
|
|
377
|
+
* a symlinked `design` with no boards dir yet would otherwise be followed by the mkdir that
|
|
378
|
+
* creates it; a symlinked `design/scenes` would let a brief write land outside the project.
|
|
379
|
+
* An absent one ends the walk (nothing beneath it exists either). Returns the error, or null. */
|
|
380
|
+
function checkRealDirs(root, dirs) {
|
|
381
|
+
for (const [p, label] of dirs) {
|
|
382
|
+
try {
|
|
383
|
+
if (lstatSync(p).isSymbolicLink()) return `${label} must be a real directory, not a symlink`;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
if (!underRoot(root, p)) return `${label} escapes the project`;
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
/** A regular file (lstat: a symlink is never followed, a dangling one is not "absent"). */
|
|
392
|
+
const isRegularFile = (p) => {
|
|
393
|
+
try {
|
|
394
|
+
return lstatSync(p).isFile();
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
/** Is there ANY node at p (a dangling symlink counts)? */
|
|
400
|
+
const nodeExists = (p) => {
|
|
401
|
+
try {
|
|
402
|
+
lstatSync(p);
|
|
403
|
+
return true;
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
/** Every board file: name, raw content, hash, and its JSON (null when malformed). `skipped`
|
|
409
|
+
* names the entries that looked like boards but were not regular files. */
|
|
410
|
+
function listBoardFiles(boardsDir) {
|
|
411
|
+
const boards = [], skipped = [];
|
|
412
|
+
if (!existsSync(boardsDir)) return {
|
|
413
|
+
boards,
|
|
414
|
+
skipped
|
|
415
|
+
};
|
|
416
|
+
for (const f of readdirSync(boardsDir)) {
|
|
417
|
+
if (!isBoardFile(f)) continue;
|
|
418
|
+
const file = join(boardsDir, f);
|
|
419
|
+
if (!isRegularFile(file)) {
|
|
420
|
+
skipped.push(f);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const content = readFileSync(file, "utf8");
|
|
424
|
+
let json = null;
|
|
425
|
+
try {
|
|
426
|
+
json = JSON.parse(content);
|
|
427
|
+
} catch {}
|
|
428
|
+
boards.push({
|
|
429
|
+
name: f.slice(0, -5),
|
|
430
|
+
file,
|
|
431
|
+
content,
|
|
432
|
+
sha256: hash(content),
|
|
433
|
+
json
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
boards,
|
|
438
|
+
skipped
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function boardFields(json, validName) {
|
|
442
|
+
const o = json;
|
|
443
|
+
const title = readTitle(o?.title);
|
|
444
|
+
const description = readDescription(o?.description);
|
|
445
|
+
const type = readType(o?.type), capability = readCapability(o?.capability), status = readStatusWord(o?.status), reason = readReason(o?.reason);
|
|
446
|
+
return {
|
|
447
|
+
...typeof o?.order === "number" && Number.isFinite(o.order) ? { order: o.order } : {},
|
|
448
|
+
...validName(o?.folder) ? { folder: o.folder } : {},
|
|
449
|
+
...title ? { title } : {},
|
|
450
|
+
...description ? { description } : {},
|
|
451
|
+
...type ? { type } : {},
|
|
452
|
+
...capability ? { capability } : {},
|
|
453
|
+
...status ? { status } : {},
|
|
454
|
+
...reason ? { reason } : {}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** The fields a board's file owns that the shell's save shape never carries: an autosave keeps
|
|
458
|
+
* each one from disk when the incoming board omits it (spec 20 - the fields survive every write). */
|
|
459
|
+
const AUTHOR_FIELDS = [
|
|
460
|
+
"order",
|
|
461
|
+
"folder",
|
|
462
|
+
"title",
|
|
463
|
+
"description",
|
|
464
|
+
"type",
|
|
465
|
+
"capability",
|
|
466
|
+
"status",
|
|
467
|
+
"reason"
|
|
468
|
+
];
|
|
469
|
+
/** The folder registry. `sha256` is the CAS token a tree write must echo (null = "there was
|
|
470
|
+
* no file"), so a write can never silently replace a registry it never saw. */
|
|
471
|
+
function readRegistry(boardsDir) {
|
|
472
|
+
const p = join(boardsDir, FOLDERS_FILE);
|
|
473
|
+
if (!nodeExists(p)) return {
|
|
474
|
+
state: "absent",
|
|
475
|
+
folders: [],
|
|
476
|
+
sha256: null
|
|
477
|
+
};
|
|
478
|
+
if (!isRegularFile(p)) return {
|
|
479
|
+
state: "malformed",
|
|
480
|
+
error: `design/boards/${FOLDERS_FILE} must be a regular file, not a symlink`,
|
|
481
|
+
sha256: null
|
|
482
|
+
};
|
|
483
|
+
const content = readFileSync(p, "utf8");
|
|
484
|
+
let raw;
|
|
485
|
+
try {
|
|
486
|
+
raw = JSON.parse(content);
|
|
487
|
+
} catch {
|
|
488
|
+
return {
|
|
489
|
+
state: "malformed",
|
|
490
|
+
error: `design/boards/${FOLDERS_FILE} is not valid JSON - fix the file`,
|
|
491
|
+
sha256: hash(content)
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const parsed = parseFolders(raw);
|
|
495
|
+
if (typeof parsed === "string") return {
|
|
496
|
+
state: "malformed",
|
|
497
|
+
error: `design/boards/${FOLDERS_FILE}: ${parsed}`,
|
|
498
|
+
sha256: hash(content)
|
|
499
|
+
};
|
|
500
|
+
return {
|
|
501
|
+
state: "ok",
|
|
502
|
+
folders: parsed,
|
|
503
|
+
sha256: hash(content)
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** The registry's write lock - one writer at a time across processes (the dev server's tree write,
|
|
507
|
+
* `folders add`, `init --kind`), so a read-modify-write of `_folders.json` is atomic among Marver's
|
|
508
|
+
* writers. A lock older than 10 s is a crashed writer's and is taken over. `wait` = how long to
|
|
509
|
+
* try (the CLI waits; the dev server never blocks its event loop - it answers 409 instead). */
|
|
510
|
+
const REGISTRY_LOCK = ".folders.lock";
|
|
511
|
+
function withRegistryLock(dir, wait, fn) {
|
|
512
|
+
const lock = join(dir, REGISTRY_LOCK);
|
|
513
|
+
const t0 = Date.now();
|
|
514
|
+
for (;;) try {
|
|
515
|
+
writeFileSync(lock, `${process.pid} ${Date.now()}\n`, { flag: "wx" });
|
|
516
|
+
break;
|
|
517
|
+
} catch (e) {
|
|
518
|
+
if (e.code !== "EEXIST") throw e;
|
|
519
|
+
try {
|
|
520
|
+
if (Date.now() - lstatSync(lock).mtimeMs > 1e4) {
|
|
521
|
+
rmSync(lock, { force: true });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
} catch {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (Date.now() - t0 >= wait) return null;
|
|
528
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
return fn();
|
|
532
|
+
} finally {
|
|
533
|
+
rmSync(lock, { force: true });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/** Append typed folders to the registry (spec 20: `init --kind`, `folders add`). Never renames,
|
|
537
|
+
* moves or retypes a folder that exists - a name already registered is skipped and reported - and
|
|
538
|
+
* never drops a field of an existing entry it does not manage. New folders rank after everything
|
|
539
|
+
* already at the root. The write is compare-and-swap: the registry is re-read just before the
|
|
540
|
+
* atomic rename, and a change since the first read (the shell's drag, another agent) starts the
|
|
541
|
+
* append over from the new file. A malformed registry is an error, never overwritten. */
|
|
542
|
+
function addFolders(root, folders) {
|
|
543
|
+
const dir = join(root, "design", "boards");
|
|
544
|
+
const de = checkBoardsDir(root, dir);
|
|
545
|
+
if (de) throw new Error(de);
|
|
546
|
+
mkdirSync(dir, { recursive: true });
|
|
547
|
+
const file = join(dir, FOLDERS_FILE);
|
|
548
|
+
const done = withRegistryLock(dir, 5e3, () => appendFolders(dir, file, folders));
|
|
549
|
+
if (!done) throw new Error(`design/boards/${FOLDERS_FILE} is being written by another process - try again`);
|
|
550
|
+
return done;
|
|
551
|
+
}
|
|
552
|
+
function appendFolders(dir, file, folders) {
|
|
553
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
554
|
+
const before = nodeExists(file) ? readFileSync(file, "utf8") : null;
|
|
555
|
+
const reg = readRegistry(dir);
|
|
556
|
+
if (reg.state === "malformed") throw new Error(reg.error);
|
|
557
|
+
let raw = [];
|
|
558
|
+
if (before !== null) {
|
|
559
|
+
const j = JSON.parse(before);
|
|
560
|
+
raw = Array.isArray(j.folders) ? j.folders : [];
|
|
561
|
+
}
|
|
562
|
+
const have = new Set(raw.map((f) => f.name));
|
|
563
|
+
const rootRanks = [...raw.filter((f) => f.parent === void 0).map((f) => f.order), ...listBoardFiles(dir).boards.map((b) => {
|
|
564
|
+
const o = b.json;
|
|
565
|
+
return o?.folder ? void 0 : o?.order;
|
|
566
|
+
})].filter((o) => typeof o === "number" && Number.isFinite(o));
|
|
567
|
+
let next = rootRanks.length ? Math.max(...rootRanks) + 1 : 0;
|
|
568
|
+
const added = [], existing = [];
|
|
569
|
+
const rows = [...raw];
|
|
570
|
+
for (const f of folders) {
|
|
571
|
+
if (have.has(f.name)) {
|
|
572
|
+
existing.push(f.name);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
rows.push({
|
|
576
|
+
name: f.name,
|
|
577
|
+
order: next++,
|
|
578
|
+
...f.title ? { title: f.title } : {},
|
|
579
|
+
...f.type ? { type: f.type } : {}
|
|
580
|
+
});
|
|
581
|
+
have.add(f.name);
|
|
582
|
+
added.push(f.name);
|
|
583
|
+
}
|
|
584
|
+
if (!added.length) return {
|
|
585
|
+
added,
|
|
586
|
+
existing
|
|
587
|
+
};
|
|
588
|
+
const version = rows.some((f) => f.parent !== void 0) ? 2 : 1;
|
|
589
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.${attempt}.tmp`;
|
|
590
|
+
writeFileSync(tmp, JSON.stringify({
|
|
591
|
+
version,
|
|
592
|
+
folders: rows
|
|
593
|
+
}, null, 2) + "\n", { flag: "wx" });
|
|
594
|
+
if ((nodeExists(file) ? readFileSync(file, "utf8") : null) !== before) {
|
|
595
|
+
rmSync(tmp, { force: true });
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
renameSync(tmp, file);
|
|
599
|
+
return {
|
|
600
|
+
added,
|
|
601
|
+
existing
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
throw new Error(`design/boards/${FOLDERS_FILE} kept changing while folders were added - try again`);
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/shared/context.ts
|
|
608
|
+
const LEVEL = /`(confirmed|reported|unknown)`/g;
|
|
609
|
+
/** What counts as a citation beside a level: a `path:line`, a run id, a cited file, or a link to a
|
|
610
|
+
* system of record (a deploy run's page). A cited file must also resolve - the check sees to it. */
|
|
611
|
+
const CITATION = /`[^`\s]+:\d+(-\d+)?`|\bruns? \d{6,}(, \d{6,})*|`[^`\s]+\.(md|json|ts|tsx|js|mjs|yml|yaml|sql)`|\]\(https?:\/\/[^)\s]+\)/;
|
|
612
|
+
/** A cited repository file inside a cell, with or without a line: `path/to/x.ts`, `CHANGELOG.md:12`. */
|
|
613
|
+
const CITED_FILE = /`((?:\.{0,2}[\w@.-]+\/)*[\w@.$-]+\.(?:md|json|ts|tsx|js|jsx|mjs|yml|yaml|sql|txt))(?::(\d+)(?:-(\d+))?)?`/g;
|
|
614
|
+
const readAudience = (v) => v === "publishable" || v === "restricted" ? v : "team";
|
|
615
|
+
const AUDIENCE_RANK = {
|
|
616
|
+
publishable: 0,
|
|
617
|
+
team: 1,
|
|
618
|
+
restricted: 2
|
|
619
|
+
};
|
|
620
|
+
/** The strictest of several audiences - what a conclusion drawn from them may be shown to. */
|
|
621
|
+
const strictest = (...a) => a.reduce((x, y) => AUDIENCE_RANK[y] > AUDIENCE_RANK[x] ? y : x, "publishable");
|
|
622
|
+
/** Line endings normalized: every reader parses LF, so a CRLF file means the same thing. */
|
|
623
|
+
const lf = (text) => text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
624
|
+
/** The headers whose cells carry evidence in a shipped table. */
|
|
625
|
+
const EVIDENCE_COLUMN = /^(evidence|verified|available|delivered)$/i;
|
|
626
|
+
/** A Marver-managed file's first line (the playbooks Marver maintains) - front matter follows it. */
|
|
627
|
+
const MANAGED_LINE = /^<!-- marver:managed [^\n]*-->\n/;
|
|
628
|
+
function frontMatter(raw) {
|
|
629
|
+
const text = lf(raw);
|
|
630
|
+
const managed = MANAGED_LINE.exec(text);
|
|
631
|
+
if (managed) {
|
|
632
|
+
const r = frontMatter(text.slice(managed[0].length));
|
|
633
|
+
return {
|
|
634
|
+
...r,
|
|
635
|
+
offset: r.offset + 1
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (!text.startsWith("---\n")) return {
|
|
639
|
+
data: null,
|
|
640
|
+
body: text,
|
|
641
|
+
offset: 0
|
|
642
|
+
};
|
|
643
|
+
const m = text.match(/^---\n([\s\S]*?)\n---[ \t]*(\n|$)/);
|
|
644
|
+
if (!m) return {
|
|
645
|
+
data: null,
|
|
646
|
+
body: text,
|
|
647
|
+
offset: 0,
|
|
648
|
+
error: "front matter opens with --- and never closes"
|
|
649
|
+
};
|
|
650
|
+
const data = {};
|
|
651
|
+
let key = null;
|
|
652
|
+
for (const line of m[1].split("\n")) {
|
|
653
|
+
const item = line.match(/^\s+-\s+(.*)$/);
|
|
654
|
+
if (item && key) {
|
|
655
|
+
if (!Array.isArray(data[key])) data[key] = [];
|
|
656
|
+
data[key].push(scalar(item[1]));
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
660
|
+
if (!kv) continue;
|
|
661
|
+
key = kv[1];
|
|
662
|
+
data[key] = kv[2] === "" ? [] : value(kv[2]);
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
data,
|
|
666
|
+
body: text.slice(m[0].length),
|
|
667
|
+
offset: m[0].split("\n").length - 1
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function scalar(s) {
|
|
671
|
+
s = s.trim().replace(/\s+#.*$/, "");
|
|
672
|
+
if (/^".*"$|^'.*'$/.test(s)) return s.slice(1, -1);
|
|
673
|
+
return s;
|
|
674
|
+
}
|
|
675
|
+
function splitTop(s) {
|
|
676
|
+
const out = [];
|
|
677
|
+
let depth = 0, cur = "", q = null;
|
|
678
|
+
for (const ch of s) {
|
|
679
|
+
if (q) {
|
|
680
|
+
cur += ch;
|
|
681
|
+
if (ch === q) q = null;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (ch === "\"" || ch === "'") {
|
|
685
|
+
q = ch;
|
|
686
|
+
cur += ch;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (ch === "{" || ch === "[") depth++;
|
|
690
|
+
if (ch === "}" || ch === "]") depth--;
|
|
691
|
+
if (ch === "," && depth === 0) {
|
|
692
|
+
out.push(cur);
|
|
693
|
+
cur = "";
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
cur += ch;
|
|
697
|
+
}
|
|
698
|
+
if (cur.trim()) out.push(cur);
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
function value(s) {
|
|
702
|
+
s = s.trim().replace(/\s+#[^"'}\]]*$/, "");
|
|
703
|
+
if (s.startsWith("{") && s.endsWith("}")) {
|
|
704
|
+
const o = {};
|
|
705
|
+
for (const part of splitTop(s.slice(1, -1))) {
|
|
706
|
+
const i = part.indexOf(":");
|
|
707
|
+
if (i > 0) o[part.slice(0, i).trim()] = value(part.slice(i + 1));
|
|
708
|
+
}
|
|
709
|
+
return o;
|
|
710
|
+
}
|
|
711
|
+
if (s.startsWith("[") && s.endsWith("]")) return splitTop(s.slice(1, -1)).map((x) => value(x));
|
|
712
|
+
return scalar(s);
|
|
713
|
+
}
|
|
714
|
+
/** Lines outside fenced code blocks, with their 1-based numbers in the whole file. */
|
|
715
|
+
function proseLines(text, offset = 0) {
|
|
716
|
+
const out = [];
|
|
717
|
+
let fenced = false;
|
|
718
|
+
lf(text).split("\n").forEach((line, i) => {
|
|
719
|
+
if (/^\s*```/.test(line)) {
|
|
720
|
+
fenced = !fenced;
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (!fenced) out.push([i + 1 + offset, line]);
|
|
724
|
+
});
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
function tables(text) {
|
|
728
|
+
const out = [];
|
|
729
|
+
let cur = null;
|
|
730
|
+
for (const [n, line] of proseLines(text)) {
|
|
731
|
+
if (!/^\s*\|/.test(line)) {
|
|
732
|
+
cur = null;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (/^\s*\|[\s|:-]+\|\s*$/.test(line)) continue;
|
|
736
|
+
const cells = splitRow(line);
|
|
737
|
+
if (!cur) {
|
|
738
|
+
cur = {
|
|
739
|
+
header: cells,
|
|
740
|
+
headerLine: n,
|
|
741
|
+
rows: []
|
|
742
|
+
};
|
|
743
|
+
out.push(cur);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
cur.rows.push({
|
|
747
|
+
line: n,
|
|
748
|
+
cells
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return out;
|
|
752
|
+
}
|
|
753
|
+
/** A table row's cells - pipes inside backticks stay in their cell. */
|
|
754
|
+
function splitRow(line) {
|
|
755
|
+
const s = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
756
|
+
const out = [];
|
|
757
|
+
let cur = "", code = false;
|
|
758
|
+
for (const ch of s) {
|
|
759
|
+
if (ch === "`") code = !code;
|
|
760
|
+
if (ch === "|" && !code) {
|
|
761
|
+
out.push(cur.trim());
|
|
762
|
+
cur = "";
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
cur += ch;
|
|
766
|
+
}
|
|
767
|
+
out.push(cur.trim());
|
|
768
|
+
return out;
|
|
769
|
+
}
|
|
770
|
+
const levelsIn = (cell) => [...cell.matchAll(LEVEL)].map((m) => m[1]);
|
|
771
|
+
/** A table whose header row has no outer pipes - Markdown renders it, the readers here do not. The
|
|
772
|
+
* check rejects it rather than read past it. Returns the separator rows' line numbers. */
|
|
773
|
+
function looseTables(text) {
|
|
774
|
+
return proseLines(text).filter(([, l]) => !/^\s*\|/.test(l) && /^\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(l)).map(([n]) => n);
|
|
775
|
+
}
|
|
776
|
+
/** The levels an availability cell grants, clause by clause (`;` separates them). A clause counts
|
|
777
|
+
* only when it claims availability: never one carrying a negation anywhere ("nowhere", "not
|
|
778
|
+
* available", "rolled back", "withdrawn") - and, for a product's Available cell, never one scoped to
|
|
779
|
+
* a pre-production place (staging, preview, sandbox) that does not also name production. A
|
|
780
|
+
* knowledge-work Delivered cell has no environments: only negations void it. */
|
|
781
|
+
const NEGATION = /\b(nowhere|none|never|no longer|not (yet )?(available|live|deployed|delivered|on|in|shipped|released|out)|not yet|pending|planned|scheduled|upcoming|awaiting|withdrawn|rolled back|reverted|removed|retired|pulled)\b|^\s*not\b/i;
|
|
782
|
+
/** The places a product is NOT yet available to its users, unless the clause also names production. */
|
|
783
|
+
const PRE_PRODUCTION = /\b(staging|preview|sandbox|dev|development|local|locally|testing|test environment|qa)\b/i;
|
|
784
|
+
function availableLevels(cell, kind = "available") {
|
|
785
|
+
const out = [];
|
|
786
|
+
for (const clause of cell.split(";")) {
|
|
787
|
+
const c = clause.replace(/\*\*/g, "").trim();
|
|
788
|
+
if (NEGATION.test(c)) continue;
|
|
789
|
+
if (kind === "available" && PRE_PRODUCTION.test(c) && !/\b(production|prod)\b/i.test(c)) continue;
|
|
790
|
+
out.push(...levelsIn(c));
|
|
791
|
+
}
|
|
792
|
+
return out;
|
|
793
|
+
}
|
|
794
|
+
const RECORD_KEY = /^(capability|project|deliverable)$/i;
|
|
795
|
+
const RECORD_AVAILABLE = /^(available|delivered)$/i;
|
|
796
|
+
const isRecordTable = (t) => RECORD_KEY.test(t.header[0] ?? "") && t.header.some((h) => RECORD_AVAILABLE.test(h));
|
|
797
|
+
function shippedRows(text) {
|
|
798
|
+
const out = [];
|
|
799
|
+
for (const t of tables(text)) {
|
|
800
|
+
if (!isRecordTable(t)) continue;
|
|
801
|
+
const a = t.header.findIndex((h) => RECORD_AVAILABLE.test(h));
|
|
802
|
+
const kind = /^delivered$/i.test(t.header[a]) ? "delivered" : "available";
|
|
803
|
+
for (const r of t.rows) {
|
|
804
|
+
const slug = /`([a-z0-9][a-z0-9-]*)`/.exec(r.cells[0] ?? "")?.[1];
|
|
805
|
+
if (!slug) continue;
|
|
806
|
+
const available = r.cells[a] ?? "";
|
|
807
|
+
out.push({
|
|
808
|
+
capability: slug,
|
|
809
|
+
line: r.line,
|
|
810
|
+
available,
|
|
811
|
+
levels: availableLevels(available, kind)
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return out;
|
|
816
|
+
}
|
|
817
|
+
/** Is this a glob the matcher reads? Balanced, unnested braces; no character classes. */
|
|
818
|
+
const validGlob = (g) => {
|
|
819
|
+
if (typeof g !== "string" || !g || /[[\]]/.test(g)) return false;
|
|
820
|
+
let depth = 0;
|
|
821
|
+
for (const c of g) if (c === "{") {
|
|
822
|
+
if (++depth > 1) return false;
|
|
823
|
+
} else if (c === "}") {
|
|
824
|
+
if (--depth < 0) return false;
|
|
825
|
+
}
|
|
826
|
+
return depth === 0;
|
|
827
|
+
};
|
|
828
|
+
const hasGlob = (g) => /[*?{]/.test(g);
|
|
829
|
+
/** A glob with **, *, ? and {a,b} as a RegExp over repo-relative paths (validGlob first). */
|
|
830
|
+
function globRe(glob) {
|
|
831
|
+
let re = "", brace = 0;
|
|
832
|
+
for (let i = 0; i < glob.length; i++) {
|
|
833
|
+
const c = glob[i];
|
|
834
|
+
if (c === "*") {
|
|
835
|
+
if (glob[i + 1] === "*") {
|
|
836
|
+
i++;
|
|
837
|
+
if (glob[i + 1] === "/") {
|
|
838
|
+
i++;
|
|
839
|
+
re += "(?:[\\s\\S]*/)?";
|
|
840
|
+
} else re += "[\\s\\S]*";
|
|
841
|
+
} else re += "[^/]*";
|
|
842
|
+
} else if (c === "?") re += "[^/]";
|
|
843
|
+
else if (c === "{") {
|
|
844
|
+
brace++;
|
|
845
|
+
re += "(?:";
|
|
846
|
+
} else if (c === "}" && brace) {
|
|
847
|
+
brace--;
|
|
848
|
+
re += ")";
|
|
849
|
+
} else if (c === "," && brace) re += "|";
|
|
850
|
+
else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
851
|
+
}
|
|
852
|
+
return new RegExp(`^${re}$`);
|
|
853
|
+
}
|
|
854
|
+
/** Read a map's text: the map, or what is wrong with it - every field checked, every glob valid. */
|
|
855
|
+
function parseMap(text) {
|
|
856
|
+
let raw;
|
|
857
|
+
try {
|
|
858
|
+
raw = JSON.parse(text);
|
|
859
|
+
} catch {
|
|
860
|
+
return "context/map.json is not valid JSON";
|
|
861
|
+
}
|
|
862
|
+
const m = raw;
|
|
863
|
+
if (!m || typeof m !== "object" || !m.capabilities || typeof m.capabilities !== "object" || Array.isArray(m.capabilities)) return "context/map.json needs a \"capabilities\" object";
|
|
864
|
+
const globs = (where, v, required) => {
|
|
865
|
+
if (v === void 0 && !required) return null;
|
|
866
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) return `context/map.json: ${where} must be a list of paths`;
|
|
867
|
+
const bad = v.findIndex((g) => !validGlob(g));
|
|
868
|
+
return bad >= 0 ? `context/map.json: ${where} has an invalid glob "${v[bad]}" (not empty; balanced, unnested {a,b}; no [...])` : null;
|
|
869
|
+
};
|
|
870
|
+
for (const [k, c] of Object.entries(m.capabilities)) {
|
|
871
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(k)) return `context/map.json: "${k}" is not a capability slug`;
|
|
872
|
+
const cc = c;
|
|
873
|
+
if (!cc || typeof cc !== "object") return `context/map.json: "${k}" must be an object`;
|
|
874
|
+
const e = globs(`"${k}".paths`, cc.paths, true) ?? globs(`"${k}".tests`, cc.tests, false);
|
|
875
|
+
if (e) return e;
|
|
876
|
+
if (cc.contract !== void 0 && typeof cc.contract !== "string") return `context/map.json: "${k}".contract must be a path`;
|
|
877
|
+
if (cc.summary !== void 0 && typeof cc.summary !== "string") return `context/map.json: "${k}".summary must be text`;
|
|
878
|
+
}
|
|
879
|
+
if (m.excluded !== void 0) {
|
|
880
|
+
if (!Array.isArray(m.excluded)) return "context/map.json: \"excluded\" must be a list";
|
|
881
|
+
for (const x of m.excluded) {
|
|
882
|
+
const e = x;
|
|
883
|
+
if (!e || typeof e.path !== "string" || !validGlob(e.path)) return "context/map.json: every \"excluded\" entry needs a valid \"path\" glob";
|
|
884
|
+
if (typeof e.reason !== "string" || !e.reason.trim()) return `context/map.json: excluded "${e.path}" needs a "reason"`;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
{
|
|
888
|
+
const e = globs("\"source\"", m.source, false);
|
|
889
|
+
if (e) return e;
|
|
890
|
+
}
|
|
891
|
+
const source = Array.isArray(m.source) ? m.source.filter((x) => typeof x === "string") : void 0;
|
|
892
|
+
return {
|
|
893
|
+
capabilities: m.capabilities,
|
|
894
|
+
excluded: Array.isArray(m.excluded) ? m.excluded : [],
|
|
895
|
+
...source ? { source } : {}
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
/** The index's generated capability table, between `<!-- generated ... -->` fences. */
|
|
899
|
+
const GENERATED = /<!-- generated[^>]*-->([\s\S]*?)<!-- \/generated -->/;
|
|
900
|
+
/** The table the index generates from the map: one row per capability, its contract linked. */
|
|
901
|
+
function capabilityTable(map) {
|
|
902
|
+
return [
|
|
903
|
+
"| Capability | Contract |",
|
|
904
|
+
"|---|---|",
|
|
905
|
+
...Object.entries(map.capabilities).map(([k, c]) => {
|
|
906
|
+
const name = `\`${k}\`${typeof c.summary === "string" && c.summary.trim() ? ` - ${c.summary.trim().replace(/\|/g, "/")}` : ""}`;
|
|
907
|
+
const rel = c.contract?.replace(/^context\//, "");
|
|
908
|
+
return rel ? `| ${name} | [\`${rel}\`](${rel}) |` : `| ${name} | none yet - see the map |`;
|
|
909
|
+
})
|
|
910
|
+
].join("\n");
|
|
911
|
+
}
|
|
912
|
+
/** Words in a document's body (front matter excluded) - the index's budget counts these. */
|
|
913
|
+
const wordCount = (text) => frontMatter(text).body.split(/\s+/).filter(Boolean).length;
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/shared/status.ts
|
|
916
|
+
/**
|
|
917
|
+
* Status (spec 20) - one resolver, shared by the dev server and the build: files in, a status and
|
|
918
|
+
* its evidence out, pure. Feature and project boards wear it; read top to bottom, the first row
|
|
919
|
+
* that matches wins:
|
|
920
|
+
*
|
|
921
|
+
* 1-3 the board says archived, paused or blocked (with its reason) - a decision, by hand
|
|
922
|
+
* 4 the evidence it needs cannot be read - Unknown, never a stale Done
|
|
923
|
+
* 5 an open plan names the capability - In progress, filling by phase
|
|
924
|
+
* 6 the shipped record shows it available, `confirmed` - Done
|
|
925
|
+
* 7 ... `reported` only - Done, reported
|
|
926
|
+
* 8 an accepted contract (`state: current`), no availability - To do
|
|
927
|
+
* 9 anything else - Backlog
|
|
928
|
+
*
|
|
929
|
+
* Without `context/`, a board may also say in-progress, todo or backlog by hand. Done is never
|
|
930
|
+
* set by hand: `"status": "done"` is ignored here and reported by `marver context check`.
|
|
931
|
+
*/
|
|
932
|
+
const STATUS_LABEL = {
|
|
933
|
+
archived: "Archived",
|
|
934
|
+
paused: "Paused",
|
|
935
|
+
blocked: "Blocked",
|
|
936
|
+
unknown: "Unknown",
|
|
937
|
+
"in-progress": "In progress",
|
|
938
|
+
done: "Done",
|
|
939
|
+
"done-reported": "Done, reported",
|
|
940
|
+
todo: "To do",
|
|
941
|
+
backlog: "Backlog"
|
|
942
|
+
};
|
|
943
|
+
const PHASE_LABEL = {
|
|
944
|
+
1: "spec",
|
|
945
|
+
2: "lo-fi",
|
|
946
|
+
3: "hi-fi"
|
|
947
|
+
};
|
|
948
|
+
const NO_CONTEXT = {
|
|
949
|
+
present: false,
|
|
950
|
+
unreadable: /* @__PURE__ */ new Map(),
|
|
951
|
+
shipped: /* @__PURE__ */ new Map(),
|
|
952
|
+
contracts: /* @__PURE__ */ new Map(),
|
|
953
|
+
plans: /* @__PURE__ */ new Map()
|
|
954
|
+
};
|
|
955
|
+
const PHASE_WORDS = {
|
|
956
|
+
spec: 1,
|
|
957
|
+
specs: 1,
|
|
958
|
+
lofi: 2,
|
|
959
|
+
"lo-fi": 2,
|
|
960
|
+
hifi: 3,
|
|
961
|
+
"hi-fi": 3
|
|
962
|
+
};
|
|
963
|
+
/** The deciding phase and where it came from - a brief's phase carries that brief's audience. */
|
|
964
|
+
function phaseSource(capability, scenes) {
|
|
965
|
+
let best;
|
|
966
|
+
for (const s of scenes) {
|
|
967
|
+
const declared = s.phase ? PHASE_WORDS[s.phase.toLowerCase()] : void 0;
|
|
968
|
+
const named = s.name === `${capability}-specs` || s.name === `${capability}-spec` ? 1 : s.name === `${capability}-lofi` ? 2 : s.name === capability ? 3 : void 0;
|
|
969
|
+
const p = declared ?? named;
|
|
970
|
+
if (p && (!best || p > best.phase)) best = {
|
|
971
|
+
phase: p,
|
|
972
|
+
scene: s.name,
|
|
973
|
+
fromBrief: !!declared,
|
|
974
|
+
audience: declared ? s.audience ?? "team" : "publishable"
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
return best;
|
|
978
|
+
}
|
|
979
|
+
/** A board's status, or null when its type carries none. */
|
|
980
|
+
function resolveStatus(b, ctx) {
|
|
981
|
+
if (!HAS_STATUS.includes(b.type)) return null;
|
|
982
|
+
const capability = b.capability ?? b.name;
|
|
983
|
+
const out = (status, row, evidence, audience, extra = {}) => ({
|
|
984
|
+
status,
|
|
985
|
+
row,
|
|
986
|
+
capability,
|
|
987
|
+
evidence,
|
|
988
|
+
audience,
|
|
989
|
+
...extra
|
|
990
|
+
});
|
|
991
|
+
if (b.status && DECISIONS.includes(b.status)) {
|
|
992
|
+
const by = `design/boards/${b.name}.json: "status": "${b.status}"`;
|
|
993
|
+
return out(b.status, DECISIONS.indexOf(b.status) + 1, [by], "team", b.status === "blocked" && b.reason ? { reason: b.reason } : {});
|
|
994
|
+
}
|
|
995
|
+
if (!ctx.present) {
|
|
996
|
+
if (b.status === "in-progress") {
|
|
997
|
+
const f = fillOf(capability, b.scenes);
|
|
998
|
+
return out("in-progress", 5, [`design/boards/${b.name}.json: by hand`, ...f.evidence], strictest("publishable", ...f.audience), f.fill);
|
|
999
|
+
}
|
|
1000
|
+
if (b.status === "todo") return out("todo", 8, [`design/boards/${b.name}.json: by hand`], "publishable");
|
|
1001
|
+
return out("backlog", 9, [b.status === "backlog" ? `design/boards/${b.name}.json: by hand` : "no context/ - nothing records it"], "publishable");
|
|
1002
|
+
}
|
|
1003
|
+
const bad = ctx.unreadable.get("*") ?? ctx.unreadable.get(capability);
|
|
1004
|
+
if (bad) return out("unknown", 4, [bad], "team");
|
|
1005
|
+
const row = ctx.shipped.get(capability);
|
|
1006
|
+
const live = row?.levels.includes("confirmed") ? "confirmed" : row?.levels.includes("reported") ? "reported" : null;
|
|
1007
|
+
const plans = ctx.plans.get(capability);
|
|
1008
|
+
if (plans?.length) {
|
|
1009
|
+
const f = fillOf(capability, b.scenes);
|
|
1010
|
+
const ev = [...plans.map((p) => `${p.where}: an open plan`), ...f.evidence];
|
|
1011
|
+
if (live && row) ev.push(`${row.where}: available, \`${live}\` - this is the next version`);
|
|
1012
|
+
return out("in-progress", 5, ev, strictest(...plans.map((p) => p.audience), ...f.audience, ...live && row ? [row.audience] : []), f.fill);
|
|
1013
|
+
}
|
|
1014
|
+
if (live === "confirmed") return out("done", 6, [`${row.where}: available, \`confirmed\``], row.audience);
|
|
1015
|
+
if (live === "reported") return out("done-reported", 7, [`${row.where}: available, \`reported\` only`], row.audience);
|
|
1016
|
+
const c = ctx.contracts.get(capability);
|
|
1017
|
+
if (c?.state === "current") return out("todo", 8, [`${c.where}: state current, nothing available yet`], strictest(c.audience, ...row ? [row.audience] : []));
|
|
1018
|
+
return out("backlog", 9, [c ? `${c.where}: state ${c.state}` : `no contract for ${capability}`], c ? c.audience : "publishable");
|
|
1019
|
+
}
|
|
1020
|
+
/** The fill, the line that says where it came from, and the audience of that source. */
|
|
1021
|
+
const fillOf = (capability, scenes) => {
|
|
1022
|
+
const src = phaseSource(capability, scenes);
|
|
1023
|
+
if (!src) return {
|
|
1024
|
+
fill: {},
|
|
1025
|
+
evidence: [],
|
|
1026
|
+
audience: []
|
|
1027
|
+
};
|
|
1028
|
+
const where = src.fromBrief ? `design/scenes/${src.scene}/_brief.md: phase ${PHASE_LABEL[src.phase]}` : `design/scenes/${src.scene}: the ${PHASE_LABEL[src.phase]} scene`;
|
|
1029
|
+
return {
|
|
1030
|
+
fill: { fill: src.phase },
|
|
1031
|
+
evidence: [where],
|
|
1032
|
+
audience: [src.audience]
|
|
1033
|
+
};
|
|
1034
|
+
};
|
|
1035
|
+
/** What a published canvas may show of a status (spec 20, Publishing status): rows 5-9 only, drawn
|
|
1036
|
+
* from `publishable` evidence only, and never a reason, a record path or other evidence. */
|
|
1037
|
+
function publishableStatus(r) {
|
|
1038
|
+
if (!r || !PUBLISHABLE.has(r.status) || r.audience !== "publishable") return null;
|
|
1039
|
+
return {
|
|
1040
|
+
status: r.status,
|
|
1041
|
+
...r.fill ? { fill: r.fill } : {}
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/** The statuses rows 5-9 produce - the only ones a published canvas may show. */
|
|
1045
|
+
const PUBLISHABLE = /* @__PURE__ */ new Set([
|
|
1046
|
+
"in-progress",
|
|
1047
|
+
"done",
|
|
1048
|
+
"done-reported",
|
|
1049
|
+
"todo",
|
|
1050
|
+
"backlog"
|
|
1051
|
+
]);
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/server/board-status.ts
|
|
1054
|
+
/**
|
|
1055
|
+
* Every board's type and status, read off the files (spec 20) - for the dev API's board list, the
|
|
1056
|
+
* manifest and the build. The rules live in shared/board-types.ts and shared/status.ts; this module
|
|
1057
|
+
* only reads: `context/` (the shipped record, the contracts, the plans, each with its audience), the
|
|
1058
|
+
* boards and their folders, and the briefs of the scenes a status board shows. Errors never throw:
|
|
1059
|
+
* evidence that cannot be read is a fact the resolver turns into Unknown - never a stale Done.
|
|
1060
|
+
*
|
|
1061
|
+
* The sidebar re-reads on every `sh:boards` and every poll, so the facts are cached by the files'
|
|
1062
|
+
* signature (names, sizes, mtimes) and a pass reads each scene brief once.
|
|
1063
|
+
*/
|
|
1064
|
+
const isDir = (p) => {
|
|
1065
|
+
try {
|
|
1066
|
+
return lstatSync(p).isDirectory();
|
|
1067
|
+
} catch {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
/** Plan states that mean the plan is no longer open. */
|
|
1072
|
+
const CLOSED_PLAN = /* @__PURE__ */ new Set([
|
|
1073
|
+
"historical",
|
|
1074
|
+
"done",
|
|
1075
|
+
"landed",
|
|
1076
|
+
"declined",
|
|
1077
|
+
"closed",
|
|
1078
|
+
"superseded"
|
|
1079
|
+
]);
|
|
1080
|
+
const CONTRACT_STATES = /* @__PURE__ */ new Set([
|
|
1081
|
+
"current",
|
|
1082
|
+
"proposed",
|
|
1083
|
+
"historical"
|
|
1084
|
+
]);
|
|
1085
|
+
/** The markdown files of a context sub-directory - or the error that kept them from being read. */
|
|
1086
|
+
function mdIn(dir) {
|
|
1087
|
+
if (!isDir(dir)) return { files: [] };
|
|
1088
|
+
try {
|
|
1089
|
+
return { files: readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".")).sort() };
|
|
1090
|
+
} catch (e) {
|
|
1091
|
+
return {
|
|
1092
|
+
files: [],
|
|
1093
|
+
error: e.message
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/** What decides the facts: every file they are read from, by size and mtime. */
|
|
1098
|
+
function signature(dir) {
|
|
1099
|
+
const parts = [];
|
|
1100
|
+
const stamp = (p) => {
|
|
1101
|
+
try {
|
|
1102
|
+
const s = statSync(p);
|
|
1103
|
+
parts.push(`${p}:${s.size}:${s.mtimeMs}:${s.ctimeMs}:${s.mode}`);
|
|
1104
|
+
} catch {
|
|
1105
|
+
parts.push(`${p}:-`);
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
stamp(join(dir, "shipped.md"));
|
|
1109
|
+
for (const sub of ["product", "plans"]) {
|
|
1110
|
+
const d = join(dir, sub);
|
|
1111
|
+
stamp(d);
|
|
1112
|
+
for (const f of mdIn(d).files) stamp(join(d, f));
|
|
1113
|
+
}
|
|
1114
|
+
return parts.join("|");
|
|
1115
|
+
}
|
|
1116
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1117
|
+
/** What `context/` says, for the resolver - cached until a file it reads changes. */
|
|
1118
|
+
function readContextFacts(root) {
|
|
1119
|
+
const dir = join(root, "context");
|
|
1120
|
+
if (!isDir(dir)) return NO_CONTEXT;
|
|
1121
|
+
const sig = signature(dir);
|
|
1122
|
+
const hit = cache.get(root);
|
|
1123
|
+
if (hit && hit.sig === sig) return hit.facts;
|
|
1124
|
+
const facts = readFresh(dir);
|
|
1125
|
+
cache.set(root, {
|
|
1126
|
+
sig,
|
|
1127
|
+
facts
|
|
1128
|
+
});
|
|
1129
|
+
return facts;
|
|
1130
|
+
}
|
|
1131
|
+
function readFresh(dir) {
|
|
1132
|
+
const facts = {
|
|
1133
|
+
present: true,
|
|
1134
|
+
unreadable: /* @__PURE__ */ new Map(),
|
|
1135
|
+
shipped: /* @__PURE__ */ new Map(),
|
|
1136
|
+
contracts: /* @__PURE__ */ new Map(),
|
|
1137
|
+
plans: /* @__PURE__ */ new Map()
|
|
1138
|
+
};
|
|
1139
|
+
const shippedFile = join(dir, "shipped.md");
|
|
1140
|
+
let text = null;
|
|
1141
|
+
try {
|
|
1142
|
+
text = readFileSync(shippedFile, "utf8");
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
if (e.code !== "ENOENT") facts.unreadable.set("*", `context/shipped.md: ${e.message}`);
|
|
1145
|
+
}
|
|
1146
|
+
if (text !== null) {
|
|
1147
|
+
const fm = frontMatter(text);
|
|
1148
|
+
if (fm.error) facts.unreadable.set("*", `context/shipped.md: ${fm.error}`);
|
|
1149
|
+
else if (!tables(text).some(isRecordTable)) facts.unreadable.set("*", "context/shipped.md: no Capability table with an Available column");
|
|
1150
|
+
else {
|
|
1151
|
+
const audience = readAudience(fm.data?.audience);
|
|
1152
|
+
for (const r of shippedRows(text)) facts.shipped.set(r.capability, {
|
|
1153
|
+
levels: r.levels,
|
|
1154
|
+
where: `context/shipped.md:${r.line}`,
|
|
1155
|
+
audience
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const product = mdIn(join(dir, "product"));
|
|
1160
|
+
if (product.error) facts.unreadable.set("*", `context/product/: ${product.error}`);
|
|
1161
|
+
for (const f of product.files) {
|
|
1162
|
+
const where = `context/product/${f}`;
|
|
1163
|
+
const slug = f.slice(0, -3);
|
|
1164
|
+
try {
|
|
1165
|
+
const fm = frontMatter(readFileSync(join(dir, "product", f), "utf8"));
|
|
1166
|
+
if (fm.error || !fm.data) {
|
|
1167
|
+
facts.unreadable.set(slug, `${where}: ${fm.error ?? "no front matter"}`);
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
const cap = readCapability(fm.data.capability) ?? slug;
|
|
1171
|
+
const state = typeof fm.data.state === "string" ? fm.data.state : "";
|
|
1172
|
+
if (!CONTRACT_STATES.has(state)) {
|
|
1173
|
+
facts.unreadable.set(cap, `${where}: state "${state}" is not current, proposed or historical`);
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
facts.contracts.set(cap, {
|
|
1177
|
+
state,
|
|
1178
|
+
where,
|
|
1179
|
+
audience: readAudience(fm.data.audience)
|
|
1180
|
+
});
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
facts.unreadable.set(slug, `${where}: ${e.message}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
const plans = mdIn(join(dir, "plans"));
|
|
1186
|
+
if (plans.error) facts.unreadable.set("*", `context/plans/: ${plans.error}`);
|
|
1187
|
+
for (const f of plans.files) {
|
|
1188
|
+
const where = `context/plans/${f}`;
|
|
1189
|
+
f.slice(0, -3);
|
|
1190
|
+
try {
|
|
1191
|
+
const fm = frontMatter(readFileSync(join(dir, "plans", f), "utf8"));
|
|
1192
|
+
if (fm.error || !fm.data) {
|
|
1193
|
+
facts.unreadable.set("*", `${where}: ${fm.error ?? "no front matter"}`);
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
if (typeof fm.data.state === "string" && CLOSED_PLAN.has(fm.data.state)) continue;
|
|
1197
|
+
const list = (v) => Array.isArray(v) ? v : v === void 0 ? [] : [v];
|
|
1198
|
+
const caps = [...list(fm.data.capability), ...list(fm.data.capabilities)].map(readCapability).filter((c) => !!c);
|
|
1199
|
+
const audience = readAudience(fm.data.audience);
|
|
1200
|
+
for (const c of caps) facts.plans.set(c, [...facts.plans.get(c) ?? [], {
|
|
1201
|
+
where,
|
|
1202
|
+
audience
|
|
1203
|
+
}]);
|
|
1204
|
+
} catch (e) {
|
|
1205
|
+
facts.unreadable.set("*", `${where}: ${e.message}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return facts;
|
|
1209
|
+
}
|
|
1210
|
+
/** The scenes a board shows: its layout rows, and the scene of every frame it pins. */
|
|
1211
|
+
function boardScenes(json) {
|
|
1212
|
+
const o = json;
|
|
1213
|
+
const out = /* @__PURE__ */ new Set();
|
|
1214
|
+
const rows = o?.layout?.rows;
|
|
1215
|
+
if (Array.isArray(rows)) {
|
|
1216
|
+
for (const r of rows) if (Array.isArray(r)) {
|
|
1217
|
+
for (const s of r) if (typeof s === "string") out.add(s);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (Array.isArray(o?.nodes)) for (const n of o.nodes) {
|
|
1221
|
+
const f = n?.frame;
|
|
1222
|
+
if (typeof f === "string" && f.includes("/")) out.add(f.split("/")[0]);
|
|
1223
|
+
}
|
|
1224
|
+
return [...out];
|
|
1225
|
+
}
|
|
1226
|
+
const SCENE = /^[a-z0-9][a-z0-9-]*$/;
|
|
1227
|
+
/** A scene as the fill reads it: does it hold a frame yet (a phase counts only once it does - a
|
|
1228
|
+
* feature's starting layout names its three phase scenes before any frame exists), and the
|
|
1229
|
+
* `phase` its brief declares. */
|
|
1230
|
+
function readScene(root, scene) {
|
|
1231
|
+
if (!SCENE.test(scene)) return { frames: false };
|
|
1232
|
+
const dir = join(root, "design", "scenes", scene);
|
|
1233
|
+
let frames = false;
|
|
1234
|
+
try {
|
|
1235
|
+
frames = readdirSync(dir).some((f) => /\.(tsx|jsx|html)$/.test(f) && !f.startsWith("_"));
|
|
1236
|
+
} catch {}
|
|
1237
|
+
let phase;
|
|
1238
|
+
let audience;
|
|
1239
|
+
try {
|
|
1240
|
+
const fm = frontMatter(readFileSync(join(dir, "_brief.md"), "utf8"));
|
|
1241
|
+
if (typeof fm.data?.phase === "string") {
|
|
1242
|
+
phase = fm.data.phase;
|
|
1243
|
+
audience = fm.data.audience === void 0 ? "publishable" : readAudience(fm.data.audience);
|
|
1244
|
+
}
|
|
1245
|
+
} catch {}
|
|
1246
|
+
return {
|
|
1247
|
+
frames,
|
|
1248
|
+
...phase ? {
|
|
1249
|
+
phase,
|
|
1250
|
+
audience
|
|
1251
|
+
} : {}
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
/** Every board's resolved type and status. `boards` are the board files' names and JSON; `folders`
|
|
1255
|
+
* the registry rows; `folderOf` the folder each board sits in directly (the tree's answer). */
|
|
1256
|
+
function annotateBoards(root, boards, folders, folderOf, facts = readContextFacts(root)) {
|
|
1257
|
+
const byName = new Map(folders.map((f) => [f.name, f]));
|
|
1258
|
+
const scenes = /* @__PURE__ */ new Map();
|
|
1259
|
+
const scene = (s) => {
|
|
1260
|
+
let v = scenes.get(s);
|
|
1261
|
+
if (!v) {
|
|
1262
|
+
v = readScene(root, s);
|
|
1263
|
+
scenes.set(s, v);
|
|
1264
|
+
}
|
|
1265
|
+
return v;
|
|
1266
|
+
};
|
|
1267
|
+
const out = /* @__PURE__ */ new Map();
|
|
1268
|
+
for (const b of boards) {
|
|
1269
|
+
const o = b.json ?? {};
|
|
1270
|
+
const f = folderOf(b.name);
|
|
1271
|
+
const folder = f ? byName.get(f) : void 0;
|
|
1272
|
+
const parent = folder?.parent ? byName.get(folder.parent) : void 0;
|
|
1273
|
+
const type = resolveType(o.type, folder?.type, parent?.type);
|
|
1274
|
+
if (!HAS_STATUS.includes(type)) {
|
|
1275
|
+
out.set(b.name, {
|
|
1276
|
+
type,
|
|
1277
|
+
status: null
|
|
1278
|
+
});
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
const shown = boardScenes(b.json).flatMap((name) => {
|
|
1282
|
+
const sc = scene(name);
|
|
1283
|
+
return sc.frames ? [{
|
|
1284
|
+
name,
|
|
1285
|
+
...sc.phase ? {
|
|
1286
|
+
phase: sc.phase,
|
|
1287
|
+
audience: sc.audience
|
|
1288
|
+
} : {}
|
|
1289
|
+
}] : [];
|
|
1290
|
+
});
|
|
1291
|
+
const status = resolveStatus({
|
|
1292
|
+
name: b.name,
|
|
1293
|
+
type,
|
|
1294
|
+
capability: readCapability(o.capability),
|
|
1295
|
+
status: readStatusWord(o.status),
|
|
1296
|
+
reason: readReason(o.reason),
|
|
1297
|
+
scenes: shown
|
|
1298
|
+
}, facts);
|
|
1299
|
+
out.set(b.name, {
|
|
1300
|
+
type,
|
|
1301
|
+
status
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
return out;
|
|
1305
|
+
}
|
|
1306
|
+
//#endregion
|
|
1307
|
+
export { readCapability as $, isRegularFile as A, folderMap as B, tables as C, boardFields as D, addFolders as E, BOARD_NAME as F, validateWire as G, isBoardName as H, FOLDERS_FILE as I, FOLDER_MODULES as J, wireKids as K, buildTree as L, nodeExists as M, readRegistry as N, checkBoardsDir as O, withRegistryLock as P, knownType as Q, flatten as R, proseLines as S, AUTHOR_FIELDS as T, readDescription as U, humanize as V, readTitle as W, KIND_FOLDERS as X, HAS_STATUS as Y, PROPOSED_PUBLISH as Z, isRecordTable as _, STATUS_LABEL as a, hash as at, looseTables as b, CITED_FILE as c, LEVEL as d, readReason as et, availableLevels as f, hasGlob as g, globRe as h, PUBLISHABLE as i, settableStatuses as it, listBoardFiles as j, checkRealDirs as k, EVIDENCE_COLUMN as l, frontMatter as m, readContextFacts as n, readType as nt, publishableStatus as o, capabilityTable as p, BOARD_TYPES as q, PHASE_LABEL as r, resolveType as rt, CITATION as s, annotateBoards as t, readStatusWord as tt, GENERATED as u, levelsIn as v, wordCount as w, parseMap as x, lf as y, folderEntries as z };
|