council-review 0.1.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/LICENSE +21 -0
- package/README.md +374 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +19 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +829 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +61 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +235 -0
- package/dist/config.js.map +1 -0
- package/dist/herdr.d.ts +36 -0
- package/dist/herdr.d.ts.map +1 -0
- package/dist/herdr.js +196 -0
- package/dist/herdr.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/levels.d.ts +9 -0
- package/dist/levels.d.ts.map +1 -0
- package/dist/levels.js +19 -0
- package/dist/levels.js.map +1 -0
- package/dist/merge.d.ts +90 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +441 -0
- package/dist/merge.js.map +1 -0
- package/dist/node-guard.d.ts +9 -0
- package/dist/node-guard.d.ts.map +1 -0
- package/dist/node-guard.js +21 -0
- package/dist/node-guard.js.map +1 -0
- package/dist/panel.d.ts +89 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +275 -0
- package/dist/panel.js.map +1 -0
- package/dist/picker.d.ts +24 -0
- package/dist/picker.d.ts.map +1 -0
- package/dist/picker.js +182 -0
- package/dist/picker.js.map +1 -0
- package/dist/providers.d.ts +66 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +544 -0
- package/dist/providers.js.map +1 -0
- package/dist/report.d.ts +116 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +493 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts +58 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +147 -0
- package/dist/resolve.js.map +1 -0
- package/dist/reviewer-spawn.d.ts +67 -0
- package/dist/reviewer-spawn.d.ts.map +1 -0
- package/dist/reviewer-spawn.js +94 -0
- package/dist/reviewer-spawn.js.map +1 -0
- package/dist/reviewer-tools.d.ts +113 -0
- package/dist/reviewer-tools.d.ts.map +1 -0
- package/dist/reviewer-tools.js +557 -0
- package/dist/reviewer-tools.js.map +1 -0
- package/dist/runner.d.ts +79 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +811 -0
- package/dist/runner.js.map +1 -0
- package/dist/schema.d.ts +63 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +302 -0
- package/dist/schema.js.map +1 -0
- package/dist/scope.d.ts +40 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +372 -0
- package/dist/scope.js.map +1 -0
- package/dist/snapshot.d.ts +57 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +471 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/status.d.ts +90 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +336 -0
- package/dist/status.js.map +1 -0
- package/dist/thinking.d.ts +53 -0
- package/dist/thinking.d.ts.map +1 -0
- package/dist/thinking.js +116 -0
- package/dist/thinking.js.map +1 -0
- package/package.json +81 -0
- package/src/bin.ts +20 -0
- package/src/cli.ts +996 -0
- package/src/config.ts +338 -0
- package/src/herdr.ts +231 -0
- package/src/index.ts +30 -0
- package/src/levels.ts +23 -0
- package/src/merge.ts +548 -0
- package/src/node-guard.ts +25 -0
- package/src/panel.ts +354 -0
- package/src/picker.ts +230 -0
- package/src/providers.ts +778 -0
- package/src/report.ts +594 -0
- package/src/resolve.ts +179 -0
- package/src/reviewer-spawn.ts +131 -0
- package/src/reviewer-tools.ts +715 -0
- package/src/runner.ts +1120 -0
- package/src/schema.ts +373 -0
- package/src/scope.ts +448 -0
- package/src/snapshot.ts +536 -0
- package/src/status.ts +470 -0
- package/src/thinking.ts +148 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library entry point: re-exports the public API of every module this package ships, so a
|
|
3
|
+
* consumer can `import { ... } from 'council-review'` instead of reaching into `dist/*.js`
|
|
4
|
+
* directly.
|
|
5
|
+
*
|
|
6
|
+
* `src/reviewer-tools.ts` is deliberately excluded. It is loaded by path into a foreign reviewer
|
|
7
|
+
* process, never imported by this package's own runtime (see its own header comment and
|
|
8
|
+
* `design.md`'s "Pass the reviewer toolset by path at spawn; never install it into the host") —
|
|
9
|
+
* re-exporting it here would violate that separation for no benefit, since it has nothing this
|
|
10
|
+
* package's own consumers would call directly.
|
|
11
|
+
*
|
|
12
|
+
* `src/cli.ts` is the executable entry point (`bin.council-review` in package.json), not part of
|
|
13
|
+
* the library surface, and is not re-exported here either.
|
|
14
|
+
*/
|
|
15
|
+
export * from './levels.js';
|
|
16
|
+
export * from './config.js';
|
|
17
|
+
export * from './providers.js';
|
|
18
|
+
export * from './thinking.js';
|
|
19
|
+
export * from './panel.js';
|
|
20
|
+
export * from './picker.js';
|
|
21
|
+
export * from './scope.js';
|
|
22
|
+
export * from './snapshot.js';
|
|
23
|
+
export * from './runner.js';
|
|
24
|
+
export * from './schema.js';
|
|
25
|
+
export * from './merge.js';
|
|
26
|
+
export * from './resolve.js';
|
|
27
|
+
export * from './report.js';
|
|
28
|
+
export * from './herdr.js';
|
|
29
|
+
export * from './reviewer-spawn.js';
|
|
30
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library entry point: re-exports the public API of every module this package ships, so a
|
|
3
|
+
* consumer can `import { ... } from 'council-review'` instead of reaching into `dist/*.js`
|
|
4
|
+
* directly.
|
|
5
|
+
*
|
|
6
|
+
* `src/reviewer-tools.ts` is deliberately excluded. It is loaded by path into a foreign reviewer
|
|
7
|
+
* process, never imported by this package's own runtime (see its own header comment and
|
|
8
|
+
* `design.md`'s "Pass the reviewer toolset by path at spawn; never install it into the host") —
|
|
9
|
+
* re-exporting it here would violate that separation for no benefit, since it has nothing this
|
|
10
|
+
* package's own consumers would call directly.
|
|
11
|
+
*
|
|
12
|
+
* `src/cli.ts` is the executable entry point (`bin.council-review` in package.json), not part of
|
|
13
|
+
* the library surface, and is not re-exported here either.
|
|
14
|
+
*/
|
|
15
|
+
export * from './levels.js';
|
|
16
|
+
export * from './config.js';
|
|
17
|
+
export * from './providers.js';
|
|
18
|
+
export * from './thinking.js';
|
|
19
|
+
export * from './panel.js';
|
|
20
|
+
export * from './picker.js';
|
|
21
|
+
export * from './scope.js';
|
|
22
|
+
export * from './snapshot.js';
|
|
23
|
+
export * from './runner.js';
|
|
24
|
+
export * from './schema.js';
|
|
25
|
+
export * from './merge.js';
|
|
26
|
+
export * from './resolve.js';
|
|
27
|
+
export * from './report.js';
|
|
28
|
+
export * from './herdr.js';
|
|
29
|
+
export * from './reviewer-spawn.js';
|
|
30
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC"}
|
package/dist/levels.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared thinking-level vocabulary. Mirrors the review agent host's own level names, so
|
|
3
|
+
* there is exactly one vocabulary to learn across this tool and the host it spawns.
|
|
4
|
+
*/
|
|
5
|
+
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
6
|
+
/** Ordered from lowest to highest. Ordinal distance in this array drives clamping. */
|
|
7
|
+
export declare const THINKING_LEVELS: readonly ThinkingLevel[];
|
|
8
|
+
export declare function isThinkingLevel(v: unknown): v is ThinkingLevel;
|
|
9
|
+
//# sourceMappingURL=levels.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"levels.d.ts","sourceRoot":"","sources":["../src/levels.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC;AAE5F,sFAAsF;AACtF,eAAO,MAAM,eAAe,EAAE,SAAS,aAAa,EAQ1C,CAAC;AAIX,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,aAAa,CAE9D"}
|
package/dist/levels.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared thinking-level vocabulary. Mirrors the review agent host's own level names, so
|
|
3
|
+
* there is exactly one vocabulary to learn across this tool and the host it spawns.
|
|
4
|
+
*/
|
|
5
|
+
/** Ordered from lowest to highest. Ordinal distance in this array drives clamping. */
|
|
6
|
+
export const THINKING_LEVELS = [
|
|
7
|
+
'off',
|
|
8
|
+
'minimal',
|
|
9
|
+
'low',
|
|
10
|
+
'medium',
|
|
11
|
+
'high',
|
|
12
|
+
'xhigh',
|
|
13
|
+
'max',
|
|
14
|
+
];
|
|
15
|
+
const THINKING_LEVEL_SET = new Set(THINKING_LEVELS);
|
|
16
|
+
export function isThinkingLevel(v) {
|
|
17
|
+
return typeof v === 'string' && THINKING_LEVEL_SET.has(v);
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=levels.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"levels.js","sourceRoot":"","sources":["../src/levels.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,sFAAsF;AACtF,MAAM,CAAC,MAAM,eAAe,GAA6B;IACvD,KAAK;IACL,SAAS;IACT,KAAK;IACL,QAAQ;IACR,MAAM;IACN,OAAO;IACP,KAAK;CACG,CAAC;AAEX,MAAM,kBAAkB,GAAwB,IAAI,GAAG,CAAC,eAAe,CAAC,CAAC;AAEzE,MAAM,UAAU,eAAe,CAAC,CAAU;IACxC,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAC5D,CAAC"}
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merges independent reviewers' findings into one ranked list by deterministic rules only.
|
|
3
|
+
*
|
|
4
|
+
* No model is ever called here, and nothing about the output depends on wall-clock time,
|
|
5
|
+
* randomness or iteration order — see the "Merging is deterministic and model-free" and
|
|
6
|
+
* "Deterministic ordering and identifiers" requirements in
|
|
7
|
+
* `openspec/changes/add-council-review/specs/council-review/findings-merge/spec.md`.
|
|
8
|
+
*
|
|
9
|
+
* This module intentionally does not import from `./runner.js`: its input is the narrow
|
|
10
|
+
* `ReviewerFindings` shape below, not the runner's `ReviewerResult`. Callers (report.ts, cli.ts)
|
|
11
|
+
* adapt `ReviewerResult[]` to `ReviewerFindings[]` at the call site.
|
|
12
|
+
*/
|
|
13
|
+
import type { IgnoreFile } from './config.js';
|
|
14
|
+
import type { RawFinding, Severity } from './schema.js';
|
|
15
|
+
/** One reviewer's contribution to a merge. `findings === null` means it never reported (failed,
|
|
16
|
+
* timed out or went over budget) — it counts toward `launched` but not `reporting`. */
|
|
17
|
+
export interface ReviewerFindings {
|
|
18
|
+
reviewerId: string;
|
|
19
|
+
findings: RawFinding[] | null;
|
|
20
|
+
}
|
|
21
|
+
export interface SourceRef {
|
|
22
|
+
reviewer: string;
|
|
23
|
+
findingId: string;
|
|
24
|
+
severity: Severity;
|
|
25
|
+
claim: string;
|
|
26
|
+
}
|
|
27
|
+
export interface MergedFinding {
|
|
28
|
+
id: string;
|
|
29
|
+
fingerprint: string;
|
|
30
|
+
file: string;
|
|
31
|
+
line: number;
|
|
32
|
+
endLine: number | null;
|
|
33
|
+
severity: Severity;
|
|
34
|
+
category: string;
|
|
35
|
+
claim: string;
|
|
36
|
+
impact: string;
|
|
37
|
+
evidence: string[];
|
|
38
|
+
suggestions: string[];
|
|
39
|
+
raisedBy: string[];
|
|
40
|
+
perReviewerSeverity: Record<string, Severity>;
|
|
41
|
+
sources: SourceRef[];
|
|
42
|
+
agreement: {
|
|
43
|
+
raisers: number;
|
|
44
|
+
reporting: number;
|
|
45
|
+
};
|
|
46
|
+
unverifiable: boolean;
|
|
47
|
+
memberFingerprints: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface MergeOptions {
|
|
50
|
+
mergeWindow: number;
|
|
51
|
+
claimSimilarity: number;
|
|
52
|
+
ignore?: IgnoreFile;
|
|
53
|
+
suppress: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface MergeOutcome {
|
|
56
|
+
findings: MergedFinding[];
|
|
57
|
+
suppressed: number;
|
|
58
|
+
launched: number;
|
|
59
|
+
reporting: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Lowercases `claim`, strips punctuation, collapses whitespace and removes stopwords, joining
|
|
63
|
+
* the remaining tokens with single spaces. An input with no non-stopword content normalises to
|
|
64
|
+
* the empty string.
|
|
65
|
+
*/
|
|
66
|
+
export declare function normaliseClaim(claim: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* Fingerprints `file` + `claim`, deliberately excluding line numbers — that is what lets a
|
|
69
|
+
* suppression or a resolution match survive a refactor that moves the code. SHA-256 over the
|
|
70
|
+
* two normalised strings, joined by a NUL byte so no legal path/claim combination can collide
|
|
71
|
+
* across the join point: a normalised file path can contain any printable character (including
|
|
72
|
+
* a literal space, which is why the separator is not one) but never embeds a NUL, and neither
|
|
73
|
+
* does a normalised claim, so `\0` is the one byte that is unambiguously never part of either
|
|
74
|
+
* side. Do not "simplify" this to a space, comma or other visible separator — that would
|
|
75
|
+
* reopen the collision `fingerprint('a/b.ts', 'c')` vs `fingerprint('a', 'b.ts c')` that the
|
|
76
|
+
* NUL join specifically closes.
|
|
77
|
+
*
|
|
78
|
+
* NOTE: this must stay the `\0` escape sequence, not a raw embedded NUL byte — the two are
|
|
79
|
+
* byte-identical once the template literal is evaluated (both are U+0000 in the UTF-8 output
|
|
80
|
+
* fed to the hash), but a literal NUL in the source file itself makes git treat merge.ts as
|
|
81
|
+
* binary (no diffs, no blame, no line-level PR review) for no benefit. The escape sequence is
|
|
82
|
+
* the same fingerprint, in a source file git can still read as text.
|
|
83
|
+
*/
|
|
84
|
+
export declare function fingerprint(file: string, claim: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* Merges `results` into one deterministic, ranked findings list. Pure function of its inputs:
|
|
87
|
+
* no model call, no network access, no randomness, no reliance on the wall clock.
|
|
88
|
+
*/
|
|
89
|
+
export declare function mergeFindings(results: readonly ReviewerFindings[], o: MergeOptions): MergeOutcome;
|
|
90
|
+
//# sourceMappingURL=merge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../src/merge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAMxD;uFACuF;AACvF,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,UAAU,EAAE,GAAG,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,QAAQ,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAC9C,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,SAAS,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAClD,YAAY,EAAE,OAAO,CAAC;IACtB,kBAAkB,EAAE,MAAM,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AA8BD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAWpD;AAqDD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAG/D;AAuUD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,gBAAgB,EAAE,EAAE,CAAC,EAAE,YAAY,GAAG,YAAY,CA6BjG"}
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merges independent reviewers' findings into one ranked list by deterministic rules only.
|
|
3
|
+
*
|
|
4
|
+
* No model is ever called here, and nothing about the output depends on wall-clock time,
|
|
5
|
+
* randomness or iteration order — see the "Merging is deterministic and model-free" and
|
|
6
|
+
* "Deterministic ordering and identifiers" requirements in
|
|
7
|
+
* `openspec/changes/add-council-review/specs/council-review/findings-merge/spec.md`.
|
|
8
|
+
*
|
|
9
|
+
* This module intentionally does not import from `./runner.js`: its input is the narrow
|
|
10
|
+
* `ReviewerFindings` shape below, not the runner's `ReviewerResult`. Callers (report.ts, cli.ts)
|
|
11
|
+
* adapt `ReviewerResult[]` to `ReviewerFindings[]` at the call site.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
// -------------------------------------------------------------------------------------------
|
|
15
|
+
// Claim normalisation
|
|
16
|
+
//
|
|
17
|
+
// Lowercases, strips punctuation (anything that is not a Unicode letter, digit or whitespace),
|
|
18
|
+
// collapses whitespace, and drops a small fixed stopword list. This is the normalisation both
|
|
19
|
+
// `fingerprint` and clustering's claim-similarity check are built on, so a fingerprint survives
|
|
20
|
+
// wording variation (different punctuation, casing or filler words) while still changing for a
|
|
21
|
+
// materially different claim.
|
|
22
|
+
// -------------------------------------------------------------------------------------------
|
|
23
|
+
const STOPWORDS = new Set([
|
|
24
|
+
'a', 'an', 'the', 'this', 'that', 'these', 'those',
|
|
25
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
26
|
+
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'into', 'onto', 'as',
|
|
27
|
+
'and', 'or', 'but', 'nor', 'not', 'no',
|
|
28
|
+
'it', 'its', 'which', 'who', 'whom', 'whose', 'what', 'when', 'where', 'why', 'how',
|
|
29
|
+
'does', 'do', 'did', 'done', 'has', 'have', 'had', 'having',
|
|
30
|
+
'will', 'would', 'shall', 'should', 'can', 'could', 'may', 'might', 'must',
|
|
31
|
+
'than', 'then', 'there', 'their', 'them', 'they', 'he', 'she', 'his', 'her',
|
|
32
|
+
'you', 'your', 'we', 'our', 'i', 'me', 'my',
|
|
33
|
+
'if', 'so', 'such', 'too', 'very', 'just', 'also', 'about', 'over', 'under',
|
|
34
|
+
'again', 'further', 'once', 'here', 'all', 'any', 'both', 'each', 'few',
|
|
35
|
+
'more', 'most', 'other', 'some', 'only', 'own', 'same', 'out', 'up', 'down', 'off',
|
|
36
|
+
]); // prettier-ignore
|
|
37
|
+
const PUNCTUATION_RE = /[^\p{L}\p{N}\s]+/gu;
|
|
38
|
+
const WHITESPACE_RE = /\s+/g;
|
|
39
|
+
/**
|
|
40
|
+
* Lowercases `claim`, strips punctuation, collapses whitespace and removes stopwords, joining
|
|
41
|
+
* the remaining tokens with single spaces. An input with no non-stopword content normalises to
|
|
42
|
+
* the empty string.
|
|
43
|
+
*/
|
|
44
|
+
export function normaliseClaim(claim) {
|
|
45
|
+
const collapsed = claim
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(PUNCTUATION_RE, ' ')
|
|
48
|
+
.replace(WHITESPACE_RE, ' ')
|
|
49
|
+
.trim();
|
|
50
|
+
if (collapsed === '')
|
|
51
|
+
return '';
|
|
52
|
+
return collapsed
|
|
53
|
+
.split(' ')
|
|
54
|
+
.filter((token) => token.length > 0 && !STOPWORDS.has(token))
|
|
55
|
+
.join(' ');
|
|
56
|
+
}
|
|
57
|
+
function claimTokens(claim) {
|
|
58
|
+
const normalised = normaliseClaim(claim);
|
|
59
|
+
return normalised === '' ? new Set() : new Set(normalised.split(' '));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Overlap coefficient over normalised claim tokens: |intersection| / min(|A|, |B|). Two claims
|
|
63
|
+
* that both normalise to nothing are treated as identical (similarity 1), matching the
|
|
64
|
+
* fingerprint behaviour for the same case.
|
|
65
|
+
*
|
|
66
|
+
* This used to be a plain Jaccard index (|intersection| / |union|), which punishes asymmetric
|
|
67
|
+
* verbosity: one reviewer writes a terse claim, another a detailed one covering the same
|
|
68
|
+
* defect, and the union balloons with the longer claim's extra words while the intersection
|
|
69
|
+
* stays fixed — driving the score down even though every one of the terse claim's tokens is
|
|
70
|
+
* present in the detailed one. Measured against real multi-model output (three models
|
|
71
|
+
* reviewing the same two-line diff), independent models' paraphrases of one defect landed at
|
|
72
|
+
* Jaccard 0.37-0.42 — below any threshold that would not also merge genuinely distinct
|
|
73
|
+
* findings elsewhere — which meant a defect every reviewer agreed on was never clustering, and
|
|
74
|
+
* shipped downstream as several separate low-agreement, "treat with skepticism" findings
|
|
75
|
+
* instead of one high-agreement one. The overlap coefficient is insensitive to one side simply
|
|
76
|
+
* being longer: it only asks whether the *shorter* claim's tokens are (mostly) a subset of the
|
|
77
|
+
* longer one's, which is exactly the "same defect, different verbosity" shape this needed to
|
|
78
|
+
* detect. It was checked against every existing clustering fixture before adopting it (see
|
|
79
|
+
* `test/unit/merge.test.ts`): it does not flip any should-not-merge case into a merge.
|
|
80
|
+
*/
|
|
81
|
+
function claimOverlap(a, b) {
|
|
82
|
+
const ta = claimTokens(a);
|
|
83
|
+
const tb = claimTokens(b);
|
|
84
|
+
if (ta.size === 0 && tb.size === 0)
|
|
85
|
+
return 1;
|
|
86
|
+
if (ta.size === 0 || tb.size === 0)
|
|
87
|
+
return 0; // one side has no content-bearing tokens at all
|
|
88
|
+
let intersection = 0;
|
|
89
|
+
for (const token of ta)
|
|
90
|
+
if (tb.has(token))
|
|
91
|
+
intersection++;
|
|
92
|
+
return intersection / Math.min(ta.size, tb.size);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A lighter normalisation for file paths: lowercased, backslashes folded to forward slashes, a
|
|
96
|
+
* leading "./" dropped, repeated slashes collapsed, and a trailing slash trimmed. Path
|
|
97
|
+
* separators and extensions are structural, not punctuation to strip — unlike claim text,
|
|
98
|
+
* stopword removal does not apply here.
|
|
99
|
+
*/
|
|
100
|
+
function normaliseFilePath(file) {
|
|
101
|
+
return file
|
|
102
|
+
.trim()
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.replace(/\\/g, '/')
|
|
105
|
+
.replace(/^\.\//, '')
|
|
106
|
+
.replace(/\/+/g, '/')
|
|
107
|
+
.replace(/\/$/, '');
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fingerprints `file` + `claim`, deliberately excluding line numbers — that is what lets a
|
|
111
|
+
* suppression or a resolution match survive a refactor that moves the code. SHA-256 over the
|
|
112
|
+
* two normalised strings, joined by a NUL byte so no legal path/claim combination can collide
|
|
113
|
+
* across the join point: a normalised file path can contain any printable character (including
|
|
114
|
+
* a literal space, which is why the separator is not one) but never embeds a NUL, and neither
|
|
115
|
+
* does a normalised claim, so `\0` is the one byte that is unambiguously never part of either
|
|
116
|
+
* side. Do not "simplify" this to a space, comma or other visible separator — that would
|
|
117
|
+
* reopen the collision `fingerprint('a/b.ts', 'c')` vs `fingerprint('a', 'b.ts c')` that the
|
|
118
|
+
* NUL join specifically closes.
|
|
119
|
+
*
|
|
120
|
+
* NOTE: this must stay the `\0` escape sequence, not a raw embedded NUL byte — the two are
|
|
121
|
+
* byte-identical once the template literal is evaluated (both are U+0000 in the UTF-8 output
|
|
122
|
+
* fed to the hash), but a literal NUL in the source file itself makes git treat merge.ts as
|
|
123
|
+
* binary (no diffs, no blame, no line-level PR review) for no benefit. The escape sequence is
|
|
124
|
+
* the same fingerprint, in a source file git can still read as text.
|
|
125
|
+
*/
|
|
126
|
+
export function fingerprint(file, claim) {
|
|
127
|
+
const key = `${normaliseFilePath(file)}\0${normaliseClaim(claim)}`;
|
|
128
|
+
return createHash('sha256').update(key, 'utf8').digest('hex');
|
|
129
|
+
}
|
|
130
|
+
// -------------------------------------------------------------------------------------------
|
|
131
|
+
// Clustering
|
|
132
|
+
// -------------------------------------------------------------------------------------------
|
|
133
|
+
const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
134
|
+
function lineRange(f) {
|
|
135
|
+
return [f.line, f.endLine ?? f.line];
|
|
136
|
+
}
|
|
137
|
+
/** 0 when the ranges overlap; otherwise the number of lines strictly between them. */
|
|
138
|
+
function rangeGap(a, b) {
|
|
139
|
+
if (a[1] < b[0])
|
|
140
|
+
return b[0] - a[1];
|
|
141
|
+
if (b[1] < a[0])
|
|
142
|
+
return a[0] - b[1];
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Categories that are treated as interchangeable for clustering purposes, beyond plain
|
|
147
|
+
* equality. "security" and "correctness" are the one pair listed here: whether a loose-equality
|
|
148
|
+
* credential check, say, is a security defect or a correctness defect is routine taxonomic
|
|
149
|
+
* disagreement between independent reviewers describing the *same* bug, not a sign they are
|
|
150
|
+
* looking at different problems — measured live, two models raising the identical `==` vs
|
|
151
|
+
* `===` defect on the same line split exactly along this line (one called it "security", the
|
|
152
|
+
* other "correctness"), and a hard category-equality requirement kept them from ever
|
|
153
|
+
* clustering. No other pair is added deliberately: a "security" vs "style" split on an
|
|
154
|
+
* identical claim (see `different-categories.json`) is a much stronger signal of two reviewers
|
|
155
|
+
* flagging genuinely different concerns on the same line (e.g. an injection risk versus a
|
|
156
|
+
* naming nit) than of taxonomic disagreement about one concern, and merging those would hide a
|
|
157
|
+
* real defect inside a cosmetic one — worse than leaving a duplicate category label in the
|
|
158
|
+
* findings list.
|
|
159
|
+
*/
|
|
160
|
+
const COMPATIBLE_CATEGORIES = new Set(['security', 'correctness']);
|
|
161
|
+
function categoriesCompatible(a, b) {
|
|
162
|
+
const na = a.trim().toLowerCase();
|
|
163
|
+
const nb = b.trim().toLowerCase();
|
|
164
|
+
if (na === nb)
|
|
165
|
+
return true;
|
|
166
|
+
return COMPATIBLE_CATEGORIES.has(na) && COMPATIBLE_CATEGORIES.has(nb);
|
|
167
|
+
}
|
|
168
|
+
/** The conjunction that decides whether two findings in the same file belong to one cluster:
|
|
169
|
+
* proximate-or-overlapping lines, AND compatible categories, AND similar-enough claims. Each
|
|
170
|
+
* condition alone is not enough — that is the point of the negative-case scenarios. */
|
|
171
|
+
function shouldMerge(a, b, o) {
|
|
172
|
+
if (rangeGap(lineRange(a), lineRange(b)) > o.mergeWindow)
|
|
173
|
+
return false;
|
|
174
|
+
if (!categoriesCompatible(a.category, b.category))
|
|
175
|
+
return false;
|
|
176
|
+
return claimOverlap(a.claim, b.claim) >= o.claimSimilarity;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Per-file union-find over `flat`. Findings are grouped by their exact `file` string first, so
|
|
180
|
+
* two findings in different files are never even compared, let alone unioned — clustering
|
|
181
|
+
* cannot cross a file boundary. Within a file, clustering is transitive: unioning is pairwise,
|
|
182
|
+
* but the resulting connected components let a chain of pairwise merges form one cluster even
|
|
183
|
+
* where the endpoints would not merge directly.
|
|
184
|
+
*/
|
|
185
|
+
function clusterFlatFindings(flat, o) {
|
|
186
|
+
const byFile = new Map();
|
|
187
|
+
flat.forEach((f, i) => {
|
|
188
|
+
const list = byFile.get(f.finding.file);
|
|
189
|
+
if (list)
|
|
190
|
+
list.push(i);
|
|
191
|
+
else
|
|
192
|
+
byFile.set(f.finding.file, [i]);
|
|
193
|
+
});
|
|
194
|
+
const parent = flat.map((_, i) => i);
|
|
195
|
+
function find(x) {
|
|
196
|
+
while (parent[x] !== x) {
|
|
197
|
+
parent[x] = parent[parent[x]];
|
|
198
|
+
x = parent[x];
|
|
199
|
+
}
|
|
200
|
+
return x;
|
|
201
|
+
}
|
|
202
|
+
function union(a, b) {
|
|
203
|
+
const ra = find(a);
|
|
204
|
+
const rb = find(b);
|
|
205
|
+
if (ra !== rb)
|
|
206
|
+
parent[ra] = rb;
|
|
207
|
+
}
|
|
208
|
+
for (const indices of byFile.values()) {
|
|
209
|
+
for (let i = 0; i < indices.length; i++) {
|
|
210
|
+
for (let j = i + 1; j < indices.length; j++) {
|
|
211
|
+
const a = flat[indices[i]];
|
|
212
|
+
const b = flat[indices[j]];
|
|
213
|
+
if (shouldMerge(a.finding, b.finding, o))
|
|
214
|
+
union(indices[i], indices[j]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const groups = new Map();
|
|
219
|
+
flat.forEach((_, i) => {
|
|
220
|
+
const root = find(i);
|
|
221
|
+
const list = groups.get(root);
|
|
222
|
+
if (list)
|
|
223
|
+
list.push(i);
|
|
224
|
+
else
|
|
225
|
+
groups.set(root, [i]);
|
|
226
|
+
});
|
|
227
|
+
return Array.from(groups.values());
|
|
228
|
+
}
|
|
229
|
+
// -------------------------------------------------------------------------------------------
|
|
230
|
+
// Free-text deduplication (evidence and suggestions)
|
|
231
|
+
//
|
|
232
|
+
// When several reviewers independently spot the same defect, it is common — and gets more
|
|
233
|
+
// likely as the panel grows, not less — for their `evidence` or `suggestion` strings to be
|
|
234
|
+
// near-identical: the clearer and more obvious a defect is, the more likely independent models
|
|
235
|
+
// are to describe it in the same words. Concatenating those verbatim, as `assembleCluster` did
|
|
236
|
+
// before this normalisation existed, repeated the same line once per agreeing reviewer in both
|
|
237
|
+
// the stored `findings.json` and the rendered report — noise that scaled with agreement instead
|
|
238
|
+
// of shrinking it, landing hardest on exactly the findings a reader should trust most.
|
|
239
|
+
//
|
|
240
|
+
// The normalisation below is deliberately much lighter than `normaliseClaim` above. Claim text
|
|
241
|
+
// is prose meant to be compared semantically, so stripping all punctuation and stopwords is
|
|
242
|
+
// safe. Evidence and suggestion strings routinely quote source code (backticks, operators,
|
|
243
|
+
// decimal points, string literals) where punctuation is load-bearing — `normaliseClaim`'s
|
|
244
|
+
// blanket strip could equate two genuinely different code snippets. So only the two variations
|
|
245
|
+
// reviewers reliably introduce on an otherwise-identical sentence are folded away: casing, and a
|
|
246
|
+
// single trailing punctuation mark. Anything else — including internal punctuation, or two
|
|
247
|
+
// strings that merely overlap in wording — is left as a distinct entry. Over-merging here would
|
|
248
|
+
// silently discard evidence that could matter; under-merging only costs the reader a redundant
|
|
249
|
+
// line, so the conservative failure mode is the one this function is biased toward.
|
|
250
|
+
// -------------------------------------------------------------------------------------------
|
|
251
|
+
function normaliseFreeText(text) {
|
|
252
|
+
return text
|
|
253
|
+
.trim()
|
|
254
|
+
.replace(WHITESPACE_RE, ' ')
|
|
255
|
+
.replace(/[.,;:!?]$/, '')
|
|
256
|
+
.toLowerCase();
|
|
257
|
+
}
|
|
258
|
+
/** Deduplicates `items` by the normalised key above, keeping the first-seen original string
|
|
259
|
+
* (unmodified) for each distinct key. Order is preserved so the result stays a function of
|
|
260
|
+
* cluster-member order, not of which duplicate happened to be dropped — the same determinism
|
|
261
|
+
* guarantee the rest of this module relies on. */
|
|
262
|
+
function dedupeFreeText(items) {
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
const result = [];
|
|
265
|
+
for (const item of items) {
|
|
266
|
+
const key = normaliseFreeText(item);
|
|
267
|
+
if (seen.has(key))
|
|
268
|
+
continue;
|
|
269
|
+
seen.add(key);
|
|
270
|
+
result.push(item);
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Builds one merged finding from a cluster of flat indices. Members are sorted by
|
|
276
|
+
* (reviewerId, findingIndex) first so that every choice made from "the members" — the
|
|
277
|
+
* representative claim/impact/category, fingerprint, evidence and suggestion order — is a
|
|
278
|
+
* function of finding content and reviewer id, never of clustering iteration order.
|
|
279
|
+
*/
|
|
280
|
+
function assembleCluster(indices, flat, reporting) {
|
|
281
|
+
const members = indices
|
|
282
|
+
.map((i) => flat[i])
|
|
283
|
+
.sort((x, y) => x.reviewerId.localeCompare(y.reviewerId) || x.findingIndex - y.findingIndex);
|
|
284
|
+
// All members share a file (clustering never crosses files); every pair of members has at
|
|
285
|
+
// least *compatible* categories (see `categoriesCompatible`) for any two to have merged in the
|
|
286
|
+
// first place — but compatible is not necessarily equal, so members of one cluster can
|
|
287
|
+
// legitimately disagree on category text (e.g. one reviewer's "security" next to another's
|
|
288
|
+
// "correctness" for the same defect). The representative — the first member in the
|
|
289
|
+
// deterministic order above — supplies the merged finding's own claim and impact text, so the
|
|
290
|
+
// displayed wording is always one reviewer's real, unrewritten sentence rather than a
|
|
291
|
+
// synthesis of several. Category is deliberately NOT taken from the representative — see
|
|
292
|
+
// `category` below — because "first by reviewerId" is alphabetical order of provider/model
|
|
293
|
+
// strings, which has no bearing on which category is the more meaningful one to show.
|
|
294
|
+
const representative = members[0];
|
|
295
|
+
const file = representative.finding.file;
|
|
296
|
+
const line = Math.min(...members.map((m) => m.finding.line));
|
|
297
|
+
const maxEndLine = Math.max(...members.map((m) => m.finding.endLine ?? m.finding.line));
|
|
298
|
+
const endLine = maxEndLine > line ? maxEndLine : null;
|
|
299
|
+
const severity = members.reduce((max, m) => (SEVERITY_RANK[m.finding.severity] > SEVERITY_RANK[max] ? m.finding.severity : max), members[0].finding.severity);
|
|
300
|
+
// Category follows the same escalate-don't-dilute principle `SEVERITY_RANK` already applies to
|
|
301
|
+
// severity above. A cluster's members are only guaranteed *compatible* categories, not equal
|
|
302
|
+
// ones, so when they disagree, resolving to "first member by reviewerId" would make the
|
|
303
|
+
// displayed category depend on the alphabetical order of the panel's model names — rename a
|
|
304
|
+
// model and a security finding could silently render as a correctness finding. Taking the
|
|
305
|
+
// category of whichever member holds the cluster's own (already-escalated) severity instead
|
|
306
|
+
// keeps severity and category telling one consistent story: a defect one reviewer flagged as
|
|
307
|
+
// critical/security is never diluted to "correctness" just because a differently-named
|
|
308
|
+
// reviewer's finding happened to sort first. This needs no new ranking table — it reuses
|
|
309
|
+
// `SEVERITY_RANK`, which already exists for severity — unlike a hand-maintained category
|
|
310
|
+
// precedence list, which would need its own justification for every future pair added to
|
|
311
|
+
// `COMPATIBLE_CATEGORIES`.
|
|
312
|
+
//
|
|
313
|
+
// Two members can still tie on severity while disagreeing on category. That tie must NOT be
|
|
314
|
+
// broken by reviewerId (i.e. by `representative`/member order): reviewerId is exactly the
|
|
315
|
+
// arbitrary, renameable input this whole fix exists to stop depending on, and using it here
|
|
316
|
+
// would silently reintroduce the bug for every tied pair. Instead the tie is broken on the
|
|
317
|
+
// same kind of content-derived key `compareMergedFindings` already uses as its own final,
|
|
318
|
+
// never-arbitrary tiebreaker: each tied member's own fingerprint (file + its claim text),
|
|
319
|
+
// ascending. Fingerprint depends only on `file` and `claim`, never on which reviewer said it,
|
|
320
|
+
// so renaming a reviewer changes nothing about which member wins a tie.
|
|
321
|
+
const categoryHolder = members.reduce((best, m) => {
|
|
322
|
+
const bestRank = SEVERITY_RANK[best.finding.severity];
|
|
323
|
+
const mRank = SEVERITY_RANK[m.finding.severity];
|
|
324
|
+
if (mRank !== bestRank)
|
|
325
|
+
return mRank > bestRank ? m : best;
|
|
326
|
+
return fingerprint(file, m.finding.claim).localeCompare(fingerprint(file, best.finding.claim)) <
|
|
327
|
+
0
|
|
328
|
+
? m
|
|
329
|
+
: best;
|
|
330
|
+
}, members[0]);
|
|
331
|
+
const category = categoryHolder.finding.category;
|
|
332
|
+
const perReviewerSeverity = {};
|
|
333
|
+
for (const m of members) {
|
|
334
|
+
const current = perReviewerSeverity[m.reviewerId];
|
|
335
|
+
if (current === undefined || SEVERITY_RANK[m.finding.severity] > SEVERITY_RANK[current]) {
|
|
336
|
+
perReviewerSeverity[m.reviewerId] = m.finding.severity;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const raisedBy = Array.from(new Set(members.map((m) => m.reviewerId))).sort();
|
|
340
|
+
const evidence = dedupeFreeText(members.map((m) => m.finding.evidence).filter((e) => !!e));
|
|
341
|
+
const suggestions = dedupeFreeText(members.map((m) => m.finding.suggestion).filter((s) => !!s));
|
|
342
|
+
const sources = members.map((m) => ({
|
|
343
|
+
reviewer: m.reviewerId,
|
|
344
|
+
findingId: String(m.findingIndex),
|
|
345
|
+
severity: m.finding.severity,
|
|
346
|
+
claim: m.finding.claim,
|
|
347
|
+
}));
|
|
348
|
+
const fp = fingerprint(file, representative.finding.claim);
|
|
349
|
+
const memberFingerprints = Array.from(new Set(members.map((m) => fingerprint(file, m.finding.claim))));
|
|
350
|
+
return {
|
|
351
|
+
fingerprint: fp,
|
|
352
|
+
file,
|
|
353
|
+
line,
|
|
354
|
+
endLine,
|
|
355
|
+
severity,
|
|
356
|
+
category,
|
|
357
|
+
claim: representative.finding.claim,
|
|
358
|
+
impact: representative.finding.impact,
|
|
359
|
+
evidence,
|
|
360
|
+
suggestions,
|
|
361
|
+
raisedBy,
|
|
362
|
+
perReviewerSeverity,
|
|
363
|
+
sources,
|
|
364
|
+
agreement: { raisers: raisedBy.length, reporting },
|
|
365
|
+
unverifiable: representative.finding.unverifiable ?? false,
|
|
366
|
+
memberFingerprints,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
// -------------------------------------------------------------------------------------------
|
|
370
|
+
// Suppression
|
|
371
|
+
// -------------------------------------------------------------------------------------------
|
|
372
|
+
/** A cluster is suppressed if its own fingerprint, or any member's fingerprint, matches an
|
|
373
|
+
* ignore-list entry. Checking members too is what lets one reviewer's suppressed claim keep a
|
|
374
|
+
* cluster suppressed even when the merge's representative-choice (see `assembleCluster`) picks
|
|
375
|
+
* a different member as the cluster's own fingerprint on a later run. */
|
|
376
|
+
function isSuppressed(cluster, ignore) {
|
|
377
|
+
if (!ignore || ignore.entries.length === 0)
|
|
378
|
+
return false;
|
|
379
|
+
const suppressedFingerprints = new Set(ignore.entries.map((e) => e.fingerprint));
|
|
380
|
+
if (suppressedFingerprints.has(cluster.fingerprint))
|
|
381
|
+
return true;
|
|
382
|
+
return cluster.memberFingerprints.some((fp) => suppressedFingerprints.has(fp));
|
|
383
|
+
}
|
|
384
|
+
// -------------------------------------------------------------------------------------------
|
|
385
|
+
// Sorting and identifier assignment
|
|
386
|
+
// -------------------------------------------------------------------------------------------
|
|
387
|
+
/**
|
|
388
|
+
* Total order: agreement (raisers) descending, severity descending, file path ascending, line
|
|
389
|
+
* ascending, then fingerprint ascending as a final deterministic tiebreaker so no two distinct
|
|
390
|
+
* findings can ever compare equal — without it, findings tied on all four documented keys would
|
|
391
|
+
* fall back to array order, which is not itself guaranteed stable across environments.
|
|
392
|
+
*
|
|
393
|
+
* Sorting by raisers (rather than a raisers/reporting ratio) is equivalent to sorting by
|
|
394
|
+
* agreement ratio here: `reporting` is the same constant for every finding produced by one
|
|
395
|
+
* `mergeFindings` call, so the two orderings never disagree.
|
|
396
|
+
*/
|
|
397
|
+
function compareMergedFindings(a, b) {
|
|
398
|
+
return (b.agreement.raisers - a.agreement.raisers ||
|
|
399
|
+
SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
|
400
|
+
a.file.localeCompare(b.file) ||
|
|
401
|
+
a.line - b.line ||
|
|
402
|
+
a.fingerprint.localeCompare(b.fingerprint));
|
|
403
|
+
}
|
|
404
|
+
/** Zero-padded, 1-based, assigned strictly after sorting so that identical input always
|
|
405
|
+
* produces identical ids, and ids increase monotonically in the sorted (i.e. written) order. */
|
|
406
|
+
function assignId(index) {
|
|
407
|
+
return `F${String(index + 1).padStart(3, '0')}`;
|
|
408
|
+
}
|
|
409
|
+
// -------------------------------------------------------------------------------------------
|
|
410
|
+
// Entry point
|
|
411
|
+
// -------------------------------------------------------------------------------------------
|
|
412
|
+
/**
|
|
413
|
+
* Merges `results` into one deterministic, ranked findings list. Pure function of its inputs:
|
|
414
|
+
* no model call, no network access, no randomness, no reliance on the wall clock.
|
|
415
|
+
*/
|
|
416
|
+
export function mergeFindings(results, o) {
|
|
417
|
+
const launched = results.length;
|
|
418
|
+
const reportingResults = results.filter((r) => r.findings !== null);
|
|
419
|
+
const reporting = reportingResults.length;
|
|
420
|
+
const flat = [];
|
|
421
|
+
for (const r of reportingResults) {
|
|
422
|
+
r.findings.forEach((finding, findingIndex) => {
|
|
423
|
+
flat.push({ reviewerId: r.reviewerId, findingIndex, finding });
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const clusters = clusterFlatFindings(flat, o);
|
|
427
|
+
const assembled = clusters.map((indices) => assembleCluster(indices, flat, reporting));
|
|
428
|
+
let suppressed = 0;
|
|
429
|
+
const kept = [];
|
|
430
|
+
for (const cluster of assembled) {
|
|
431
|
+
if (o.suppress && isSuppressed(cluster, o.ignore)) {
|
|
432
|
+
suppressed++;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
kept.push(cluster);
|
|
436
|
+
}
|
|
437
|
+
const sorted = [...kept].sort(compareMergedFindings);
|
|
438
|
+
const findings = sorted.map((c, i) => ({ id: assignId(i), ...c }));
|
|
439
|
+
return { findings, suppressed, launched, reporting };
|
|
440
|
+
}
|
|
441
|
+
//# sourceMappingURL=merge.js.map
|