@hegemonart/get-design-done 1.54.0 → 1.55.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +47 -0
- package/README.md +2 -0
- package/bin/gdd-dashboard +91 -0
- package/package.json +2 -1
- package/scripts/lib/dashboard/graph-html.cjs +0 -0
- package/scripts/lib/health-mirror/index.cjs +146 -1
- package/sdk/cli/commands/dashboard.ts +419 -0
- package/sdk/cli/index.js +253 -2
- package/sdk/cli/index.ts +7 -0
- package/sdk/dashboard/data/_pkg-root.cjs +92 -0
- package/sdk/dashboard/data/cost-aggregator.cjs +187 -0
- package/sdk/dashboard/data/discovery.cjs +297 -0
- package/sdk/dashboard/data/risk-surface.cjs +136 -0
- package/sdk/dashboard/data/source.cjs +576 -0
- package/sdk/dashboard/tui/ansi.cjs +355 -0
- package/sdk/dashboard/tui/index.cjs +778 -0
- package/sdk/mcp/gdd-mcp/server.js +70 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* sdk/dashboard/data/discovery.cjs — Phase 55 (GDD Dashboard, dep-free).
|
|
4
|
+
*
|
|
5
|
+
* Best-effort, graceful-absent discovery of the three "where is GDD running"
|
|
6
|
+
* surfaces the dashboard renders:
|
|
7
|
+
*
|
|
8
|
+
* - discoverRuntimes() -> the 14 installable runtimes + whether
|
|
9
|
+
* each one's global config dir is present
|
|
10
|
+
* on this machine (Phase 24/28.7 set).
|
|
11
|
+
* - discoverWorktrees({root?}) -> linked git worktrees via
|
|
12
|
+
* `git worktree list --porcelain`.
|
|
13
|
+
* - discoverSessions({root?}) -> session manifests under
|
|
14
|
+
* `<root>/.design/sessions/*.json`
|
|
15
|
+
* (Phase 55 R4: not yet persisted by the
|
|
16
|
+
* pipeline -> degrades to []).
|
|
17
|
+
* - recordSession({id, harness}) -> OPTIONAL additive writer that atomically
|
|
18
|
+
* drops `<root>/.design/sessions/<id>.json`
|
|
19
|
+
* so cross-harness visibility can grow over
|
|
20
|
+
* time (tmp + rename, same-dir, Windows-safe).
|
|
21
|
+
*
|
|
22
|
+
* Everything is graceful-absent and NEVER throws: no git -> [] worktrees; no
|
|
23
|
+
* sessions dir -> [] sessions; an unknown runtime in the catalog is skipped
|
|
24
|
+
* rather than thrown. Sibling resolution (runtime-homes) is required via a
|
|
25
|
+
* package-root walk-up so this file survives being copied around the tree
|
|
26
|
+
* (the Phase 53/54 __dirname lesson).
|
|
27
|
+
*
|
|
28
|
+
* Determinism: the runtime catalog order is fixed; worktree order follows git's
|
|
29
|
+
* porcelain output order; session order follows readdir then a stable id sort.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const { spawnSync } = require('node:child_process');
|
|
35
|
+
|
|
36
|
+
const { requireFromPackageRoot } = require('./_pkg-root.cjs');
|
|
37
|
+
|
|
38
|
+
// runtime-homes is a sibling .cjs lib; resolve it via package-root walk-up so a
|
|
39
|
+
// fixed __dirname-relative jump never breaks if this file moves (Phase 53/54).
|
|
40
|
+
const runtimeHomes = requireFromPackageRoot('scripts/lib/install/runtime-homes.cjs');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The 14 GDD runtimes locked by Phase 24 D-02 (and resolved by
|
|
44
|
+
* runtime-homes.cjs). `cline` is rules-based and has no skills dir
|
|
45
|
+
* (getGlobalSkillsBase('cline') === null) — surfaced as skillsBase: null.
|
|
46
|
+
*/
|
|
47
|
+
const RUNTIMES = Object.freeze([
|
|
48
|
+
'claude',
|
|
49
|
+
'opencode',
|
|
50
|
+
'gemini',
|
|
51
|
+
'kilo',
|
|
52
|
+
'codex',
|
|
53
|
+
'copilot',
|
|
54
|
+
'cursor',
|
|
55
|
+
'windsurf',
|
|
56
|
+
'antigravity',
|
|
57
|
+
'augment',
|
|
58
|
+
'trae',
|
|
59
|
+
'qwen',
|
|
60
|
+
'codebuddy',
|
|
61
|
+
'cline',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** True iff `p` exists and is a directory. Never throws. */
|
|
65
|
+
function dirExists(p) {
|
|
66
|
+
try {
|
|
67
|
+
return fs.statSync(p).isDirectory();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Discover the installable runtimes and whether each is present locally.
|
|
75
|
+
*
|
|
76
|
+
* "present" = the runtime's global config dir exists on disk. We do NOT read
|
|
77
|
+
* any file inside it (pure presence probe), and a resolver RangeError on an
|
|
78
|
+
* unexpected id is swallowed (the entry is still emitted with present:false).
|
|
79
|
+
*
|
|
80
|
+
* @returns {Array<{runtime:string, configDir:string|null, skillsBase:string|null, present:boolean}>}
|
|
81
|
+
*/
|
|
82
|
+
function discoverRuntimes() {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const runtime of RUNTIMES) {
|
|
85
|
+
let configDir = null;
|
|
86
|
+
let skillsBase = null;
|
|
87
|
+
let present = false;
|
|
88
|
+
try {
|
|
89
|
+
configDir = runtimeHomes.getGlobalConfigDir(runtime);
|
|
90
|
+
} catch {
|
|
91
|
+
configDir = null;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
skillsBase = runtimeHomes.getGlobalSkillsBase(runtime); // null for cline
|
|
95
|
+
} catch {
|
|
96
|
+
skillsBase = null;
|
|
97
|
+
}
|
|
98
|
+
if (configDir) present = dirExists(configDir);
|
|
99
|
+
out.push({ runtime, configDir, skillsBase, present });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Default git runner: synchronous `git <args>` in `cwd`, trimmed stdout or null
|
|
106
|
+
* on ANY failure (git missing, non-zero, not a repo). Matches the
|
|
107
|
+
* worktree-resolve.cjs injectable-exec contract: `(cmd, args) => string`.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} cmd literal 'git'
|
|
110
|
+
* @param {string[]} args
|
|
111
|
+
* @param {string} cwd
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
function defaultGitExec(cmd, args, cwd) {
|
|
115
|
+
try {
|
|
116
|
+
const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
117
|
+
if (!res || res.status !== 0 || typeof res.stdout !== 'string') return null;
|
|
118
|
+
return res.stdout;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse `git worktree list --porcelain` output into structured records.
|
|
126
|
+
*
|
|
127
|
+
* Porcelain format is blank-line-separated stanzas; each stanza has lines like:
|
|
128
|
+
* worktree /abs/path
|
|
129
|
+
* HEAD <sha>
|
|
130
|
+
* branch refs/heads/<name> (or `detached` / `bare`)
|
|
131
|
+
* locked [reason] (optional)
|
|
132
|
+
*
|
|
133
|
+
* Tolerant: unknown keys are ignored; a stanza without a `worktree` line is
|
|
134
|
+
* dropped. Pure string parsing — never throws.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} porcelain
|
|
137
|
+
* @returns {Array<{path:string, head:string|null, branch:string|null, detached:boolean, bare:boolean, locked:boolean}>}
|
|
138
|
+
*/
|
|
139
|
+
function parseWorktreePorcelain(porcelain) {
|
|
140
|
+
const out = [];
|
|
141
|
+
if (typeof porcelain !== 'string' || porcelain.trim() === '') return out;
|
|
142
|
+
// Stanzas separated by one or more blank lines.
|
|
143
|
+
const stanzas = porcelain.replace(/\r\n/g, '\n').split(/\n\s*\n/);
|
|
144
|
+
for (const stanza of stanzas) {
|
|
145
|
+
const rec = { path: null, head: null, branch: null, detached: false, bare: false, locked: false };
|
|
146
|
+
for (const lineRaw of stanza.split('\n')) {
|
|
147
|
+
const line = lineRaw.trim();
|
|
148
|
+
if (line === '') continue;
|
|
149
|
+
if (line.startsWith('worktree ')) rec.path = line.slice('worktree '.length).trim();
|
|
150
|
+
else if (line.startsWith('HEAD ')) rec.head = line.slice('HEAD '.length).trim();
|
|
151
|
+
else if (line.startsWith('branch ')) {
|
|
152
|
+
const ref = line.slice('branch '.length).trim();
|
|
153
|
+
rec.branch = ref.replace(/^refs\/heads\//, '');
|
|
154
|
+
} else if (line === 'detached') rec.detached = true;
|
|
155
|
+
else if (line === 'bare') rec.bare = true;
|
|
156
|
+
else if (line === 'locked' || line.startsWith('locked ')) rec.locked = true;
|
|
157
|
+
}
|
|
158
|
+
if (rec.path) out.push(rec);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Discover linked git worktrees for the repo containing `root`.
|
|
165
|
+
*
|
|
166
|
+
* `exec` is injectable (matching worktree-resolve.cjs): `(cmd, args) => string`.
|
|
167
|
+
* Returns [] when git is unavailable / `root` is not a repo. NEVER throws.
|
|
168
|
+
*
|
|
169
|
+
* @param {{root?: string, exec?: (cmd:string, args:string[]) => string}} [opts]
|
|
170
|
+
* @returns {Array<{path:string, head:string|null, branch:string|null, detached:boolean, bare:boolean, locked:boolean}>}
|
|
171
|
+
*/
|
|
172
|
+
function discoverWorktrees(opts = {}) {
|
|
173
|
+
const root = opts.root || process.cwd();
|
|
174
|
+
const run = typeof opts.exec === 'function'
|
|
175
|
+
? (args) => {
|
|
176
|
+
try {
|
|
177
|
+
const o = opts.exec('git', args);
|
|
178
|
+
return typeof o === 'string' ? o : null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
: (args) => defaultGitExec('git', args, root);
|
|
184
|
+
|
|
185
|
+
const porcelain = run(['worktree', 'list', '--porcelain']);
|
|
186
|
+
if (porcelain == null) return [];
|
|
187
|
+
return parseWorktreePorcelain(porcelain);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve the sessions directory: `<root>/.design/sessions`.
|
|
192
|
+
* @param {{root?: string}} [opts]
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
function sessionsDirFor(opts = {}) {
|
|
196
|
+
const root = opts.root || process.cwd();
|
|
197
|
+
return path.join(root, '.design', 'sessions');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Discover persisted session manifests under `<root>/.design/sessions/*.json`.
|
|
202
|
+
*
|
|
203
|
+
* Phase 55 R4: the pipeline does not yet persist session manifests, so this
|
|
204
|
+
* degrades to [] in practice. When present, each `<id>.json` is read + parsed
|
|
205
|
+
* (malformed/unreadable files skipped). Results are sorted by id for
|
|
206
|
+
* determinism. NEVER throws.
|
|
207
|
+
*
|
|
208
|
+
* @param {{root?: string}} [opts]
|
|
209
|
+
* @returns {Array<Record<string, unknown>>}
|
|
210
|
+
*/
|
|
211
|
+
function discoverSessions(opts = {}) {
|
|
212
|
+
const dir = sessionsDirFor(opts);
|
|
213
|
+
let entries;
|
|
214
|
+
try {
|
|
215
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
216
|
+
} catch {
|
|
217
|
+
return []; // no sessions dir -> graceful empty
|
|
218
|
+
}
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const ent of entries) {
|
|
221
|
+
if (!ent.isFile() || !ent.name.endsWith('.json')) continue;
|
|
222
|
+
try {
|
|
223
|
+
const body = fs.readFileSync(path.join(dir, ent.name), 'utf8');
|
|
224
|
+
const parsed = JSON.parse(body);
|
|
225
|
+
if (parsed && typeof parsed === 'object') out.push(parsed);
|
|
226
|
+
} catch {
|
|
227
|
+
// skip malformed/unreadable manifest
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
out.sort((a, b) => String(a && a.id).localeCompare(String(b && b.id)));
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* OPTIONAL additive writer (Phase 55 R4 / D5): atomically persist a session
|
|
236
|
+
* manifest at `<root>/.design/sessions/<id>.json` so future runs / other
|
|
237
|
+
* harnesses can discover it. Uses tmp + same-dir rename (Windows-safe atomic
|
|
238
|
+
* write idiom, mirrors scripts/lib/graph/atomic-write.mjs).
|
|
239
|
+
*
|
|
240
|
+
* Stamps `updated_at` (ISO) so the manifest carries freshness — this is the one
|
|
241
|
+
* intentional non-deterministic field (a write side-effect, not part of any
|
|
242
|
+
* deterministic render contract). `id` is required.
|
|
243
|
+
*
|
|
244
|
+
* Returns the written file path. NEVER throws on a sanitizable input; throws
|
|
245
|
+
* only on a missing/empty id (a programmer error the caller must fix).
|
|
246
|
+
*
|
|
247
|
+
* @param {{id: string, harness?: string, root?: string, [k:string]: unknown}} input
|
|
248
|
+
* @returns {string} absolute path of the written manifest
|
|
249
|
+
*/
|
|
250
|
+
function recordSession(input) {
|
|
251
|
+
if (!input || typeof input.id !== 'string' || input.id.length === 0) {
|
|
252
|
+
throw new TypeError('recordSession: id is required');
|
|
253
|
+
}
|
|
254
|
+
// Sanitize id into a safe filename (no path separators / traversal).
|
|
255
|
+
const safeId = input.id.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
256
|
+
const dir = sessionsDirFor({ root: input.root });
|
|
257
|
+
const target = path.join(dir, `${safeId}.json`);
|
|
258
|
+
|
|
259
|
+
const manifest = { id: input.id };
|
|
260
|
+
if (typeof input.harness === 'string') manifest.harness = input.harness;
|
|
261
|
+
// Preserve opaque extras (anything except control keys).
|
|
262
|
+
for (const key of Object.keys(input)) {
|
|
263
|
+
if (key === 'root' || key === 'id' || key === 'harness') continue;
|
|
264
|
+
manifest[key] = input[key];
|
|
265
|
+
}
|
|
266
|
+
manifest.updated_at = new Date().toISOString();
|
|
267
|
+
|
|
268
|
+
const base = path.basename(target);
|
|
269
|
+
const tmp = path.join(
|
|
270
|
+
dir,
|
|
271
|
+
`.${base}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`,
|
|
272
|
+
);
|
|
273
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
274
|
+
const body = JSON.stringify(manifest, null, 2) + '\n';
|
|
275
|
+
try {
|
|
276
|
+
fs.writeFileSync(tmp, body, 'utf8');
|
|
277
|
+
fs.renameSync(tmp, target);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
try {
|
|
280
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
281
|
+
} catch {
|
|
282
|
+
/* best-effort cleanup; original error takes precedence */
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
return target;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
discoverRuntimes,
|
|
291
|
+
discoverWorktrees,
|
|
292
|
+
discoverSessions,
|
|
293
|
+
recordSession,
|
|
294
|
+
parseWorktreePorcelain,
|
|
295
|
+
sessionsDirFor,
|
|
296
|
+
RUNTIMES,
|
|
297
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* sdk/dashboard/data/risk-surface.cjs — Phase 55 (GDD Dashboard, dep-free).
|
|
4
|
+
*
|
|
5
|
+
* Risk/confidence surfacing helper (CONTEXT.md D-8): the dashboard's Findings
|
|
6
|
+
* pane shows a risk column. Phase 56 will POPULATE `risk_score` / `confidence`
|
|
7
|
+
* / `suggested_action` on events/findings; until then those fields are ABSENT.
|
|
8
|
+
* This helper bridges the gap WITHOUT depending on Phase 56:
|
|
9
|
+
*
|
|
10
|
+
* - When the fields are PRESENT, it reads them and routes a display color.
|
|
11
|
+
* - When they are ABSENT (the pre-56 reality), it emits a blank placeholder
|
|
12
|
+
* row (all null fields + color 'default') so the column renders cleanly and
|
|
13
|
+
* the migration to Phase 56 needs no UI change — the data simply fills in.
|
|
14
|
+
*
|
|
15
|
+
* PURE + dependency-free + deterministic: no FS, no network, no Date.now /
|
|
16
|
+
* Math.random. Input is an array (or a single item); output is a parallel array
|
|
17
|
+
* (or single item) of surfaced rows. NEVER throws on malformed input — a
|
|
18
|
+
* non-object / null item degrades to the blank placeholder.
|
|
19
|
+
*
|
|
20
|
+
* Surfaced row shape (the dashboard contract):
|
|
21
|
+
* {
|
|
22
|
+
* risk_score: number | null, // pass-through when a finite number
|
|
23
|
+
* confidence: number | null, // pass-through when a finite number
|
|
24
|
+
* suggested_action: 'Allow' | 'Review' | 'RequireConfirmation' | 'Block' | null,
|
|
25
|
+
* color: 'green' | 'yellow' | 'orange' | 'red' | 'default'
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* Color route (D-8):
|
|
29
|
+
* Allow -> green
|
|
30
|
+
* Review -> yellow
|
|
31
|
+
* RequireConfirmation -> orange
|
|
32
|
+
* Block -> red
|
|
33
|
+
* (absent / unknown) -> default
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Canonical action → color map (the only colors the Findings pane uses). */
|
|
37
|
+
const ACTION_COLOR = Object.freeze({
|
|
38
|
+
Allow: 'green',
|
|
39
|
+
Review: 'yellow',
|
|
40
|
+
RequireConfirmation: 'orange',
|
|
41
|
+
Block: 'red',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** The set of recognized suggested-action values (Phase 56 vocabulary). */
|
|
45
|
+
const VALID_ACTIONS = Object.freeze(Object.keys(ACTION_COLOR));
|
|
46
|
+
|
|
47
|
+
/** The blank placeholder row emitted pre-56 (or for malformed/absent input). */
|
|
48
|
+
function blankRow() {
|
|
49
|
+
return {
|
|
50
|
+
risk_score: null,
|
|
51
|
+
confidence: null,
|
|
52
|
+
suggested_action: null,
|
|
53
|
+
color: 'default',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Coerce a value to a finite number, or null. (Pass-through for the numeric
|
|
59
|
+
* risk fields — NaN / Infinity / non-numbers degrade to null so the column
|
|
60
|
+
* never renders garbage.)
|
|
61
|
+
* @param {*} v
|
|
62
|
+
* @returns {number|null}
|
|
63
|
+
*/
|
|
64
|
+
function finiteOrNull(v) {
|
|
65
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Map a suggested_action to its display color. Unknown / absent -> 'default'.
|
|
70
|
+
* @param {*} action
|
|
71
|
+
* @returns {'green'|'yellow'|'orange'|'red'|'default'}
|
|
72
|
+
*/
|
|
73
|
+
function colorForAction(action) {
|
|
74
|
+
if (typeof action === 'string' && Object.prototype.hasOwnProperty.call(ACTION_COLOR, action)) {
|
|
75
|
+
return ACTION_COLOR[action];
|
|
76
|
+
}
|
|
77
|
+
return 'default';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Surface the risk fields on ONE event/finding. Reads `risk_score`,
|
|
82
|
+
* `confidence`, `suggested_action` WHEN PRESENT; otherwise returns the blank
|
|
83
|
+
* placeholder. PURE; NEVER throws.
|
|
84
|
+
*
|
|
85
|
+
* @param {*} item an event or finding (may be missing the risk fields pre-56)
|
|
86
|
+
* @returns {{risk_score:number|null, confidence:number|null,
|
|
87
|
+
* suggested_action:string|null, color:string}}
|
|
88
|
+
*/
|
|
89
|
+
function surfaceRiskOne(item) {
|
|
90
|
+
if (!item || typeof item !== 'object') return blankRow();
|
|
91
|
+
|
|
92
|
+
const risk_score = finiteOrNull(item.risk_score);
|
|
93
|
+
const confidence = finiteOrNull(item.confidence);
|
|
94
|
+
const rawAction = item.suggested_action;
|
|
95
|
+
const suggested_action =
|
|
96
|
+
typeof rawAction === 'string' && VALID_ACTIONS.includes(rawAction) ? rawAction : null;
|
|
97
|
+
|
|
98
|
+
// Pre-56: when NONE of the risk fields are present, emit the blank placeholder
|
|
99
|
+
// verbatim (color 'default') so the column reads as "not yet scored".
|
|
100
|
+
if (risk_score === null && confidence === null && suggested_action === null) {
|
|
101
|
+
return blankRow();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
risk_score,
|
|
106
|
+
confidence,
|
|
107
|
+
suggested_action,
|
|
108
|
+
color: colorForAction(suggested_action),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Surface risk across a list of events/findings (or a single item). The output
|
|
114
|
+
* shape mirrors the input arity: an array in -> an array out (parallel,
|
|
115
|
+
* order-preserving); a single object in -> a single surfaced row out. A nullish
|
|
116
|
+
* / non-array, non-object input -> a single blank placeholder. PURE; NEVER
|
|
117
|
+
* throws.
|
|
118
|
+
*
|
|
119
|
+
* @param {*} eventsOrFindings array of items, a single item, or nullish
|
|
120
|
+
* @returns {object|object[]} surfaced row(s)
|
|
121
|
+
*/
|
|
122
|
+
function surfaceRisk(eventsOrFindings) {
|
|
123
|
+
if (Array.isArray(eventsOrFindings)) {
|
|
124
|
+
return eventsOrFindings.map(surfaceRiskOne);
|
|
125
|
+
}
|
|
126
|
+
return surfaceRiskOne(eventsOrFindings);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
surfaceRisk,
|
|
131
|
+
// Exposed for tests + sibling reuse.
|
|
132
|
+
surfaceRiskOne,
|
|
133
|
+
colorForAction,
|
|
134
|
+
ACTION_COLOR,
|
|
135
|
+
VALID_ACTIONS,
|
|
136
|
+
};
|