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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +374 -0
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +19 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/cli.d.ts +14 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +829 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +61 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +235 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/herdr.d.ts +36 -0
  16. package/dist/herdr.d.ts.map +1 -0
  17. package/dist/herdr.js +196 -0
  18. package/dist/herdr.js.map +1 -0
  19. package/dist/index.d.ts +30 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +30 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/levels.d.ts +9 -0
  24. package/dist/levels.d.ts.map +1 -0
  25. package/dist/levels.js +19 -0
  26. package/dist/levels.js.map +1 -0
  27. package/dist/merge.d.ts +90 -0
  28. package/dist/merge.d.ts.map +1 -0
  29. package/dist/merge.js +441 -0
  30. package/dist/merge.js.map +1 -0
  31. package/dist/node-guard.d.ts +9 -0
  32. package/dist/node-guard.d.ts.map +1 -0
  33. package/dist/node-guard.js +21 -0
  34. package/dist/node-guard.js.map +1 -0
  35. package/dist/panel.d.ts +89 -0
  36. package/dist/panel.d.ts.map +1 -0
  37. package/dist/panel.js +275 -0
  38. package/dist/panel.js.map +1 -0
  39. package/dist/picker.d.ts +24 -0
  40. package/dist/picker.d.ts.map +1 -0
  41. package/dist/picker.js +182 -0
  42. package/dist/picker.js.map +1 -0
  43. package/dist/providers.d.ts +66 -0
  44. package/dist/providers.d.ts.map +1 -0
  45. package/dist/providers.js +544 -0
  46. package/dist/providers.js.map +1 -0
  47. package/dist/report.d.ts +116 -0
  48. package/dist/report.d.ts.map +1 -0
  49. package/dist/report.js +493 -0
  50. package/dist/report.js.map +1 -0
  51. package/dist/resolve.d.ts +58 -0
  52. package/dist/resolve.d.ts.map +1 -0
  53. package/dist/resolve.js +147 -0
  54. package/dist/resolve.js.map +1 -0
  55. package/dist/reviewer-spawn.d.ts +67 -0
  56. package/dist/reviewer-spawn.d.ts.map +1 -0
  57. package/dist/reviewer-spawn.js +94 -0
  58. package/dist/reviewer-spawn.js.map +1 -0
  59. package/dist/reviewer-tools.d.ts +113 -0
  60. package/dist/reviewer-tools.d.ts.map +1 -0
  61. package/dist/reviewer-tools.js +557 -0
  62. package/dist/reviewer-tools.js.map +1 -0
  63. package/dist/runner.d.ts +79 -0
  64. package/dist/runner.d.ts.map +1 -0
  65. package/dist/runner.js +811 -0
  66. package/dist/runner.js.map +1 -0
  67. package/dist/schema.d.ts +63 -0
  68. package/dist/schema.d.ts.map +1 -0
  69. package/dist/schema.js +302 -0
  70. package/dist/schema.js.map +1 -0
  71. package/dist/scope.d.ts +40 -0
  72. package/dist/scope.d.ts.map +1 -0
  73. package/dist/scope.js +372 -0
  74. package/dist/scope.js.map +1 -0
  75. package/dist/snapshot.d.ts +57 -0
  76. package/dist/snapshot.d.ts.map +1 -0
  77. package/dist/snapshot.js +471 -0
  78. package/dist/snapshot.js.map +1 -0
  79. package/dist/status.d.ts +90 -0
  80. package/dist/status.d.ts.map +1 -0
  81. package/dist/status.js +336 -0
  82. package/dist/status.js.map +1 -0
  83. package/dist/thinking.d.ts +53 -0
  84. package/dist/thinking.d.ts.map +1 -0
  85. package/dist/thinking.js +116 -0
  86. package/dist/thinking.js.map +1 -0
  87. package/package.json +81 -0
  88. package/src/bin.ts +20 -0
  89. package/src/cli.ts +996 -0
  90. package/src/config.ts +338 -0
  91. package/src/herdr.ts +231 -0
  92. package/src/index.ts +30 -0
  93. package/src/levels.ts +23 -0
  94. package/src/merge.ts +548 -0
  95. package/src/node-guard.ts +25 -0
  96. package/src/panel.ts +354 -0
  97. package/src/picker.ts +230 -0
  98. package/src/providers.ts +778 -0
  99. package/src/report.ts +594 -0
  100. package/src/resolve.ts +179 -0
  101. package/src/reviewer-spawn.ts +131 -0
  102. package/src/reviewer-tools.ts +715 -0
  103. package/src/runner.ts +1120 -0
  104. package/src/schema.ts +373 -0
  105. package/src/scope.ts +448 -0
  106. package/src/snapshot.ts +536 -0
  107. package/src/status.ts +470 -0
  108. package/src/thinking.ts +148 -0
@@ -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"}
@@ -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"}
@@ -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