@untangle-code/search 0.0.0-stage → 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 (140) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +433 -2
  4. package/THIRD_PARTY_LICENSES.md +29 -0
  5. package/dist/benchmark-integrity.d.ts +12 -0
  6. package/dist/benchmark-integrity.d.ts.map +1 -0
  7. package/dist/benchmark-integrity.js +10 -0
  8. package/dist/benchmark-integrity.js.map +1 -0
  9. package/dist/bm25.d.ts +46 -0
  10. package/dist/bm25.d.ts.map +1 -0
  11. package/dist/bm25.js +105 -0
  12. package/dist/bm25.js.map +1 -0
  13. package/dist/deadline.d.ts +4 -0
  14. package/dist/deadline.d.ts.map +1 -0
  15. package/dist/deadline.js +54 -0
  16. package/dist/deadline.js.map +1 -0
  17. package/dist/dedupe.d.ts +48 -0
  18. package/dist/dedupe.d.ts.map +1 -0
  19. package/dist/dedupe.js +58 -0
  20. package/dist/dedupe.js.map +1 -0
  21. package/dist/diversify.d.ts +45 -0
  22. package/dist/diversify.d.ts.map +1 -0
  23. package/dist/diversify.js +30 -0
  24. package/dist/diversify.js.map +1 -0
  25. package/dist/engine.d.ts +157 -0
  26. package/dist/engine.d.ts.map +1 -0
  27. package/dist/engine.js +1498 -0
  28. package/dist/engine.js.map +1 -0
  29. package/dist/enrichment.d.ts +16 -0
  30. package/dist/enrichment.d.ts.map +1 -0
  31. package/dist/enrichment.js +92 -0
  32. package/dist/enrichment.js.map +1 -0
  33. package/dist/filter.d.ts +86 -0
  34. package/dist/filter.d.ts.map +1 -0
  35. package/dist/filter.js +166 -0
  36. package/dist/filter.js.map +1 -0
  37. package/dist/findability.d.ts +144 -0
  38. package/dist/findability.d.ts.map +1 -0
  39. package/dist/findability.js +393 -0
  40. package/dist/findability.js.map +1 -0
  41. package/dist/format.d.ts +28 -0
  42. package/dist/format.d.ts.map +1 -0
  43. package/dist/format.js +172 -0
  44. package/dist/format.js.map +1 -0
  45. package/dist/fusion.d.ts +47 -0
  46. package/dist/fusion.d.ts.map +1 -0
  47. package/dist/fusion.js +84 -0
  48. package/dist/fusion.js.map +1 -0
  49. package/dist/glob.d.ts +50 -0
  50. package/dist/glob.d.ts.map +1 -0
  51. package/dist/glob.js +144 -0
  52. package/dist/glob.js.map +1 -0
  53. package/dist/grep.d.ts +76 -0
  54. package/dist/grep.d.ts.map +1 -0
  55. package/dist/grep.js +475 -0
  56. package/dist/grep.js.map +1 -0
  57. package/dist/index.d.ts +42 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +63 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/ledger.d.ts +45 -0
  62. package/dist/ledger.d.ts.map +1 -0
  63. package/dist/ledger.js +39 -0
  64. package/dist/ledger.js.map +1 -0
  65. package/dist/payload.d.ts +108 -0
  66. package/dist/payload.d.ts.map +1 -0
  67. package/dist/payload.js +263 -0
  68. package/dist/payload.js.map +1 -0
  69. package/dist/port.d.ts +112 -0
  70. package/dist/port.d.ts.map +1 -0
  71. package/dist/port.js +2 -0
  72. package/dist/port.js.map +1 -0
  73. package/dist/product-complexity.d.ts +89 -0
  74. package/dist/product-complexity.d.ts.map +1 -0
  75. package/dist/product-complexity.js +1367 -0
  76. package/dist/product-complexity.js.map +1 -0
  77. package/dist/regex-safety.d.ts +75 -0
  78. package/dist/regex-safety.d.ts.map +1 -0
  79. package/dist/regex-safety.js +902 -0
  80. package/dist/regex-safety.js.map +1 -0
  81. package/dist/relevance-prerequisites.d.ts +21 -0
  82. package/dist/relevance-prerequisites.d.ts.map +1 -0
  83. package/dist/relevance-prerequisites.js +47 -0
  84. package/dist/relevance-prerequisites.js.map +1 -0
  85. package/dist/relevance.d.ts +107 -0
  86. package/dist/relevance.d.ts.map +1 -0
  87. package/dist/relevance.js +199 -0
  88. package/dist/relevance.js.map +1 -0
  89. package/dist/retrieval.d.ts +46 -0
  90. package/dist/retrieval.d.ts.map +1 -0
  91. package/dist/retrieval.js +182 -0
  92. package/dist/retrieval.js.map +1 -0
  93. package/dist/scoring.d.ts +102 -0
  94. package/dist/scoring.d.ts.map +1 -0
  95. package/dist/scoring.js +175 -0
  96. package/dist/scoring.js.map +1 -0
  97. package/dist/sort.d.ts +21 -0
  98. package/dist/sort.d.ts.map +1 -0
  99. package/dist/sort.js +53 -0
  100. package/dist/sort.js.map +1 -0
  101. package/dist/symbol-match.d.ts +21 -0
  102. package/dist/symbol-match.d.ts.map +1 -0
  103. package/dist/symbol-match.js +72 -0
  104. package/dist/symbol-match.js.map +1 -0
  105. package/dist/tokenize.d.ts +41 -0
  106. package/dist/tokenize.d.ts.map +1 -0
  107. package/dist/tokenize.js +202 -0
  108. package/dist/tokenize.js.map +1 -0
  109. package/dist/types.d.ts +173 -0
  110. package/dist/types.d.ts.map +1 -0
  111. package/dist/types.js +42 -0
  112. package/dist/types.js.map +1 -0
  113. package/package.json +61 -3
  114. package/src/benchmark-integrity.ts +25 -0
  115. package/src/bm25.ts +135 -0
  116. package/src/deadline.ts +59 -0
  117. package/src/dedupe.ts +99 -0
  118. package/src/diversify.ts +71 -0
  119. package/src/engine.ts +1957 -0
  120. package/src/enrichment.ts +115 -0
  121. package/src/filter.ts +267 -0
  122. package/src/findability.ts +580 -0
  123. package/src/format.ts +228 -0
  124. package/src/fusion.ts +121 -0
  125. package/src/glob.ts +151 -0
  126. package/src/grep.ts +618 -0
  127. package/src/index.ts +83 -0
  128. package/src/ledger.ts +72 -0
  129. package/src/payload.ts +382 -0
  130. package/src/port.ts +133 -0
  131. package/src/product-complexity.ts +2168 -0
  132. package/src/regex-safety.ts +1002 -0
  133. package/src/relevance-prerequisites.ts +83 -0
  134. package/src/relevance.ts +328 -0
  135. package/src/retrieval.ts +299 -0
  136. package/src/scoring.ts +321 -0
  137. package/src/sort.ts +86 -0
  138. package/src/symbol-match.ts +87 -0
  139. package/src/tokenize.ts +213 -0
  140. package/src/types.ts +206 -0
package/src/format.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Ausgabeformate (Nutzerwunsch A4: „Text, JSON, JSONL").
3
+ *
4
+ * Drei Formate, drei Zwecke:
5
+ *
6
+ * - `text` — fuer Menschen im Terminal. Eine Zeile Kopf je Treffer, darunter
7
+ * die Score-Zerlegung in Klartext. Die Zerlegung ist hier KEIN Debug-Anhang,
8
+ * sondern der eigentliche Mehrwert: sie beantwortet „warum steht das oben?".
9
+ * - `json` — ein Dokument, vollstaendig, mit Ledger und Warnungen. Fuer
10
+ * Werkzeuge, die das Ganze wollen.
11
+ * - `jsonl` — eine Zeile je Treffer, streambar. Fuer Agents und Pipes.
12
+ * Die erste Zeile ist ein Kopfsatz mit Anfrage-Fingerabdruck, Warnungen und
13
+ * Ledger — sonst wuerde beim Streamen genau die Information verloren gehen,
14
+ * die E4 sichtbar machen soll.
15
+ *
16
+ * Alle drei sind deterministisch: gleiche Ergebnismenge, gleiche Bytes.
17
+ */
18
+ import type {
19
+ ScoreComponentV1,
20
+ SearchGraphRelationsV1,
21
+ SearchHitV1,
22
+ SearchResultSetV1,
23
+ } from "@untangle-code/domain";
24
+
25
+ import type { GrepResultSetV1, OutputFormat } from "./types.js";
26
+
27
+ /** Formatiert eine Zahl mit fester Stellenzahl — stabil ueber Plattformen. */
28
+ function fixed(value: number, digits = 4): string {
29
+ return value.toFixed(digits);
30
+ }
31
+
32
+ function formatComponent(component: ScoreComponentV1): string {
33
+ const sign = component.contribution < 0 ? "-" : "+";
34
+ const evidence = component.evidence === undefined ? "" : ` [${component.evidence}]`;
35
+ return ` ${sign} ${component.kind} = ${fixed(Math.abs(component.contribution))} (raw ${fixed(component.rawValue)} x weight ${fixed(component.weight)})${evidence}`;
36
+ }
37
+
38
+ /** Kompakte, aber vollstaendige Textdarstellung einer begrenzten Graphfacette. */
39
+ export function formatSearchGraphRelationsAsText(
40
+ label: "context" | "linked",
41
+ value: SearchGraphRelationsV1,
42
+ ): string[] {
43
+ const lines = value.relations.map((relation) => {
44
+ const arrow = relation.direction === "incoming" ? "<-" : "->";
45
+ const symbol = relation.symbol === undefined ? "" : ` symbol=${relation.symbol.id}`;
46
+ return (
47
+ ` ${label} ${arrow} ${relation.repoId}@${relation.refId} ` +
48
+ `${relation.path} file=${relation.fileId}${symbol} ` +
49
+ `edge=${relation.edgeId} kind=${relation.edgeKind} crossRepository=${String(relation.crossRepository)}`
50
+ );
51
+ });
52
+ for (const omission of value.omissions) {
53
+ lines.push(
54
+ ` ${label} omitted ${omission.reason} x${String(omission.count)}: ${omission.detail}`,
55
+ );
56
+ }
57
+ return lines;
58
+ }
59
+
60
+ /** Eine Trefferzeile plus Score-Zerlegung im Textformat. */
61
+ export function formatHitAsText(hit: SearchHitV1): string {
62
+ const lines: string[] = [];
63
+ const symbol =
64
+ hit.symbolName === undefined ? "" : ` ${hit.symbolKind ?? "symbol"} ${hit.symbolName}`;
65
+ lines.push(
66
+ `${String(hit.rank).padStart(3, " ")}. ${fixed(hit.score.final)} ${hit.repoLabel}@${hit.refName} ${hit.filePath}:${hit.range.startLine}-${hit.range.endLine}${symbol}`,
67
+ );
68
+ if (hit.complexity?.cognitive !== undefined) {
69
+ lines.push(` complexity: cognitive ${hit.complexity.cognitive}`);
70
+ }
71
+ if (hit.findability !== undefined) {
72
+ lines.push(
73
+ ` findability: ${fixed(hit.findability.score, 2)} (heuristic${hit.findability.primaryCause === undefined ? "" : `, ${hit.findability.primaryCause}`})`,
74
+ );
75
+ }
76
+ lines.push(" why:");
77
+ for (const component of hit.score.components) {
78
+ lines.push(formatComponent(component));
79
+ }
80
+ if (hit.score.clamped) {
81
+ lines.push(` clamped: ${hit.score.clampReason ?? ""}`);
82
+ }
83
+ if (hit.relatedFragments.length > 0) {
84
+ lines.push(
85
+ ` related fragments: ${hit.relatedFragments.map((fragment) => `${fragment.chunkKind}@${fragment.startLine}`).join(", ")}`,
86
+ );
87
+ }
88
+ if (hit.graphContext !== undefined) {
89
+ lines.push(...formatSearchGraphRelationsAsText("context", hit.graphContext));
90
+ }
91
+ if (hit.linkedSymbols !== undefined) {
92
+ lines.push(...formatSearchGraphRelationsAsText("linked", hit.linkedSymbols));
93
+ }
94
+ if (hit.contentOmittedReason !== undefined) {
95
+ lines.push(` content omitted: ${hit.contentOmittedReason}`);
96
+ }
97
+ if (hit.content !== undefined) {
98
+ for (const line of hit.content.split("\n")) {
99
+ lines.push(` | ${line}`);
100
+ }
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+
105
+ /** Formatiert eine ganze Ergebnismenge. */
106
+ export function formatSearchResultSet(set: SearchResultSetV1, format: OutputFormat): string {
107
+ if (format === "json") {
108
+ return JSON.stringify(set, null, 2);
109
+ }
110
+ if (format === "jsonl") {
111
+ const header = {
112
+ kind: "header" as const,
113
+ queryFingerprint: set.queryFingerprint,
114
+ ...(set.indexFreshness === undefined ? {} : { indexFreshness: set.indexFreshness }),
115
+ query: set.query,
116
+ profileSeparation: set.profileSeparation,
117
+ totalHits: set.totalHits,
118
+ warnings: set.warnings,
119
+ skipped: set.skipped,
120
+ };
121
+ const lines = [JSON.stringify(header)];
122
+ for (const group of set.groups) {
123
+ for (const hit of group.hits) {
124
+ lines.push(
125
+ JSON.stringify({ kind: "hit", embeddingProfileId: group.embeddingProfileId, hit }),
126
+ );
127
+ }
128
+ }
129
+ return lines.join("\n");
130
+ }
131
+
132
+ const lines: string[] = [];
133
+ lines.push(`query: ${set.query.text}`);
134
+ lines.push(`fingerprint: ${set.queryFingerprint}`);
135
+ if (set.profileSeparation) {
136
+ lines.push(
137
+ `NOTE: ${set.groups.length} embedding profiles — scores are comparable only within a group (spec.md §13.4)`,
138
+ );
139
+ }
140
+ for (const group of set.groups) {
141
+ lines.push("");
142
+ lines.push(`profile ${group.embeddingProfileId} (repos: ${group.repoIds.join(", ")})`);
143
+ for (const caveat of group.caveats) {
144
+ lines.push(` caveat: ${caveat}`);
145
+ }
146
+ // Das Verdikt kommt aus der Entscheidung, nie aus der Listenlaenge. Ein
147
+ // blosses „(no hits)" waere bei einer Ablehnung eine Tatsachenbehauptung
148
+ // ueber den Code: es GAB Kandidaten, die Policy hat sie verworfen.
149
+ const verdict = group.relevanceDecision;
150
+ if (verdict?.status === "rejected") {
151
+ lines.push(
152
+ ` (relevance policy ${verdict.calibrationId ?? "unknown"} judged the evidence ` +
153
+ `insufficient: ${verdict.basis}; abstention=${verdict.abstention})`,
154
+ );
155
+ }
156
+ if (group.hits.length === 0 && verdict?.status !== "rejected") {
157
+ lines.push(" (no hits)");
158
+ }
159
+ for (const hit of group.hits) {
160
+ lines.push(formatHitAsText(hit));
161
+ }
162
+ }
163
+ if (set.warnings.length > 0) {
164
+ lines.push("");
165
+ lines.push("warnings:");
166
+ for (const warning of set.warnings) {
167
+ lines.push(` ${warning.code}: ${warning.message}`);
168
+ }
169
+ }
170
+ if (set.skipped.length > 0) {
171
+ lines.push("");
172
+ lines.push(`skipped (${set.skipped.length}) — nothing is dropped silently:`);
173
+ for (const entry of set.skipped) {
174
+ lines.push(
175
+ ` ${entry.reason} ${entry.subjectKind} ${entry.subjectId ?? entry.path ?? ""}: ${entry.detail}`,
176
+ );
177
+ }
178
+ }
179
+ return lines.join("\n");
180
+ }
181
+
182
+ /** Formatiert grep-Treffer. */
183
+ export function formatGrepResultSet(set: GrepResultSetV1, format: OutputFormat): string {
184
+ if (format === "json") {
185
+ return JSON.stringify(set, null, 2);
186
+ }
187
+ if (format === "jsonl") {
188
+ const header = {
189
+ kind: "header" as const,
190
+ pattern: set.pattern,
191
+ ...(set.indexFreshness === undefined ? {} : { indexFreshness: set.indexFreshness }),
192
+ isRegex: set.isRegex,
193
+ totalMatches: set.totalMatches,
194
+ truncated: set.truncated,
195
+ ...(set.truncationReason === undefined ? {} : { truncationReason: set.truncationReason }),
196
+ scannedChunks: set.scannedChunks,
197
+ warnings: set.warnings,
198
+ skipped: set.skipped,
199
+ };
200
+ return [header, ...set.matches.map((match) => ({ kind: "match" as const, match }))]
201
+ .map((entry) => JSON.stringify(entry))
202
+ .join("\n");
203
+ }
204
+
205
+ const lines: string[] = [];
206
+ for (const match of set.matches) {
207
+ for (const context of match.before) {
208
+ lines.push(`${match.filePath}-${context.line}- ${context.text}`);
209
+ }
210
+ lines.push(`${match.filePath}:${match.line}:${match.column}: ${match.lineText}`);
211
+ for (const context of match.after) {
212
+ lines.push(`${match.filePath}-${context.line}- ${context.text}`);
213
+ }
214
+ if (match.before.length > 0 || match.after.length > 0) {
215
+ lines.push("--");
216
+ }
217
+ }
218
+ lines.push(
219
+ `${set.totalMatches} match(es) in ${set.scannedChunks} scanned chunk(s)${set.truncated ? ` — TRUNCATED (${set.truncationReason ?? "unknown"})` : ""}`,
220
+ );
221
+ if (set.skipped.length > 0) {
222
+ lines.push(`skipped (${set.skipped.length}):`);
223
+ for (const entry of set.skipped) {
224
+ lines.push(` ${entry.reason} ${entry.subjectId ?? entry.path ?? ""}: ${entry.detail}`);
225
+ }
226
+ }
227
+ return lines.join("\n");
228
+ }
package/src/fusion.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Fusion der drei Ranglisten (spec.md §13.2 Schritt 6, §13.4).
3
+ *
4
+ * Verfahren: gewichtete Reciprocal Rank Fusion.
5
+ *
6
+ * rrf(d) = Σ_zweig w_zweig / (k + rang_zweig(d))
7
+ *
8
+ * WARUM RRF UND NICHT EINE SCORE-SUMME: Cosine-Aehnlichkeit, BM25 und eine
9
+ * Namensuebereinstimmung leben in drei verschiedenen Wertebereichen. Sie zu
10
+ * addieren hiesse, sie stillschweigend gleich zu skalieren. RRF benutzt nur
11
+ * die RANGFOLGE jedes Zweigs und ist damit gegen die Skala jedes einzelnen
12
+ * Zweigs unempfindlich — dieselbe Eigenschaft, wegen der spec.md §13.4 RRF
13
+ * als einziges Fusionsverfahren fuer v1 festlegt.
14
+ *
15
+ * WARUM k = 60: der Wert aus der RRF-Originalarbeit (Cormack, Clarke,
16
+ * Buettcher 2009), seither die uebliche Voreinstellung — unter anderem in
17
+ * Qdrants eigener RRF-Implementierung, gegen die dieser Abrufkern austauschbar
18
+ * bleiben soll. Grosses k daempft den Vorsprung der ersten Plaetze, kleines k
19
+ * ueberhoeht ihn; 60 ist der dokumentierte Mittelweg, nicht ein geratener.
20
+ *
21
+ * WARUM DIE ZWEIGGEWICHTE SO GEWAEHLT SIND:
22
+ * - dicht 1.0 und lexikalisch 1.0 — die beiden Abrufzweige sind gleichwertig;
23
+ * welcher besser ist, haengt an der Anfrage (Absichtsfrage vs. Bezeichner),
24
+ * und genau deshalb werden beide gefragt;
25
+ * - Symbolname 0.5 — der Zweig ist eine PRAEZISIONS-, keine Abrufquelle. Ein
26
+ * exakter Namenstreffer wird zusaetzlich als `exact_symbol_boost` belohnt
27
+ * (spec.md §13.3). Mit vollem Gewicht in der Fusion UND vollem Boost
28
+ * wuerde derselbe Beleg doppelt zaehlen; das halbe Gewicht ist der
29
+ * ausdrueckliche Abschlag dafuer.
30
+ */
31
+ import { compareCodepoints } from "@untangle-code/domain";
32
+
33
+ /** Glaettungskonstante der RRF-Formel (siehe Modulkommentar). */
34
+ export const RRF_K = 60;
35
+
36
+ /** Voreingestellte Zweiggewichte (siehe Modulkommentar). */
37
+ export const DEFAULT_BRANCH_WEIGHTS = {
38
+ dense: 1,
39
+ lexical: 1,
40
+ symbol: 0.5,
41
+ } as const;
42
+
43
+ export type FusionBranch = keyof typeof DEFAULT_BRANCH_WEIGHTS;
44
+
45
+ /** Eine Rangliste eines Zweigs: IDs, bester zuerst. */
46
+ export interface FusionInput {
47
+ branch: FusionBranch;
48
+ /** IDs in Rangfolge; Position 0 ist Rang 1. */
49
+ rankedIds: readonly string[];
50
+ }
51
+
52
+ export interface FusionResult {
53
+ id: string;
54
+ /** Roher RRF-Wert. */
55
+ rrf: number;
56
+ /** Auf [0, 1] normalisierter RRF-Wert (siehe {@link maximumRrf}). */
57
+ normalized: number;
58
+ /** Beitragende Zweige mit ihrem 1-basierten Rang. */
59
+ ranks: Partial<Record<FusionBranch, number>>;
60
+ /** Wirksame Gewichte der beteiligten Zweige (fuer die Score-Evidenz). */
61
+ weights?: Partial<Record<FusionBranch, number>>;
62
+ }
63
+
64
+ export type BranchWeights = Record<FusionBranch, number>;
65
+
66
+ /**
67
+ * Der groesstmoegliche RRF-Wert: jeder beteiligte Zweig auf Rang 1.
68
+ *
69
+ * Damit wird auf [0, 1] normalisiert, OHNE durch den Maximalwert der aktuellen
70
+ * Trefferliste zu teilen. Das ist der Unterschied zwischen „dieser Treffer ist
71
+ * gut" und „dieser Treffer war der beste, den wir hatten": eine Normalisierung
72
+ * am Listenmaximum gaebe dem besten Treffer immer 1.0, auch wenn er schlecht
73
+ * ist — und die Findability-Diagnose (vision.md §1d) braucht genau diesen
74
+ * Unterschied.
75
+ */
76
+ export function maximumRrf(branches: readonly FusionBranch[], weights: BranchWeights): number {
77
+ const uniqueBranches = [...new Set(branches)];
78
+ return uniqueBranches.reduce((sum, branch) => sum + weights[branch] / (RRF_K + 1), 0);
79
+ }
80
+
81
+ /**
82
+ * Fusioniert die Ranglisten. Ein Kandidat, der in einem Zweig nicht vorkommt,
83
+ * bekommt von diesem Zweig schlicht keinen Beitrag — er wird nicht bestraft.
84
+ *
85
+ * Die Ausgabe ist absteigend nach RRF sortiert, bei Gleichstand nach
86
+ * Codepoints der ID (deterministisch).
87
+ */
88
+ export function fuseRankings(
89
+ inputs: readonly FusionInput[],
90
+ weights: BranchWeights = { ...DEFAULT_BRANCH_WEIGHTS },
91
+ ): FusionResult[] {
92
+ const accumulator = new Map<
93
+ string,
94
+ { rrf: number; ranks: Partial<Record<FusionBranch, number>> }
95
+ >();
96
+ for (const input of inputs) {
97
+ const weight = weights[input.branch];
98
+ input.rankedIds.forEach((id, index) => {
99
+ const rank = index + 1;
100
+ const entry = accumulator.get(id) ?? { rrf: 0, ranks: {} };
101
+ entry.rrf += weight / (RRF_K + rank);
102
+ entry.ranks[input.branch] = rank;
103
+ accumulator.set(id, entry);
104
+ });
105
+ }
106
+ const maximum = maximumRrf(
107
+ inputs.map((input) => input.branch),
108
+ weights,
109
+ );
110
+ const results: FusionResult[] = [...accumulator.entries()].map(([id, entry]) => ({
111
+ id,
112
+ rrf: entry.rrf,
113
+ normalized: maximum === 0 ? 0 : entry.rrf / maximum,
114
+ ranks: entry.ranks,
115
+ weights: Object.fromEntries(
116
+ [...new Set(inputs.map((input) => input.branch))].map((branch) => [branch, weights[branch]]),
117
+ ),
118
+ }));
119
+ results.sort((a, b) => (a.rrf === b.rrf ? compareCodepoints(a.id, b.id) : b.rrf - a.rrf));
120
+ return results;
121
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Deterministischer Glob-Abgleich fuer Pfad-Ein- und -Ausschluss
3
+ * (Nutzerwunsch A4: „Pfad-Ein-/Ausschluss" wie ein grep-Werkzeug).
4
+ *
5
+ * Warum eine eigene, kleine Implementierung statt einer Bibliothek: der
6
+ * Abgleich muss auf „/"-normalisierten, repo-relativen Pfaden arbeiten
7
+ * (spec.md §5.2), darf keine Dateisystemzugriffe machen (wir globben ueber
8
+ * INDIZIERTE Pfade, nicht ueber die Platte) und muss auf jeder Maschine
9
+ * dieselbe Antwort geben. Die unterstuetzte Teilmenge ist bewusst klein und
10
+ * hier vollstaendig dokumentiert:
11
+ *
12
+ * | Muster | Bedeutung |
13
+ * |---|---|
14
+ * | `?` | genau ein Zeichen, aber nie `/` |
15
+ * | `*` | beliebig viele Zeichen, aber nie `/` |
16
+ * | `**` | beliebig viele Zeichen inklusive `/` |
17
+ * | `[abc]` | eine Zeichenklasse, `[!abc]`/`[^abc]` negiert |
18
+ * | sonst | Literal |
19
+ *
20
+ * Ein Muster ohne `/` wird zusaetzlich gegen den BASENAMEN geprueft, damit
21
+ * `*.ts` das tut, was jeder erwartet, ohne dass der Nutzer `**\/*.ts`
22
+ * schreiben muss. Diese Bequemlichkeit ist eine bewusste Entscheidung und in
23
+ * der README als solche vermerkt.
24
+ */
25
+
26
+ /** Wandelt ein Glob-Muster in einen verankerten regulaeren Ausdruck. */
27
+ export function globToRegExp(pattern: string): RegExp {
28
+ let source = "^";
29
+ let index = 0;
30
+ while (index < pattern.length) {
31
+ const char = pattern[index] as string;
32
+ if (char === "*") {
33
+ const isDouble = pattern[index + 1] === "*";
34
+ if (isDouble) {
35
+ // `a/**/b` soll auch `a/b` treffen: der Schraegstrich hinter `**`
36
+ // wird optional mitgeschluckt.
37
+ if (pattern[index + 2] === "/") {
38
+ source += "(?:.*/)?";
39
+ index += 3;
40
+ continue;
41
+ }
42
+ source += ".*";
43
+ index += 2;
44
+ continue;
45
+ }
46
+ source += "[^/]*";
47
+ index += 1;
48
+ continue;
49
+ }
50
+ if (char === "?") {
51
+ source += "[^/]";
52
+ index += 1;
53
+ continue;
54
+ }
55
+ if (char === "[") {
56
+ const close = pattern.indexOf("]", index + 1);
57
+ if (close === -1) {
58
+ // Unpaarige Klammer ist ein Literal, kein Fehler — ein Suchmuster
59
+ // darf nicht daran scheitern, dass jemand `[` im Pfad meint.
60
+ source += "\\[";
61
+ index += 1;
62
+ continue;
63
+ }
64
+ const body = pattern.slice(index + 1, close);
65
+ const negated = body.startsWith("!") || body.startsWith("^");
66
+ const content = negated ? body.slice(1) : body;
67
+ source += `[${negated ? "^" : ""}${content.replace(/[\\\]]/g, "\\$&")}]`;
68
+ index = close + 1;
69
+ continue;
70
+ }
71
+ source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
+ index += 1;
73
+ }
74
+ source += "$";
75
+ try {
76
+ return new RegExp(source, "u");
77
+ } catch (error) {
78
+ // Ein fehlerhaftes Muster ist ein EINGABEFEHLER des Nutzers. Ohne diesen
79
+ // Rahmen wandert der rohe `SyntaxError` durch den Abgleich bis in ein
80
+ // catch, das ihn als Speicherausfall verbucht — der Nutzer bekaeme dann
81
+ // „Provider nicht verfuegbar" fuer sein eigenes Zeichenklassenmuster.
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ throw new Error(`glob pattern "${pattern}" is not a valid pattern: ${message}`);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Prueft eine Musterliste, BEVOR sie benutzt wird.
89
+ *
90
+ * Warum vorab und nicht erst beim Abgleich: der Abgleich laeuft je Kandidat,
91
+ * tief in der Auswertung. Ein Fehler dort trifft eine Stelle, die ihn nur noch
92
+ * als „dieses Ziel hat nicht geantwortet" deuten kann. Vorab geprueft, ist er
93
+ * das, was er ist: eine abgelehnte Anfrage mit benanntem Muster.
94
+ */
95
+ export function assertValidGlobPatterns(patterns: readonly string[], label: string): void {
96
+ for (const pattern of patterns) {
97
+ try {
98
+ globToRegExp(normalizeGlobPath(pattern));
99
+ } catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ throw new Error(`search query field "${label}" contains an invalid pattern: ${message}`);
102
+ }
103
+ }
104
+ }
105
+
106
+ /** Kanonisiert einen Pfad fuer den Abgleich: Backslashes zu `/`, kein `./`. */
107
+ export function normalizeGlobPath(path: string): string {
108
+ const slashed = path.replace(/\\/g, "/");
109
+ return slashed.startsWith("./") ? slashed.slice(2) : slashed;
110
+ }
111
+
112
+ /** True, wenn `path` auf `pattern` passt (siehe Modulkommentar). */
113
+ export function matchesGlob(path: string, pattern: string): boolean {
114
+ const normalizedPath = normalizeGlobPath(path);
115
+ const normalizedPattern = normalizeGlobPath(pattern);
116
+ const expression = globToRegExp(normalizedPattern);
117
+ if (expression.test(normalizedPath)) {
118
+ return true;
119
+ }
120
+ if (!normalizedPattern.includes("/")) {
121
+ const basename = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
122
+ return expression.test(basename);
123
+ }
124
+ return false;
125
+ }
126
+
127
+ /** True, wenn `path` auf mindestens eines der Muster passt. */
128
+ export function matchesAnyGlob(path: string, patterns: readonly string[]): boolean {
129
+ return patterns.some((pattern) => matchesGlob(path, pattern));
130
+ }
131
+
132
+ /**
133
+ * Entscheidet ueber Ein-/Ausschluss eines Pfades.
134
+ *
135
+ * - leere Einschlussliste heisst „alles einschliessen" (wie in SearchFiltersV1
136
+ * dokumentiert: eine leere Liste ist kein Filter);
137
+ * - Ausschluss gewinnt immer gegen Einschluss.
138
+ */
139
+ export function isPathSelected(
140
+ path: string,
141
+ include: readonly string[],
142
+ exclude: readonly string[],
143
+ ): boolean {
144
+ if (exclude.length > 0 && matchesAnyGlob(path, exclude)) {
145
+ return false;
146
+ }
147
+ if (include.length === 0) {
148
+ return true;
149
+ }
150
+ return matchesAnyGlob(path, include);
151
+ }