crewforth 3.1.1 → 3.2.0-rc.2
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 +171 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +56 -5
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +14 -13
- package/kit/agents/crew-commit-agent.md +7 -2
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +117 -6
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +15 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/smoke-test.sh +576 -41
- package/kit/hooks/agent-outcome.sh +26 -12
- package/kit/hooks/guard-agent-model.sh +109 -9
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +12 -0
- package/kit/studio/server/index.js +125 -11
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +223 -15
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +131 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +10 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// What the Map view draws, worked out from a crew map. No page in it.
|
|
2
|
+
//
|
|
3
|
+
// The map is Crewforth's (server/lib/crew-map.js reads it). This file puts each node in a band, says which lines
|
|
4
|
+
// a selection lights, orders the findings and words them. It invents no layer: a module whose place the map does
|
|
5
|
+
// not show goes to "Unplaced", and a finding the scanner hides by default stays hidden until asked for.
|
|
6
|
+
|
|
7
|
+
export const BANDS = [
|
|
8
|
+
['clients', 'Clients'],
|
|
9
|
+
['entry', 'Entry points'],
|
|
10
|
+
['core', 'Core'],
|
|
11
|
+
['infra', 'Infrastructure'],
|
|
12
|
+
['external', 'External'],
|
|
13
|
+
['unplaced', 'Unplaced'],
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const RESOURCE = { db: 'db', cache: 'cache', queue: 'queue', auth: 'auth', 'http-api': 'api' };
|
|
17
|
+
export const resourceWord = (r) => RESOURCE[r] ?? String(r ?? 'external');
|
|
18
|
+
|
|
19
|
+
/** The modules a finding is about: named in its data, or the module whose folder holds its first cited file. */
|
|
20
|
+
export function findingModules(f, modules) {
|
|
21
|
+
const named = [f?.data?.module, ...(Array.isArray(f?.data?.modules) ? f.data.modules : [])].filter((x) => typeof x === 'string');
|
|
22
|
+
if (named.length) return [...new Set(named)];
|
|
23
|
+
const file = f?.evidence?.[0]?.file;
|
|
24
|
+
if (typeof file !== 'string') return [];
|
|
25
|
+
let best = null;
|
|
26
|
+
for (const m of modules) {
|
|
27
|
+
const dir = m.path === '.' || m.path === '' ? '' : `${m.path}/`;
|
|
28
|
+
if ((dir === '' || file.startsWith(dir)) && (!best || dir.length > best.dir.length)) best = { id: m.id, dir };
|
|
29
|
+
}
|
|
30
|
+
return best ? [best.id] : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Each node's band, from what the map shows of it:
|
|
35
|
+
* external resource → External · container service → Infrastructure · a module of a compared project → Clients
|
|
36
|
+
* a module that provides an endpoint, or that a container service builds → Entry points
|
|
37
|
+
* one that only calls endpoints → Clients
|
|
38
|
+
* one another module of this project references → Core · one with an entry point and nothing above → Entry points
|
|
39
|
+
* anything else → Unplaced, so a guess is never drawn as a fact.
|
|
40
|
+
*/
|
|
41
|
+
export function bandOf(node, doc) {
|
|
42
|
+
if (node.kind === 'external') return 'external';
|
|
43
|
+
if (node.kind === 'service') return 'infra';
|
|
44
|
+
if (node.kind === 'client') return 'clients';
|
|
45
|
+
if ((doc.provides?.endpoints ?? []).some((e) => e.module === node.id)) return 'entry';
|
|
46
|
+
if ((doc.edges ?? []).some((e) => e.to === node.id && e.kind === 'builds')) return 'entry';
|
|
47
|
+
if ((doc.consumes?.endpoints ?? []).some((e) => e.module === node.id)) return 'clients';
|
|
48
|
+
if ((doc.edges ?? []).some((e) => e.to === node.id && e.from !== node.id && e.from.startsWith('mod:'))) return 'core';
|
|
49
|
+
if (node.entry) return 'entry';
|
|
50
|
+
return 'unplaced';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The nodes the view draws, each with its band, and the clients of a compared project as nodes of their own.
|
|
55
|
+
* @param compare the server's `compare` ({ label, clients }) or null
|
|
56
|
+
*/
|
|
57
|
+
export function nodesOf(doc, compare = null) {
|
|
58
|
+
const own = (doc.nodes ?? []).map((n) => ({ ...n, band: bandOf(n, doc) }));
|
|
59
|
+
const clients = (compare?.clients ?? []).map((c) => ({
|
|
60
|
+
id: `cmp:${c.id}`, kind: 'client', name: c.name, tech: c.tech ?? [], from: compare.label, calls: c.calls, missing: c.missing, band: 'clients',
|
|
61
|
+
}));
|
|
62
|
+
return [...clients, ...own];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The edges between drawn nodes; a compared client gets one edge to each module of this project it calls. */
|
|
66
|
+
export function edgesOf(doc, compare = null) {
|
|
67
|
+
const own = (doc.edges ?? []).map((e) => ({ ...e, solid: e.confidence !== 'inferred', line: lineOf(e) }));
|
|
68
|
+
const extra = [];
|
|
69
|
+
for (const c of compare?.clients ?? []) {
|
|
70
|
+
// The module each call lands on is the server's match (`to`), never a guess made here.
|
|
71
|
+
const to = new Map();
|
|
72
|
+
for (const call of c.calls) if (call.to) to.set(call.to, (to.get(call.to) ?? 0) + 1);
|
|
73
|
+
for (const [m, n] of to) extra.push({ from: `cmp:${c.id}`, to: m, kind: 'calls', confidence: 'evidence', solid: true, line: 'evidence', calls: n, missing: c.missing.length });
|
|
74
|
+
}
|
|
75
|
+
return [...extra, ...own];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const edgeKey = (e) => `${e.from}>${e.to}>${e.kind}`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* How a line is drawn: `evidence` (solid, a file proves it), `inferred` (dotted) or `model` (a line the model
|
|
82
|
+
* proposed: dashed with its own mark, never drawn as if a file showed it).
|
|
83
|
+
*/
|
|
84
|
+
export function lineOf(e) {
|
|
85
|
+
if (e?.source === 'model') return 'model';
|
|
86
|
+
return e?.confidence === 'inferred' ? 'inferred' : 'evidence';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An external resource every line of which is inferred is itself only inferred, and its card says so. */
|
|
90
|
+
export function inferredOnly(node, edges) {
|
|
91
|
+
if (node.kind !== 'external') return false;
|
|
92
|
+
const into = edges.filter((e) => e.to === node.id || e.from === node.id);
|
|
93
|
+
return into.length > 0 && into.every((e) => lineOf(e) !== 'evidence');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The folders that hold two or more modules of one band: a group the view can fold into one card. A module at the
|
|
98
|
+
* project's root, or alone in its folder, is in no group. { groupId: { id, band, folder, members: [ids] } }
|
|
99
|
+
*/
|
|
100
|
+
export function groupsOf(nodes) {
|
|
101
|
+
const by = new Map();
|
|
102
|
+
for (const n of nodes) {
|
|
103
|
+
if (n.kind !== 'module' || typeof n.path !== 'string' || !n.path.includes('/')) continue;
|
|
104
|
+
const folder = n.path.slice(0, n.path.lastIndexOf('/'));
|
|
105
|
+
const id = `grp:${n.band}:${folder}`;
|
|
106
|
+
const g = by.get(id) ?? by.set(id, { id, band: n.band, folder, members: [] }).get(id);
|
|
107
|
+
g.members.push(n.id);
|
|
108
|
+
}
|
|
109
|
+
return Object.fromEntries([...by].filter(([, g]) => g.members.length > 1));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The nodes and edges with the folded groups folded: each folded group is one card in place of its members, and
|
|
114
|
+
* the members' lines are its lines, one per (from, to, kind), drawn solid when any of them is.
|
|
115
|
+
*/
|
|
116
|
+
export function foldView(nodes, edges, groups, folded) {
|
|
117
|
+
const into = new Map();
|
|
118
|
+
for (const g of Object.values(groups)) if (folded.has(g.id)) for (const m of g.members) into.set(m, g.id);
|
|
119
|
+
if (!into.size) return { nodes, edges };
|
|
120
|
+
const out = [];
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
for (const n of nodes) {
|
|
123
|
+
const gid = into.get(n.id);
|
|
124
|
+
if (!gid) { out.push(n); continue; }
|
|
125
|
+
if (seen.has(gid)) continue;
|
|
126
|
+
seen.add(gid);
|
|
127
|
+
const g = groups[gid];
|
|
128
|
+
out.push({ id: gid, kind: 'group', band: g.band, name: `${g.folder}/`, folder: g.folder, members: g.members });
|
|
129
|
+
}
|
|
130
|
+
const em = new Map();
|
|
131
|
+
for (const e of edges) {
|
|
132
|
+
const from = into.get(e.from) ?? e.from;
|
|
133
|
+
const to = into.get(e.to) ?? e.to;
|
|
134
|
+
if (from === to) continue;
|
|
135
|
+
const k = `${from}>${to}>${e.kind}`;
|
|
136
|
+
const was = em.get(k);
|
|
137
|
+
if (!was) em.set(k, { ...e, from, to });
|
|
138
|
+
else if (lineOf(e) === 'evidence' && was.line !== 'evidence') em.set(k, { ...was, solid: true, line: 'evidence' });
|
|
139
|
+
}
|
|
140
|
+
return { nodes: out, edges: [...em.values()] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The layout with the viewer's own placings over it: a card placed by hand stays where it was put. */
|
|
144
|
+
export function placed(lay, pins) {
|
|
145
|
+
const pos = { ...lay.pos };
|
|
146
|
+
for (const [id, p] of Object.entries(pins ?? {})) if (pos[id]) pos[id] = { ...pos[id], x: p.x, y: p.y };
|
|
147
|
+
const right = Object.values(pos).reduce((m, p) => Math.max(m, p.x + p.w), lay.width);
|
|
148
|
+
const bottom = Object.values(pos).reduce((m, p) => Math.max(m, p.y + p.h), lay.height);
|
|
149
|
+
return { ...lay, pos, width: right, height: bottom };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Which lines a selection lights: the selected node's own, in and out. With nothing selected, none is lit. */
|
|
153
|
+
export function litEdges(edges, selected) {
|
|
154
|
+
if (!selected) return new Set();
|
|
155
|
+
return new Set(edges.filter((e) => e.from === selected || e.to === selected).map(edgeKey));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The findings the list shows, proven first, then by title. Hidden by default: the scanner's own mark
|
|
160
|
+
* (`data.defaultHidden`, for rules too often wrong to show unasked); shown when `showHidden`. A compared client's
|
|
161
|
+
* calls this project does not provide are findings too, marked as coming from the comparison.
|
|
162
|
+
*/
|
|
163
|
+
export function findingsOf(doc, { showHidden = false, compare = null } = {}) {
|
|
164
|
+
const modules = (doc.nodes ?? []).filter((n) => n.kind === 'module');
|
|
165
|
+
const all = (doc.findings ?? []).map((f) => ({
|
|
166
|
+
id: f.id, rule: f.rule, title: f.title, proven: f.confidence === 'proven', hidden: Boolean(f.data?.defaultHidden),
|
|
167
|
+
hiddenReason: f.data?.hiddenReason ?? null, where: whereOf(f), modules: findingModules(f, modules), evidence: f.evidence ?? [], from: null,
|
|
168
|
+
}));
|
|
169
|
+
for (const c of compare?.clients ?? []) {
|
|
170
|
+
for (const m of c.missing) {
|
|
171
|
+
all.push({
|
|
172
|
+
id: `cmp:${c.id}:${m.method} ${m.path}`, rule: 'compare-missing', title: `${m.method} ${m.path} is called by ${c.name} and not provided here`,
|
|
173
|
+
proven: true, hidden: false, hiddenReason: null, where: m.evidence?.[0] ? `${m.evidence[0].file}:${m.evidence[0].line}` : '', modules: [`cmp:${c.id}`],
|
|
174
|
+
evidence: m.evidence ?? [], from: compare.label,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const shown = all.filter((f) => showHidden || !f.hidden);
|
|
179
|
+
shown.sort((a, b) => Number(b.proven) - Number(a.proven) || (a.title < b.title ? -1 : a.title > b.title ? 1 : 0));
|
|
180
|
+
return { shown, hidden: all.filter((f) => f.hidden).length, proven: shown.filter((f) => f.proven).length, total: all.length };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function whereOf(f) {
|
|
184
|
+
const e = f.evidence?.[0];
|
|
185
|
+
return e ? `${e.file}:${e.line}` : '';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** How many findings each drawn node carries, of those the list shows. */
|
|
189
|
+
export function findingCounts(findings) {
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const f of findings) for (const m of f.modules) out[m] = (out[m] ?? 0) + 1;
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** What a card says under its name. */
|
|
196
|
+
export function cardSub(node, doc) {
|
|
197
|
+
if (node.kind === 'client') return `${node.from} · ${node.calls.length} ${node.calls.length === 1 ? 'call' : 'calls'} here`;
|
|
198
|
+
if (node.kind === 'service') return node.image ? `container · ${node.image}` : 'container';
|
|
199
|
+
const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
|
|
200
|
+
const tech = (node.tech ?? []).slice(0, 2).map((t) => t.replace(/@.*$/, '')).join(' · ');
|
|
201
|
+
return [tech || node.stack, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The summary pills: what the map holds, and how many findings the list shows. */
|
|
205
|
+
export function summaryOf(doc, findings) {
|
|
206
|
+
const n = (k) => (doc.nodes ?? []).filter((x) => x.kind === k).length;
|
|
207
|
+
const eps = (doc.provides?.endpoints?.length ?? 0) + (doc.consumes?.endpoints?.length ?? 0);
|
|
208
|
+
return [
|
|
209
|
+
`${n('module')} ${n('module') === 1 ? 'module' : 'modules'}`,
|
|
210
|
+
`${n('external')} external`,
|
|
211
|
+
`${eps} ${eps === 1 ? 'endpoint' : 'endpoints'}`,
|
|
212
|
+
`${findings.shown.length} ${findings.shown.length === 1 ? 'finding' : 'findings'}`,
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The source strip: the file, the commit it was scanned at, and how far HEAD has moved since. */
|
|
217
|
+
export function sourceOf(data) {
|
|
218
|
+
const commit = data.doc?.source?.commit ?? null;
|
|
219
|
+
const behind = data.behind?.count;
|
|
220
|
+
return {
|
|
221
|
+
file: data.file,
|
|
222
|
+
at: commit ? `scanned at ${commit.slice(0, 7)}` : 'scanned outside git',
|
|
223
|
+
behind: typeof behind === 'number' && behind > 0 ? `${behind} ${behind === 1 ? 'commit' : 'commits'} behind` : null,
|
|
224
|
+
behindNote: typeof behind === 'number' ? null : (data.behind?.reason ?? null),
|
|
225
|
+
rule: 'Nothing is run; files are only read.',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** What the inspector shows for one node. */
|
|
230
|
+
export function inspectOf(node, doc, edges, findings) {
|
|
231
|
+
const byId = new Map(nodesOf(doc).map((n) => [n.id, n]));
|
|
232
|
+
const nameOf = (id) => byId.get(id)?.name ?? byId.get(id)?.label ?? id.replace(/^(mod|svc|ext):/, '');
|
|
233
|
+
const line = (e, other) => ({
|
|
234
|
+
kind: e.kind, to: nameOf(other), host: byId.get(other)?.hosts?.[0] ?? null,
|
|
235
|
+
evidence: e.evidence?.[0] ? `${e.evidence[0].file}:${e.evidence[0].line}` : null, solid: e.solid, line: e.line ?? lineOf(e),
|
|
236
|
+
});
|
|
237
|
+
const out = edges.filter((e) => e.from === node.id).map((e) => line(e, e.to));
|
|
238
|
+
const into = edges.filter((e) => e.to === node.id).map((e) => line(e, e.from));
|
|
239
|
+
// Connections, by the kind of line: what the inspector's second tab lists.
|
|
240
|
+
const byKind = {};
|
|
241
|
+
for (const [dir, list] of [['out', out], ['in', into]]) for (const c of list) (byKind[c.kind] ??= { out: [], in: [] })[dir].push(c);
|
|
242
|
+
return {
|
|
243
|
+
title: node.name ?? node.label ?? node.id,
|
|
244
|
+
path: node.path ?? (node.kind === 'external' ? (node.hosts ?? []).join(', ') : null),
|
|
245
|
+
chips: (node.tech ?? []).map((t) => t.replace('@', ' ')),
|
|
246
|
+
// /crew-map's description: { text, cites }, each cite a file:line of this node's evidence (eval/lib/crew-map/narrative.mjs).
|
|
247
|
+
narrative: typeof node.narrative?.text === 'string' ? node.narrative.text : null,
|
|
248
|
+
narrativeCites: Array.isArray(node.narrative?.cites) ? node.narrative.cites.filter((c) => typeof c === 'string') : [],
|
|
249
|
+
narrativeNote: 'Not written yet: this map holds no description of the module, only what its files show.',
|
|
250
|
+
connectsTo: out,
|
|
251
|
+
connections: { out, in: into },
|
|
252
|
+
byKind: Object.entries(byKind).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
253
|
+
// Every line the map cites for this node itself, as file:line: what the inspector lets the viewer copy.
|
|
254
|
+
evidence: (node.evidence ?? []).map((e) => `${e.file}:${e.line}`),
|
|
255
|
+
evidenceTotal: node.evidenceTotal ?? (node.evidence ?? []).length,
|
|
256
|
+
findings: findings.filter((f) => f.modules.includes(node.id)),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A finding as a task to paste into a session: what, where, and that the map is the source. */
|
|
261
|
+
export function taskOf(f) {
|
|
262
|
+
return [
|
|
263
|
+
`${f.title}.`,
|
|
264
|
+
f.where ? `Evidence: ${f.where}${f.evidence?.[0]?.text ? ` (${f.evidence[0].text})` : ''}.` : null,
|
|
265
|
+
f.proven ? null : 'The crew map inferred this; check it before acting on it.',
|
|
266
|
+
f.from ? `Found by comparing this project's map with ${f.from}'s.` : `From the crew map (rule ${f.rule}).`,
|
|
267
|
+
].filter(Boolean).join(' ');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** What the view says when there is no map to draw. */
|
|
271
|
+
export function emptyNote(data) {
|
|
272
|
+
if (!data) return 'Reading the map…';
|
|
273
|
+
if (data.measured === false) return data.missing ? `No map yet — ${data.reason}` : `Not read — ${data.reason}`;
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const CARD = { module: [200, 64], service: [200, 64], client: [200, 64], external: [104, 52] };
|
|
278
|
+
const GAP_X = 24;
|
|
279
|
+
const GAP_Y = 20;
|
|
280
|
+
const LEFT = 140; // the band's label sits in this column
|
|
281
|
+
const PAD_Y = 18;
|
|
282
|
+
const CHIP_ROW = 26; // one row of endpoint chips under a card, when the Endpoints layer is on
|
|
283
|
+
export const CHIPS_MAX = 4;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Where everything sits: bands from top to bottom, each as tall as its rows of cards; cards left to right,
|
|
287
|
+
* wrapping at `width`. Empty bands are not drawn. A card with endpoint chips (`chips[id]`) is taller. With
|
|
288
|
+
* `rowKey`, a run of cards that share a key (an open folder group) starts a row of its own and the next card after
|
|
289
|
+
* it does too, so a frame drawn around the run holds those cards and no other.
|
|
290
|
+
* @returns { bands: [{ key, label, y, h }], pos: { id: { x, y, w, h } }, width, height }
|
|
291
|
+
*/
|
|
292
|
+
export function layoutOf(nodes, { width = 960, chips = {}, rowKey = null } = {}) {
|
|
293
|
+
const pos = {};
|
|
294
|
+
const bands = [];
|
|
295
|
+
let y = 0;
|
|
296
|
+
for (const [key, label] of BANDS) {
|
|
297
|
+
const members = nodes.filter((n) => n.band === key);
|
|
298
|
+
if (!members.length) continue;
|
|
299
|
+
let x = LEFT; let rowY = y + PAD_Y; let rowH = 0; let prev = null;
|
|
300
|
+
for (const n of members) {
|
|
301
|
+
const key = rowKey?.(n) ?? null;
|
|
302
|
+
if (x > LEFT && key !== prev && (key || prev)) { x = LEFT; rowY += rowH + GAP_Y; rowH = 0; }
|
|
303
|
+
prev = key;
|
|
304
|
+
const [w, h0] = CARD[n.kind] ?? CARD.module;
|
|
305
|
+
const extra = Math.ceil(Math.min(chips[n.id] ?? 0, CHIPS_MAX + 1) / 2) * CHIP_ROW;
|
|
306
|
+
const h = h0 + extra;
|
|
307
|
+
if (x > LEFT && x + w > width) { x = LEFT; rowY += rowH + GAP_Y; rowH = 0; }
|
|
308
|
+
pos[n.id] = { x, y: rowY, w, h };
|
|
309
|
+
x += w + GAP_X; rowH = Math.max(rowH, h);
|
|
310
|
+
}
|
|
311
|
+
const h = rowY + rowH + PAD_Y - y;
|
|
312
|
+
bands.push({ key, label, y, h });
|
|
313
|
+
y += h;
|
|
314
|
+
}
|
|
315
|
+
const right = Object.values(pos).reduce((m, p) => Math.max(m, p.x + p.w), LEFT);
|
|
316
|
+
return { bands, pos, width: Math.max(width, right + GAP_X), height: y };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A line between two cards: out of the bottom of the upper one into the top of the lower, or side to side. */
|
|
320
|
+
export function pathOf(a, b) {
|
|
321
|
+
if (!a || !b) return null;
|
|
322
|
+
const down = b.y >= a.y + a.h;
|
|
323
|
+
const up = a.y >= b.y + b.h;
|
|
324
|
+
if (down || up) {
|
|
325
|
+
const [s, t] = down ? [a, b] : [b, a];
|
|
326
|
+
const x1 = s.x + s.w / 2; const y1 = s.y + s.h; const x2 = t.x + t.w / 2; const y2 = t.y;
|
|
327
|
+
const m = (y1 + y2) / 2;
|
|
328
|
+
return { d: `M${x1} ${y1} C${x1} ${m} ${x2} ${m} ${x2} ${y2}`, mid: [(x1 + x2) / 2, m] };
|
|
329
|
+
}
|
|
330
|
+
const [s, t] = a.x <= b.x ? [a, b] : [b, a];
|
|
331
|
+
const x1 = s.x + s.w; const y1 = s.y + s.h / 2; const x2 = t.x; const y2 = t.y + t.h / 2;
|
|
332
|
+
return { d: `M${x1} ${y1} L${x2} ${y2}`, mid: [(x1 + x2) / 2, (y1 + y2) / 2] };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The endpoint chips of a module, as the Endpoints layer lists them: what it provides, then what it calls. */
|
|
336
|
+
export function chipsOf(node, doc) {
|
|
337
|
+
const p = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).map((e) => `${e.method} ${e.path}`);
|
|
338
|
+
const c = (doc.consumes?.endpoints ?? []).filter((e) => e.module === node.id).map((e) => `→ ${e.method} ${e.path}`);
|
|
339
|
+
const all = [...p, ...c];
|
|
340
|
+
return all.length > CHIPS_MAX ? [...all.slice(0, CHIPS_MAX), `+${all.length - CHIPS_MAX} more`] : all;
|
|
341
|
+
}
|