@hegemonart/get-design-done 1.21.0 → 1.23.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 +184 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +7 -2
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +20 -0
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/parse-contract.cjs +168 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- package/scripts/lib/transports/ws.cjs +179 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --experimental-strip-types
|
|
2
|
+
// scripts/cli/gdd-events.mjs — CLI transport for the event stream
|
|
3
|
+
// (Plan 22-06).
|
|
4
|
+
//
|
|
5
|
+
// Subcommands:
|
|
6
|
+
// gdd-events tail [--follow] [--path=<p>]
|
|
7
|
+
// - dump events.jsonl to stdout, line-by-line
|
|
8
|
+
// - --follow re-polls every 250ms appending new content (no native
|
|
9
|
+
// inotify dep; portable across platforms)
|
|
10
|
+
//
|
|
11
|
+
// gdd-events grep <filter> [--path=<p>]
|
|
12
|
+
// - filter language (space-separated terms, all AND'd):
|
|
13
|
+
// type=<exact-string> — match `type` field
|
|
14
|
+
// payload.<dotted.path>=<value> — drill into payload by '.'-path
|
|
15
|
+
// !type=<exact-string> — negate
|
|
16
|
+
// !payload.<path>=<value> — negate
|
|
17
|
+
// - prints matching events to stdout as JSONL (compact)
|
|
18
|
+
//
|
|
19
|
+
// gdd-events cat [--path=<p>]
|
|
20
|
+
// - alias for tail without --follow, but pretty-prints with a
|
|
21
|
+
// leading timestamp+type prefix per line
|
|
22
|
+
//
|
|
23
|
+
// gdd-events list-types
|
|
24
|
+
// - prints the runtime KNOWN_EVENT_TYPES list (from Plan 22-01)
|
|
25
|
+
//
|
|
26
|
+
// gdd-events serve [--port=<n>] [--token=<t>] [--tail=<file>]
|
|
27
|
+
// - WebSocket transport (Plan 22-07). Loaded lazily via
|
|
28
|
+
// probe-optional; helpful error if `ws` is not installed.
|
|
29
|
+
//
|
|
30
|
+
// Default --path is `.design/telemetry/events.jsonl` (relative to cwd).
|
|
31
|
+
|
|
32
|
+
import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
33
|
+
import { resolve, isAbsolute } from 'node:path';
|
|
34
|
+
import { pathToFileURL } from 'node:url';
|
|
35
|
+
import { argv, exit, stdout, stderr } from 'node:process';
|
|
36
|
+
import { createRequire } from 'node:module';
|
|
37
|
+
|
|
38
|
+
const require = createRequire(import.meta.url);
|
|
39
|
+
|
|
40
|
+
const DEFAULT_PATH = '.design/telemetry/events.jsonl';
|
|
41
|
+
|
|
42
|
+
function usage() {
|
|
43
|
+
stderr.write(
|
|
44
|
+
[
|
|
45
|
+
'gdd-events — Phase 22 event-stream CLI',
|
|
46
|
+
'',
|
|
47
|
+
'Usage:',
|
|
48
|
+
' gdd-events tail [--follow] [--path=<p>]',
|
|
49
|
+
' gdd-events grep <filter…> [--path=<p>]',
|
|
50
|
+
' gdd-events cat [--path=<p>]',
|
|
51
|
+
' gdd-events list-types',
|
|
52
|
+
' gdd-events serve [--port=<n>] [--token=<t>] [--tail=<file>]',
|
|
53
|
+
'',
|
|
54
|
+
'Filter language (grep): type=<s> payload.<dotted.path>=<s> !type=<s>',
|
|
55
|
+
'',
|
|
56
|
+
].join('\n'),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseArgs(args) {
|
|
61
|
+
const out = { _: [], flags: {} };
|
|
62
|
+
for (const a of args) {
|
|
63
|
+
if (a.startsWith('--')) {
|
|
64
|
+
const eq = a.indexOf('=');
|
|
65
|
+
if (eq === -1) {
|
|
66
|
+
out.flags[a.slice(2)] = true;
|
|
67
|
+
} else {
|
|
68
|
+
out.flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
out._.push(a);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolvePath(flagPath) {
|
|
78
|
+
const raw = flagPath || DEFAULT_PATH;
|
|
79
|
+
return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Compile filter terms like "type=foo", "!payload.x=1" into a predicate. */
|
|
83
|
+
export function compileFilter(terms) {
|
|
84
|
+
/** @type {Array<(ev: any) => boolean>} */
|
|
85
|
+
const checks = [];
|
|
86
|
+
for (const term of terms) {
|
|
87
|
+
let negate = false;
|
|
88
|
+
let body = term;
|
|
89
|
+
if (body.startsWith('!')) {
|
|
90
|
+
negate = true;
|
|
91
|
+
body = body.slice(1);
|
|
92
|
+
}
|
|
93
|
+
const eq = body.indexOf('=');
|
|
94
|
+
if (eq === -1) {
|
|
95
|
+
throw new Error(`gdd-events: bad filter term: ${term}`);
|
|
96
|
+
}
|
|
97
|
+
const key = body.slice(0, eq);
|
|
98
|
+
const want = body.slice(eq + 1);
|
|
99
|
+
/** @type {(ev: any) => boolean} */
|
|
100
|
+
let test;
|
|
101
|
+
if (key === 'type') {
|
|
102
|
+
test = (ev) => ev?.type === want;
|
|
103
|
+
} else if (key.startsWith('payload.')) {
|
|
104
|
+
const path = key.slice('payload.'.length).split('.');
|
|
105
|
+
test = (ev) => {
|
|
106
|
+
let cur = ev?.payload;
|
|
107
|
+
for (const part of path) {
|
|
108
|
+
if (cur == null || typeof cur !== 'object') return false;
|
|
109
|
+
cur = cur[part];
|
|
110
|
+
}
|
|
111
|
+
return String(cur) === want;
|
|
112
|
+
};
|
|
113
|
+
} else if (key === 'stage') {
|
|
114
|
+
test = (ev) => ev?.stage === want;
|
|
115
|
+
} else if (key === 'cycle') {
|
|
116
|
+
test = (ev) => ev?.cycle === want;
|
|
117
|
+
} else if (key === 'sessionId') {
|
|
118
|
+
test = (ev) => ev?.sessionId === want;
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error(`gdd-events: unsupported filter key: ${key}`);
|
|
121
|
+
}
|
|
122
|
+
checks.push(negate ? (ev) => !test(ev) : test);
|
|
123
|
+
}
|
|
124
|
+
return (ev) => checks.every((c) => c(ev));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function cmdTail(parsed) {
|
|
128
|
+
const path = resolvePath(parsed.flags.path);
|
|
129
|
+
const { readEvents } = await import('../lib/event-stream/reader.ts');
|
|
130
|
+
if (!parsed.flags.follow) {
|
|
131
|
+
for await (const ev of readEvents({ path })) {
|
|
132
|
+
stdout.write(JSON.stringify(ev) + '\n');
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
// Follow mode: stream existing content, then poll for appends.
|
|
137
|
+
let offset = 0;
|
|
138
|
+
if (existsSync(path)) {
|
|
139
|
+
for await (const ev of readEvents({ path })) {
|
|
140
|
+
stdout.write(JSON.stringify(ev) + '\n');
|
|
141
|
+
}
|
|
142
|
+
offset = statSync(path).size;
|
|
143
|
+
}
|
|
144
|
+
// Poll loop. Reads new bytes since last offset, splits on \n, writes each.
|
|
145
|
+
let buf = '';
|
|
146
|
+
// eslint-disable-next-line no-constant-condition
|
|
147
|
+
while (true) {
|
|
148
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
149
|
+
if (!existsSync(path)) continue;
|
|
150
|
+
const size = statSync(path).size;
|
|
151
|
+
if (size <= offset) continue;
|
|
152
|
+
const fd = openSync(path, 'r');
|
|
153
|
+
try {
|
|
154
|
+
const need = size - offset;
|
|
155
|
+
const chunk = Buffer.allocUnsafe(need);
|
|
156
|
+
const n = readSync(fd, chunk, 0, need, offset);
|
|
157
|
+
offset += n;
|
|
158
|
+
buf += chunk.subarray(0, n).toString('utf8');
|
|
159
|
+
const lines = buf.split('\n');
|
|
160
|
+
buf = lines.pop() || '';
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
if (line.trim() === '') continue;
|
|
163
|
+
stdout.write(line + '\n');
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdGrep(parsed) {
|
|
172
|
+
const path = resolvePath(parsed.flags.path);
|
|
173
|
+
const terms = parsed._;
|
|
174
|
+
if (terms.length === 0) {
|
|
175
|
+
stderr.write('gdd-events grep: at least one filter term required\n');
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
const predicate = compileFilter(terms);
|
|
179
|
+
const { readEvents } = await import('../lib/event-stream/reader.ts');
|
|
180
|
+
for await (const ev of readEvents({ path, predicate })) {
|
|
181
|
+
stdout.write(JSON.stringify(ev) + '\n');
|
|
182
|
+
}
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function cmdCat(parsed) {
|
|
187
|
+
const path = resolvePath(parsed.flags.path);
|
|
188
|
+
const { readEvents } = await import('../lib/event-stream/reader.ts');
|
|
189
|
+
for await (const ev of readEvents({ path })) {
|
|
190
|
+
const ts = ev.timestamp ?? '?';
|
|
191
|
+
const tp = ev.type ?? '?';
|
|
192
|
+
stdout.write(`${ts} ${tp.padEnd(28)} ${JSON.stringify(ev.payload ?? {})}\n`);
|
|
193
|
+
}
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function cmdListTypes() {
|
|
198
|
+
const { KNOWN_EVENT_TYPES } = await import('../lib/event-stream/types.ts');
|
|
199
|
+
for (const t of KNOWN_EVENT_TYPES) stdout.write(t + '\n');
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function cmdServe(parsed) {
|
|
204
|
+
let mod;
|
|
205
|
+
try {
|
|
206
|
+
mod = require('../lib/transports/ws.cjs');
|
|
207
|
+
} catch (err) {
|
|
208
|
+
stderr.write(
|
|
209
|
+
'gdd-events serve: WebSocket transport requires the optional `ws` package.\n' +
|
|
210
|
+
' install: npm i -D ws\n' +
|
|
211
|
+
` ${err && err.message ? err.message : String(err)}\n`,
|
|
212
|
+
);
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
const port = Number(parsed.flags.port) || 9595;
|
|
216
|
+
const token = parsed.flags.token || process.env.GDD_EVENTS_TOKEN;
|
|
217
|
+
if (!token) {
|
|
218
|
+
stderr.write('gdd-events serve: --token=<t> or GDD_EVENTS_TOKEN env required\n');
|
|
219
|
+
return 2;
|
|
220
|
+
}
|
|
221
|
+
const tailFrom = parsed.flags.tail
|
|
222
|
+
? resolvePath(parsed.flags.tail)
|
|
223
|
+
: resolvePath(undefined);
|
|
224
|
+
// Bridge live bus → ws transport. The transport is CommonJS and cannot
|
|
225
|
+
// require .ts directly, so we import the bus here and pass subscribeAll
|
|
226
|
+
// as a callback factory.
|
|
227
|
+
const { subscribeAll } = await import('../lib/event-stream/index.ts');
|
|
228
|
+
const subscribe = (handler) => subscribeAll(handler);
|
|
229
|
+
const handle = await mod.startServer({ port, token, tailFrom, subscribe });
|
|
230
|
+
stderr.write(`gdd-events: WebSocket listening on :${port} (auth required)\n`);
|
|
231
|
+
// Keep the process alive until SIGINT/SIGTERM.
|
|
232
|
+
await new Promise((resolve) => {
|
|
233
|
+
const close = () => {
|
|
234
|
+
handle.close();
|
|
235
|
+
resolve();
|
|
236
|
+
};
|
|
237
|
+
process.once('SIGINT', close);
|
|
238
|
+
process.once('SIGTERM', close);
|
|
239
|
+
});
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function main() {
|
|
244
|
+
const parsed = parseArgs(argv.slice(2));
|
|
245
|
+
const sub = parsed._.shift();
|
|
246
|
+
try {
|
|
247
|
+
switch (sub) {
|
|
248
|
+
case 'tail':
|
|
249
|
+
return await cmdTail(parsed);
|
|
250
|
+
case 'grep':
|
|
251
|
+
return await cmdGrep(parsed);
|
|
252
|
+
case 'cat':
|
|
253
|
+
return await cmdCat(parsed);
|
|
254
|
+
case 'list-types':
|
|
255
|
+
return await cmdListTypes();
|
|
256
|
+
case 'serve':
|
|
257
|
+
return await cmdServe(parsed);
|
|
258
|
+
case '-h':
|
|
259
|
+
case '--help':
|
|
260
|
+
case 'help':
|
|
261
|
+
usage();
|
|
262
|
+
return 0;
|
|
263
|
+
default:
|
|
264
|
+
usage();
|
|
265
|
+
return sub === undefined ? 0 : 2;
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
stderr.write(`gdd-events: ${err && err.message ? err.message : String(err)}\n`);
|
|
269
|
+
return 1;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Compare module URL via pathToFileURL — Windows paths use backslashes
|
|
274
|
+
// and need proper file:// URL canonicalisation; the simpler `file://${argv[1]}`
|
|
275
|
+
// form drops to false on Windows and the CLI silently no-ops.
|
|
276
|
+
const isCli = process.argv[1] !== undefined &&
|
|
277
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
278
|
+
if (isCli) {
|
|
279
|
+
main().then((code) => exit(code), (err) => {
|
|
280
|
+
stderr.write(`gdd-events fatal: ${err}\n`);
|
|
281
|
+
exit(1);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit-aggregator/index.cjs — dedup + score + rank findings from N
|
|
3
|
+
* audit-agents (Plan 23-04).
|
|
4
|
+
*
|
|
5
|
+
* Replaces the prompt-only "trust the agent's score" pattern with a
|
|
6
|
+
* deterministic scoring + dedup function that downstream tooling
|
|
7
|
+
* (`/gdd:audit`, `/gdd:reflect`) can rely on.
|
|
8
|
+
*
|
|
9
|
+
* Dedup key: `${lowercased(normalizePath(file))}::${line ?? 0}::${rule_id}`.
|
|
10
|
+
* Survivor selection on collision:
|
|
11
|
+
* 1. higher confidence wins
|
|
12
|
+
* 2. tie → higher severity (P0 > P1 > P2 > P3)
|
|
13
|
+
* 3. tie → lexicographically earliest agent
|
|
14
|
+
* 4. tie → first-seen
|
|
15
|
+
*
|
|
16
|
+
* Score = severityWeight(severity) * confidence.
|
|
17
|
+
*
|
|
18
|
+
* No external deps. CommonJS to match the rest of scripts/lib/.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const SEVERITY_RANK = { P0: 4, P1: 3, P2: 2, P3: 1 };
|
|
24
|
+
const DEFAULT_WEIGHTS = Object.freeze({ P0: 8, P1: 4, P2: 2, P3: 1 });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} Finding
|
|
28
|
+
* @property {string} file
|
|
29
|
+
* @property {number} [line]
|
|
30
|
+
* @property {string} rule_id
|
|
31
|
+
* @property {'P0'|'P1'|'P2'|'P3'} severity
|
|
32
|
+
* @property {string} summary
|
|
33
|
+
* @property {string} [evidence]
|
|
34
|
+
* @property {string} [agent]
|
|
35
|
+
* @property {number} [confidence]
|
|
36
|
+
* @property {string[]} [merged_from]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} AggregateResult
|
|
41
|
+
* @property {Finding[]} findings
|
|
42
|
+
* @property {Object<string, number>} byRule
|
|
43
|
+
* @property {Object<string, number>} bySeverity
|
|
44
|
+
* @property {Object<string, number>} byFile
|
|
45
|
+
* @property {number} total
|
|
46
|
+
* @property {number} duplicates
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} AggregateOptions
|
|
51
|
+
* @property {number} [topN]
|
|
52
|
+
* @property {Object<string, number>} [severityWeights]
|
|
53
|
+
* @property {(a: Finding, b: Finding) => Finding} [merge]
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
function normalizePath(p) {
|
|
57
|
+
return String(p).replace(/\\/g, '/').toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let _confidenceWarningEmitted = false;
|
|
61
|
+
|
|
62
|
+
function clampConfidence(c) {
|
|
63
|
+
if (c === undefined || c === null) return 1;
|
|
64
|
+
if (typeof c !== 'number' || Number.isNaN(c)) return 1;
|
|
65
|
+
if (c < 0) {
|
|
66
|
+
if (!_confidenceWarningEmitted) {
|
|
67
|
+
process.emitWarning('audit-aggregator: confidence < 0 clamped to 0', 'AuditAggregator');
|
|
68
|
+
_confidenceWarningEmitted = true;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
if (c > 1) {
|
|
73
|
+
if (!_confidenceWarningEmitted) {
|
|
74
|
+
process.emitWarning('audit-aggregator: confidence > 1 clamped to 1', 'AuditAggregator');
|
|
75
|
+
_confidenceWarningEmitted = true;
|
|
76
|
+
}
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
return c;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute score for a finding.
|
|
84
|
+
*
|
|
85
|
+
* @param {Finding} f
|
|
86
|
+
* @param {Object<string, number>} weights
|
|
87
|
+
* @returns {number}
|
|
88
|
+
*/
|
|
89
|
+
function score(f, weights) {
|
|
90
|
+
const w = (weights && weights[f.severity]) ?? DEFAULT_WEIGHTS[f.severity] ?? 0;
|
|
91
|
+
return w * clampConfidence(f.confidence);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateFinding(f, idx) {
|
|
95
|
+
if (!f || typeof f !== 'object') {
|
|
96
|
+
throw new TypeError(`audit-aggregator: input[${idx}] is not an object`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof f.file !== 'string' || f.file.length === 0) {
|
|
99
|
+
throw new TypeError(`audit-aggregator: input[${idx}].file is required (non-empty string)`);
|
|
100
|
+
}
|
|
101
|
+
if (typeof f.rule_id !== 'string' || f.rule_id.length === 0) {
|
|
102
|
+
throw new TypeError(`audit-aggregator: input[${idx}].rule_id is required (non-empty string)`);
|
|
103
|
+
}
|
|
104
|
+
if (!(f.severity in SEVERITY_RANK)) {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
`audit-aggregator: input[${idx}].severity must be P0|P1|P2|P3 (got ${JSON.stringify(f.severity)})`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function dedupKey(f) {
|
|
112
|
+
return `${normalizePath(f.file)}::${f.line ?? 0}::${f.rule_id}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function defaultMerge(a, b) {
|
|
116
|
+
// Higher confidence wins.
|
|
117
|
+
const ca = clampConfidence(a.confidence);
|
|
118
|
+
const cb = clampConfidence(b.confidence);
|
|
119
|
+
if (ca !== cb) return ca > cb ? a : b;
|
|
120
|
+
// Higher severity wins.
|
|
121
|
+
const ra = SEVERITY_RANK[a.severity];
|
|
122
|
+
const rb = SEVERITY_RANK[b.severity];
|
|
123
|
+
if (ra !== rb) return ra > rb ? a : b;
|
|
124
|
+
// Lexicographic agent.
|
|
125
|
+
const aa = a.agent ?? '';
|
|
126
|
+
const ab = b.agent ?? '';
|
|
127
|
+
if (aa !== ab) return aa < ab ? a : b;
|
|
128
|
+
// First-seen wins (a is by convention the existing entry).
|
|
129
|
+
return a;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Aggregate findings.
|
|
134
|
+
*
|
|
135
|
+
* @param {Finding[]} input
|
|
136
|
+
* @param {AggregateOptions} [opts]
|
|
137
|
+
* @returns {AggregateResult}
|
|
138
|
+
*/
|
|
139
|
+
function aggregate(input, opts = {}) {
|
|
140
|
+
if (!Array.isArray(input)) {
|
|
141
|
+
throw new TypeError('audit-aggregator: input must be an array');
|
|
142
|
+
}
|
|
143
|
+
// Reset the once-per-call warning flag so a second call can warn again.
|
|
144
|
+
_confidenceWarningEmitted = false;
|
|
145
|
+
const merge = typeof opts.merge === 'function' ? opts.merge : defaultMerge;
|
|
146
|
+
const weights = { ...DEFAULT_WEIGHTS, ...(opts.severityWeights || {}) };
|
|
147
|
+
|
|
148
|
+
/** @type {Map<string, Finding>} */
|
|
149
|
+
const byKey = new Map();
|
|
150
|
+
let duplicates = 0;
|
|
151
|
+
for (let i = 0; i < input.length; i++) {
|
|
152
|
+
validateFinding(input[i], i);
|
|
153
|
+
const f = { ...input[i] };
|
|
154
|
+
const key = dedupKey(f);
|
|
155
|
+
if (byKey.has(key)) {
|
|
156
|
+
duplicates += 1;
|
|
157
|
+
const existing = byKey.get(key);
|
|
158
|
+
const winner = merge(existing, f);
|
|
159
|
+
const loser = winner === existing ? f : existing;
|
|
160
|
+
const mergedFrom = new Set(winner.merged_from || []);
|
|
161
|
+
if (existing.agent && existing !== winner) mergedFrom.add(existing.agent);
|
|
162
|
+
if (loser.agent && loser !== winner) mergedFrom.add(loser.agent);
|
|
163
|
+
// Combine prior merged_from too.
|
|
164
|
+
for (const a of (loser.merged_from || [])) mergedFrom.add(a);
|
|
165
|
+
winner.merged_from = Array.from(mergedFrom);
|
|
166
|
+
byKey.set(key, winner);
|
|
167
|
+
} else {
|
|
168
|
+
byKey.set(key, f);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const findings = Array.from(byKey.values()).map((f) => ({ ...f, _score: score(f, weights) }));
|
|
173
|
+
findings.sort((a, b) => {
|
|
174
|
+
if (a._score !== b._score) return b._score - a._score;
|
|
175
|
+
const ra = SEVERITY_RANK[a.severity];
|
|
176
|
+
const rb = SEVERITY_RANK[b.severity];
|
|
177
|
+
if (ra !== rb) return rb - ra;
|
|
178
|
+
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
179
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
180
|
+
});
|
|
181
|
+
// Strip the internal _score field before returning.
|
|
182
|
+
for (const f of findings) delete f._score;
|
|
183
|
+
|
|
184
|
+
const truncated = typeof opts.topN === 'number' && opts.topN >= 0
|
|
185
|
+
? findings.slice(0, opts.topN)
|
|
186
|
+
: findings;
|
|
187
|
+
|
|
188
|
+
/** @type {Record<string, number>} */
|
|
189
|
+
const byRule = {};
|
|
190
|
+
/** @type {Record<string, number>} */
|
|
191
|
+
const bySeverity = { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
192
|
+
/** @type {Record<string, number>} */
|
|
193
|
+
const byFile = {};
|
|
194
|
+
for (const f of truncated) {
|
|
195
|
+
byRule[f.rule_id] = (byRule[f.rule_id] ?? 0) + 1;
|
|
196
|
+
bySeverity[f.severity] += 1;
|
|
197
|
+
const k = normalizePath(f.file);
|
|
198
|
+
byFile[k] = (byFile[k] ?? 0) + 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
findings: truncated,
|
|
203
|
+
byRule,
|
|
204
|
+
bySeverity,
|
|
205
|
+
byFile,
|
|
206
|
+
total: truncated.length,
|
|
207
|
+
duplicates,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
aggregate,
|
|
213
|
+
score,
|
|
214
|
+
normalizePath,
|
|
215
|
+
dedupKey,
|
|
216
|
+
defaultMerge,
|
|
217
|
+
DEFAULT_WEIGHTS,
|
|
218
|
+
SEVERITY_RANK,
|
|
219
|
+
};
|