@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/grep.ts ADDED
@@ -0,0 +1,618 @@
1
+ /**
2
+ * grep-artige Suche ueber den indexierten Inhalt (Nutzerwunsch A4).
3
+ *
4
+ * „Beides — semantisch UND grep-artig — unter einer Oberflaeche" heisst hier
5
+ * woertlich: derselbe Port, dieselben Filter, dieselbe Provenienz je Treffer,
6
+ * derselbe Skip-Ledger. Der Unterschied ist nur, WAS gefragt wird — ein
7
+ * Zeichenmuster statt einer Bedeutung.
8
+ *
9
+ * WICHTIG FUER DIE INTERPRETATION DER ERGEBNISSE: Gesucht wird im INDEX, nicht
10
+ * auf der Platte. Was nicht indexiert ist (uebersprungene Datei, Chunk ohne
11
+ * gespeicherten Inhalt), kann hier nicht gefunden werden. Genau deshalb traegt
12
+ * `GrepResultSetV1` den Skip-Ledger und die Zahl der durchsuchten Chunks mit —
13
+ * „keine Treffer" und „nichts durchsucht" duerfen nicht gleich aussehen (E4).
14
+ *
15
+ * Zeilennummern sind ECHTE Dateizeilen: der Chunk kennt seine `start_line`,
16
+ * und die Zeilenzaehlung setzt darauf auf. Kontextzeilen kommen aus demselben
17
+ * Chunk; reicht der Kontext ueber die Chunkgrenze hinaus, endet er dort. Das
18
+ * ist eine bewusste Grenze (siehe README „Bekannte Grenzen") — die
19
+ * Nachbarzeilen liegen in einem anderen Punkt und muessten einzeln nachgeladen
20
+ * werden.
21
+ */
22
+ import type {
23
+ FieldCondition,
24
+ PayloadFilter,
25
+ PortCallOptions,
26
+ SkipLedgerEntryV1,
27
+ ToolWarningV1,
28
+ VectorStore,
29
+ } from "@untangle-code/domain";
30
+ import { compareCodepoints } from "@untangle-code/domain";
31
+
32
+ import { assertValidGlobPatterns, isPathSelected } from "./glob.js";
33
+ import { SearchSkipLedger } from "./ledger.js";
34
+ import { readCodeChunkPayload } from "./payload.js";
35
+ import { assertSafeGrepPattern } from "./regex-safety.js";
36
+ import type { GrepContextLineV1, GrepMatchV1, GrepRequestV1, GrepResultSetV1 } from "./types.js";
37
+
38
+ /** Voreinstellungen der Budgets — jede Ueberschreitung ist ein benannter Abbruchgrund. */
39
+ export const GREP_DEFAULTS = {
40
+ maxMatches: 200,
41
+ maxMatchesPerFile: 50,
42
+ maxScannedChunks: 5000,
43
+ /** Seitengroesse des Scroll-Laufs ueber die Collection. */
44
+ scrollPageSize: 256,
45
+ } as const;
46
+
47
+ /**
48
+ * Wie viele glob-ausgeschlossene Chunks EINZELN im Ledger stehen.
49
+ *
50
+ * Jeder ausgeschlossene Chunk gehoert in den Ledger (E4) — bei einem Budget
51
+ * von 5000 gescannten Chunks waere eine Liste mit 5000 Eintraegen aber selbst
52
+ * ein Problem fuer den Leser. Also: die ersten `GREP_GLOB_LEDGER_DETAIL_LIMIT`
53
+ * einzeln, danach EIN Sammeleintrag mit der vollstaendigen Zahl. Verschwiegen
54
+ * wird nichts; die Zahl stimmt in jedem Fall.
55
+ */
56
+ export const GREP_GLOB_LEDGER_DETAIL_LIMIT = 50;
57
+
58
+ /**
59
+ * Der Geltungsbereich, in dem grep ueberhaupt suchen darf.
60
+ *
61
+ * Dieselbe Begruendung wie bei `SearchScopeV1` in `filter.ts`: ohne Workspace
62
+ * laeuft eine Mustersuche ueber die Chunks JEDES Workspaces, der zufaellig in
63
+ * derselben Collection liegt, und meldet fremden Code als eigenen.
64
+ */
65
+ export interface GrepScopeV1 {
66
+ /** Workspace, falls die Anfrage selbst keinen nennt. */
67
+ workspaceId?: string;
68
+ /** Repositories der durchsuchten Collections; leer heisst „nicht deklariert". */
69
+ repoIds?: readonly string[];
70
+ }
71
+
72
+ /** Maskiert einen Literaltext, damit er als regulaerer Ausdruck sich selbst bedeutet. */
73
+ export function escapeRegExpLiteral(text: string): string {
74
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
75
+ }
76
+
77
+ /**
78
+ * Baut den Suchausdruck.
79
+ *
80
+ * `wholeWord` verwendet bewusst Lookarounds ueber Bezeichnerzeichen statt
81
+ * `\b`: `\b` kennt nur ASCII-Wortzeichen, und ein Bezeichner mit Umlaut oder
82
+ * `$` waere damit an der falschen Stelle „ganzes Wort".
83
+ */
84
+ export function buildGrepRegExp(request: GrepRequestV1): RegExp {
85
+ const pattern = request.pattern;
86
+ if (pattern === "") {
87
+ throw new Error("grep pattern must not be empty");
88
+ }
89
+ if (request.regex === true) {
90
+ // Das `i`-Flag geht in die Pruefung ein: `(a|A)+` ist ohne `i` eindeutig
91
+ // und mit `i` mehrdeutig. Wer das Flag hier vergisst, prueft ein anderes
92
+ // Muster als das, das gleich laeuft.
93
+ assertSafeGrepPattern(pattern, { caseInsensitive: request.caseSensitive === false });
94
+ }
95
+ const body = request.regex === true ? pattern : escapeRegExpLiteral(pattern);
96
+ const wrapped =
97
+ request.wholeWord === true ? `(?<![\\p{L}\\p{N}_$])(?:${body})(?![\\p{L}\\p{N}_$])` : body;
98
+ const flags = request.caseSensitive === false ? "gui" : "gu";
99
+ try {
100
+ return new RegExp(wrapped, flags);
101
+ } catch (error) {
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ throw new Error(`grep pattern is not a valid regular expression: ${message}`);
104
+ }
105
+ }
106
+
107
+ function keywordIn(
108
+ field: string,
109
+ values: readonly string[] | undefined,
110
+ ): FieldCondition | undefined {
111
+ if (values === undefined || values.length === 0) {
112
+ return undefined;
113
+ }
114
+ const unique = [...new Set(values)].sort(compareCodepoints);
115
+ if (unique.length === 1) {
116
+ return { field, match: { eq: unique[0] as string } };
117
+ }
118
+ return { field, match: { in: unique } };
119
+ }
120
+
121
+ /** Der aufgeloeste Geltungsbereich eines grep-Laufs. */
122
+ interface ResolvedGrepScope {
123
+ filter: PayloadFilter;
124
+ /** Gesetzt, wenn der Bereich leer ist; dann wird nicht gesucht. */
125
+ emptyScopeReason?: string;
126
+ /** Gesetzt, wenn kein Workspace bekannt ist (Suche ueber alle Workspaces). */
127
+ unscopedWarning?: string;
128
+ /**
129
+ * WP-177: der wirksame Workspace, GETRIMMT — Grundlage der zweiten
130
+ * Verteidigungslinie im Scan. `undefined` heisst „keiner bekannt"; dann
131
+ * steht auch die Warnung oben.
132
+ */
133
+ workspaceId?: string;
134
+ /**
135
+ * WP-177/WP-178: die wirksam erlaubten Repositories (Deklaration geschnitten
136
+ * mit dem Filter der Anfrage). Leer heisst „nicht eingeschraenkt".
137
+ */
138
+ repoIds: string[];
139
+ /**
140
+ * WP-176: angefragte Repositories, die keine der durchsuchten Collections
141
+ * fuehrt. Sie wurden NICHT durchsucht, und das gehoert ins Ergebnis.
142
+ */
143
+ outOfScopeRepoIds: string[];
144
+ }
145
+
146
+ function buildGrepFilter(request: GrepRequestV1, scope: GrepScopeV1 = {}): ResolvedGrepScope {
147
+ const must: FieldCondition[] = [{ field: "point_type", match: { eq: "code_chunk" } }];
148
+ // WP-190, dieselbe Ursache wie in `normalizeSearchQuery`: getrimmt, damit
149
+ // Speicherfilter und Gegenpruefung denselben Wert benutzen.
150
+ const workspaceId = (request.workspaceId ?? scope.workspaceId)?.trim();
151
+ let unscopedWarning: string | undefined;
152
+ if (workspaceId === undefined || workspaceId === "") {
153
+ // E4: „ueber alles gesucht" und „im eigenen Workspace gesucht" duerfen
154
+ // nicht gleich aussehen.
155
+ unscopedWarning =
156
+ "grep ran without a workspaceId; the scan covers every workspace stored in the searched collections";
157
+ } else {
158
+ must.push({ field: "workspace_id", match: { eq: workspaceId } });
159
+ }
160
+
161
+ const requested = request.repositories ?? [];
162
+ const declared = scope.repoIds ?? [];
163
+ let repoIds: string[];
164
+ // WP-176: dieselbe Luecke wie bei `search` — angefragte Repositories, die
165
+ // keine Collection fuehrt, fielen lautlos heraus, solange wenigstens EINES
166
+ // uebrig blieb. Nur der vollstaendig leere Durchschnitt kam in den Ledger.
167
+ let outOfScopeRepoIds: string[] = [];
168
+ if (requested.length === 0) {
169
+ repoIds = [...new Set(declared)].sort(compareCodepoints);
170
+ } else if (declared.length === 0) {
171
+ repoIds = [...new Set(requested)].sort(compareCodepoints);
172
+ } else {
173
+ const declaredSet = new Set(declared);
174
+ repoIds = [...new Set(requested.filter((repoId) => declaredSet.has(repoId)))].sort(
175
+ compareCodepoints,
176
+ );
177
+ outOfScopeRepoIds = [...new Set(requested.filter((repoId) => !declaredSet.has(repoId)))].sort(
178
+ compareCodepoints,
179
+ );
180
+ if (repoIds.length === 0) {
181
+ return {
182
+ filter: { must },
183
+ repoIds,
184
+ outOfScopeRepoIds,
185
+ ...(workspaceId === undefined || workspaceId === "" ? {} : { workspaceId }),
186
+ emptyScopeReason: `the requested repositories [${[...new Set(requested)].sort(compareCodepoints).join(", ")}] are not part of the searched collections [${[...declaredSet].sort(compareCodepoints).join(", ")}]; nothing was scanned`,
187
+ ...(unscopedWarning === undefined ? {} : { unscopedWarning }),
188
+ };
189
+ }
190
+ }
191
+
192
+ for (const condition of [
193
+ keywordIn("repo_id", repoIds),
194
+ keywordIn("ref_id", request.refs),
195
+ keywordIn("language", request.languages),
196
+ ]) {
197
+ if (condition !== undefined) {
198
+ must.push(condition);
199
+ }
200
+ }
201
+ return {
202
+ filter: { must },
203
+ repoIds,
204
+ outOfScopeRepoIds,
205
+ ...(workspaceId === undefined || workspaceId === "" ? {} : { workspaceId }),
206
+ ...(unscopedWarning === undefined ? {} : { unscopedWarning }),
207
+ };
208
+ }
209
+
210
+ interface RawMatch extends GrepMatchV1 {
211
+ /** Schluessel zur Deduplizierung ueberlappender Fragmente. */
212
+ key: string;
213
+ }
214
+
215
+ function collectMatchesInChunk(
216
+ expression: RegExp,
217
+ text: string,
218
+ startLine: number,
219
+ contextBefore: number,
220
+ contextAfter: number,
221
+ ): {
222
+ line: number;
223
+ column: number;
224
+ matchText: string;
225
+ lineText: string;
226
+ before: GrepContextLineV1[];
227
+ after: GrepContextLineV1[];
228
+ }[] {
229
+ const lines = text.split("\n");
230
+ const results: {
231
+ line: number;
232
+ column: number;
233
+ matchText: string;
234
+ lineText: string;
235
+ before: GrepContextLineV1[];
236
+ after: GrepContextLineV1[];
237
+ }[] = [];
238
+ lines.forEach((lineText, index) => {
239
+ // `lastIndex` je Zeile zuruecksetzen: der Ausdruck ist global, und ein
240
+ // stehengebliebener Zeiger wuerde Treffer in der naechsten Zeile
241
+ // ueberspringen — ein klassischer, still wirkender Fehler.
242
+ expression.lastIndex = 0;
243
+ let match = expression.exec(lineText);
244
+ while (match !== null) {
245
+ const matchText = match[0];
246
+ results.push({
247
+ line: startLine + index,
248
+ column: match.index + 1,
249
+ matchText,
250
+ lineText,
251
+ before: sliceContext(lines, index - contextBefore, index, startLine),
252
+ after: sliceContext(lines, index + 1, index + 1 + contextAfter, startLine),
253
+ });
254
+ if (matchText === "") {
255
+ // Nulllaengen-Treffer wuerden sonst endlos an derselben Stelle stehen.
256
+ expression.lastIndex += 1;
257
+ }
258
+ if (expression.lastIndex > lineText.length) {
259
+ break;
260
+ }
261
+ match = expression.exec(lineText);
262
+ }
263
+ });
264
+ return results;
265
+ }
266
+
267
+ function sliceContext(
268
+ lines: readonly string[],
269
+ from: number,
270
+ to: number,
271
+ startLine: number,
272
+ ): GrepContextLineV1[] {
273
+ const begin = Math.max(0, from);
274
+ const end = Math.min(lines.length, to);
275
+ const context: GrepContextLineV1[] = [];
276
+ for (let index = begin; index < end; index += 1) {
277
+ context.push({ line: startLine + index, text: lines[index] as string });
278
+ }
279
+ return context;
280
+ }
281
+
282
+ /**
283
+ * Durchsucht die angegebenen Collections mit einem Muster.
284
+ *
285
+ * Der Ablauf ist bewusst ein Scroll und kein `hybridQuery`: eine Regex-Suche
286
+ * hat kein Ranking-Signal, sie hat einen VOLLSTAENDIGKEITSANSPRUCH. Also wird
287
+ * gefiltert und durchlaufen, bis das Budget greift — und wenn es greift, steht
288
+ * das im Ergebnis.
289
+ */
290
+ export async function grepIndexedChunks(
291
+ store: VectorStore,
292
+ collections: readonly string[],
293
+ request: GrepRequestV1,
294
+ options?: PortCallOptions,
295
+ scope: GrepScopeV1 = {},
296
+ ): Promise<GrepResultSetV1> {
297
+ options?.signal?.throwIfAborted();
298
+ const expression = buildGrepRegExp(request);
299
+ const includeGlobsRaw = request.pathGlob ?? [];
300
+ const excludeGlobsRaw = request.pathExclude ?? [];
301
+ // Vor dem ersten Speicherzugriff pruefen: ein fehlerhaftes Muster ist ein
302
+ // Eingabefehler und darf nicht mitten im Scan als Ausfall erscheinen.
303
+ assertValidGlobPatterns(includeGlobsRaw, "pathGlob");
304
+ assertValidGlobPatterns(excludeGlobsRaw, "pathExclude");
305
+ const resolved = buildGrepFilter(request, scope);
306
+ const filter = resolved.filter;
307
+ const ledger = new SearchSkipLedger();
308
+ const warnings: ToolWarningV1[] = [];
309
+ if (resolved.unscopedWarning !== undefined) {
310
+ warnings.push({ code: "WORKSPACE_UNSCOPED", message: resolved.unscopedWarning });
311
+ }
312
+
313
+ const maxMatches = request.maxMatches ?? GREP_DEFAULTS.maxMatches;
314
+ const maxMatchesPerFile = request.maxMatchesPerFile ?? GREP_DEFAULTS.maxMatchesPerFile;
315
+ const maxScannedChunks = request.maxScannedChunks ?? GREP_DEFAULTS.maxScannedChunks;
316
+ const contextBefore = Math.max(0, request.contextLinesBefore ?? 0);
317
+ const contextAfter = Math.max(0, request.contextLinesAfter ?? 0);
318
+ const includeGlobs = includeGlobsRaw;
319
+ const excludeGlobs = excludeGlobsRaw;
320
+
321
+ const matches: RawMatch[] = [];
322
+ const seen = new Set<string>();
323
+ const perFileCounts = new Map<string, number>();
324
+ let scannedChunks = 0;
325
+ let truncated = false;
326
+ let truncationReason: GrepResultSetV1["truncationReason"];
327
+ let globExcludedChunks = 0;
328
+
329
+ if (resolved.emptyScopeReason !== undefined) {
330
+ ledger.add({
331
+ subjectKind: "chunk",
332
+ reason: "excluded_by_configuration",
333
+ detail: resolved.emptyScopeReason,
334
+ recoverable: true,
335
+ });
336
+ }
337
+ if (resolved.outOfScopeRepoIds.length > 0) {
338
+ const detail = `the requested repositories [${resolved.outOfScopeRepoIds.join(", ")}] are not part of the searched collections; they were NOT scanned`;
339
+ warnings.push({
340
+ code: "REPOSITORY_OUT_OF_SCOPE",
341
+ message: detail,
342
+ details: { repoIds: resolved.outOfScopeRepoIds },
343
+ });
344
+ for (const repoId of resolved.outOfScopeRepoIds) {
345
+ ledger.add({
346
+ subjectKind: "chunk",
347
+ subjectId: repoId,
348
+ repoId,
349
+ reason: "excluded_by_configuration",
350
+ detail,
351
+ recoverable: true,
352
+ });
353
+ }
354
+ }
355
+
356
+ scan: for (const collection of resolved.emptyScopeReason === undefined ? collections : []) {
357
+ let offset: string | undefined;
358
+ for (;;) {
359
+ options?.signal?.throwIfAborted();
360
+ const page = await store.scroll(
361
+ collection,
362
+ {
363
+ filter,
364
+ limit: GREP_DEFAULTS.scrollPageSize,
365
+ ...(offset === undefined ? {} : { offset }),
366
+ },
367
+ options,
368
+ );
369
+ for (const point of page.points) {
370
+ // Je Chunk pruefen, nicht nur je Seite: eine Seite sind bis zu 256
371
+ // Punkte, und ein Abbruch, der erst danach wirkt, ist keiner.
372
+ options?.signal?.throwIfAborted();
373
+ if (scannedChunks >= maxScannedChunks) {
374
+ truncated = true;
375
+ truncationReason = "max_scanned_chunks";
376
+ ledger.add({
377
+ subjectKind: "chunk",
378
+ subjectId: point.id,
379
+ reason: "time_budget_exceeded",
380
+ detail: `grep stopped after scanning ${maxScannedChunks} chunks; the remainder of "${collection}" was not searched`,
381
+ recoverable: true,
382
+ });
383
+ break scan;
384
+ }
385
+ scannedChunks += 1;
386
+ const read = readCodeChunkPayload(point.payload);
387
+ if (!read.ok) {
388
+ ledger.add({
389
+ subjectKind: "chunk",
390
+ subjectId: point.id,
391
+ reason: "missing_index_entry",
392
+ detail: `point "${point.id}" could not be read as a code chunk: ${read.reason}`,
393
+ });
394
+ continue;
395
+ }
396
+ const payload = read.payload;
397
+ // WP-177 (E4): DIE ZWEITE VERTEIDIGUNGSLINIE, die grep bisher nicht
398
+ // hatte. `search` prueft den Geltungsbereich nach dem Abruf ein
399
+ // zweites Mal, weil ein Speicher, der den Payloadfilter nicht oder
400
+ // anders auswertet, sonst fremden Inhalt als eigenen ausliefert. Fuer
401
+ // grep galt dieselbe Gefahr und keine Pruefung: ein solcher Speicher
402
+ // haette hier Zeilen aus einem fremden Workspace ausgedruckt, ohne
403
+ // Warnung und ohne Ledger-Eintrag. Das ist kein ungenaues Ergebnis,
404
+ // sondern ein Datenleck.
405
+ if (resolved.workspaceId !== undefined && payload.workspace_id !== resolved.workspaceId) {
406
+ ledger.add({
407
+ subjectKind: "chunk",
408
+ subjectId: payload.chunk_id,
409
+ path: payload.file_path,
410
+ repoId: payload.repo_id,
411
+ refId: payload.ref_id,
412
+ reason: "excluded_by_configuration",
413
+ detail: `chunk belongs to workspace "${payload.workspace_id}", but this grep is scoped to workspace "${resolved.workspaceId}" — the store returned it anyway`,
414
+ });
415
+ continue;
416
+ }
417
+ if (resolved.repoIds.length > 0 && !resolved.repoIds.includes(payload.repo_id)) {
418
+ ledger.add({
419
+ subjectKind: "chunk",
420
+ subjectId: payload.chunk_id,
421
+ path: payload.file_path,
422
+ repoId: payload.repo_id,
423
+ refId: payload.ref_id,
424
+ reason: "excluded_by_configuration",
425
+ detail: `chunk belongs to repository "${payload.repo_id}", which is not among the scanned repositories [${resolved.repoIds.join(", ")}] — the store returned it anyway`,
426
+ });
427
+ continue;
428
+ }
429
+ if (!isPathSelected(payload.file_path, includeGlobs, excludeGlobs)) {
430
+ // E4: derselbe Ausschluss, den search() in den Ledger schreibt,
431
+ // gehoert auch hier hinein. Sonst meldet grep 12 durchsuchte Chunks
432
+ // und einen leeren Ledger, obwohl 4 davon nie angesehen wurden.
433
+ globExcludedChunks += 1;
434
+ if (globExcludedChunks <= GREP_GLOB_LEDGER_DETAIL_LIMIT) {
435
+ ledger.add({
436
+ subjectKind: "chunk",
437
+ subjectId: payload.chunk_id,
438
+ path: payload.file_path,
439
+ repoId: payload.repo_id,
440
+ refId: payload.ref_id,
441
+ reason: "excluded_by_configuration",
442
+ detail: `path "${payload.file_path}" does not pass the include/exclude globs (include: [${includeGlobs.join(", ")}], exclude: [${excludeGlobs.join(", ")}])`,
443
+ recoverable: true,
444
+ });
445
+ }
446
+ continue;
447
+ }
448
+ if (read.substitutedFields.includes("start_line")) {
449
+ // Ohne `start_line` zaehlt der Chunk ab Zeile 1 — die gemeldeten
450
+ // Zeilennummern waeren dann falsch, ohne dass man es sieht.
451
+ ledger.add({
452
+ subjectKind: "chunk",
453
+ subjectId: payload.chunk_id,
454
+ path: payload.file_path,
455
+ repoId: payload.repo_id,
456
+ refId: payload.ref_id,
457
+ reason: "missing_index_entry",
458
+ detail:
459
+ "chunk payload carries no start_line; the reported line numbers of its matches count from line 1 of the chunk, not of the file",
460
+ recoverable: true,
461
+ });
462
+ }
463
+ if (payload.content === "") {
464
+ ledger.add({
465
+ subjectKind: "chunk",
466
+ subjectId: payload.chunk_id,
467
+ path: payload.file_path,
468
+ repoId: payload.repo_id,
469
+ refId: payload.ref_id,
470
+ reason: "missing_index_entry",
471
+ detail: "chunk carries no stored content; a regex search cannot inspect it",
472
+ });
473
+ continue;
474
+ }
475
+ const fileKey = `${payload.repo_id}/${payload.ref_id}/${payload.file_id}`;
476
+ for (const found of collectMatchesInChunk(
477
+ expression,
478
+ payload.content,
479
+ payload.start_line,
480
+ contextBefore,
481
+ contextAfter,
482
+ )) {
483
+ const key = `${fileKey}:${found.line}:${found.column}:${found.matchText}`;
484
+ if (seen.has(key)) {
485
+ // Ueberlappende Fragmente desselben Symbols liefern denselben
486
+ // Treffer mehrfach; er zaehlt einmal.
487
+ continue;
488
+ }
489
+ const perFile = perFileCounts.get(fileKey) ?? 0;
490
+ if (perFile >= maxMatchesPerFile) {
491
+ truncated = true;
492
+ truncationReason = truncationReason ?? "max_matches_per_file";
493
+ ledger.add({
494
+ subjectKind: "file",
495
+ subjectId: payload.file_id,
496
+ path: payload.file_path,
497
+ repoId: payload.repo_id,
498
+ refId: payload.ref_id,
499
+ reason: "excluded_by_configuration",
500
+ detail: `more than maxMatchesPerFile=${maxMatchesPerFile} matches in this file; further matches were not returned`,
501
+ recoverable: true,
502
+ });
503
+ break;
504
+ }
505
+ seen.add(key);
506
+ perFileCounts.set(fileKey, perFile + 1);
507
+ matches.push({
508
+ key,
509
+ repoId: payload.repo_id,
510
+ repoLabel: payload.repo_label,
511
+ refId: payload.ref_id,
512
+ refName: payload.ref_name,
513
+ ...(payload.commit_sha === undefined ? {} : { commitSha: payload.commit_sha }),
514
+ fileId: payload.file_id,
515
+ filePath: payload.file_path,
516
+ language: payload.language,
517
+ chunkId: payload.chunk_id,
518
+ ...(payload.symbol_name === undefined ? {} : { symbolName: payload.symbol_name }),
519
+ line: found.line,
520
+ column: found.column,
521
+ matchText: found.matchText,
522
+ lineText: found.lineText,
523
+ before: found.before,
524
+ after: found.after,
525
+ });
526
+ if (matches.length >= maxMatches) {
527
+ truncated = true;
528
+ truncationReason = "max_matches";
529
+ ledger.add({
530
+ subjectKind: "chunk",
531
+ subjectId: payload.chunk_id,
532
+ path: payload.file_path,
533
+ repoId: payload.repo_id,
534
+ refId: payload.ref_id,
535
+ reason: "excluded_by_configuration",
536
+ detail: `grep stopped at maxMatches=${maxMatches}; further matches exist but were not returned`,
537
+ recoverable: true,
538
+ });
539
+ break scan;
540
+ }
541
+ }
542
+ }
543
+ if (page.nextOffset === undefined) {
544
+ break;
545
+ }
546
+ offset = page.nextOffset;
547
+ }
548
+ }
549
+
550
+ if (globExcludedChunks > GREP_GLOB_LEDGER_DETAIL_LIMIT) {
551
+ ledger.add({
552
+ subjectKind: "chunk",
553
+ reason: "excluded_by_configuration",
554
+ detail: `${globExcludedChunks} scanned chunks were excluded by the include/exclude globs; the first ${GREP_GLOB_LEDGER_DETAIL_LIMIT} are listed individually above`,
555
+ recoverable: true,
556
+ });
557
+ }
558
+
559
+ if (collections.length === 0) {
560
+ warnings.push({
561
+ code: "NO_COLLECTIONS",
562
+ message:
563
+ "grep ran without a single collection to search; the empty result says nothing about the code",
564
+ });
565
+ }
566
+
567
+ const sorted = sortGrepMatches(matches, request.sort ?? "path");
568
+ return {
569
+ schemaVersion: 1,
570
+ pattern: request.pattern,
571
+ isRegex: request.regex === true,
572
+ matches: sorted.map(({ key: _key, ...match }) => match),
573
+ totalMatches: sorted.length,
574
+ truncated,
575
+ ...(truncationReason === undefined ? {} : { truncationReason }),
576
+ scannedChunks,
577
+ skipped: ledger.toArray() as SkipLedgerEntryV1[],
578
+ warnings,
579
+ };
580
+ }
581
+
582
+ /** Sortiert grep-Treffer deterministisch. */
583
+ export function sortGrepMatches<T extends GrepMatchV1>(
584
+ matches: readonly T[],
585
+ sort: "path" | "matches-desc",
586
+ ): T[] {
587
+ const byPath = (a: T, b: T): number => {
588
+ const byRepo = compareCodepoints(a.repoId, b.repoId);
589
+ if (byRepo !== 0) {
590
+ return byRepo;
591
+ }
592
+ const byRef = compareCodepoints(a.refId, b.refId);
593
+ if (byRef !== 0) {
594
+ return byRef;
595
+ }
596
+ const byFile = compareCodepoints(a.filePath, b.filePath);
597
+ if (byFile !== 0) {
598
+ return byFile;
599
+ }
600
+ if (a.line !== b.line) {
601
+ return a.line - b.line;
602
+ }
603
+ return a.column - b.column;
604
+ };
605
+ if (sort === "path") {
606
+ return [...matches].sort(byPath);
607
+ }
608
+ const counts = new Map<string, number>();
609
+ for (const match of matches) {
610
+ const key = `${match.repoId}/${match.refId}/${match.fileId}`;
611
+ counts.set(key, (counts.get(key) ?? 0) + 1);
612
+ }
613
+ return [...matches].sort((a, b) => {
614
+ const countA = counts.get(`${a.repoId}/${a.refId}/${a.fileId}`) ?? 0;
615
+ const countB = counts.get(`${b.repoId}/${b.refId}/${b.fileId}`) ?? 0;
616
+ return countA === countB ? byPath(a, b) : countB - countA;
617
+ });
618
+ }
package/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @untangle-code/search — der Abrufkern von Untangle (spec.md §13,
3
+ * architecture.md §18, vision.md §1b/§1d).
4
+ *
5
+ * Das Paket beantwortet die Frage, mit der der Nutzer das Produkt beschrieben
6
+ * hat: „wenn ich nach ‚Funktion X' suche sollte ich schnell an der richtigen
7
+ * Stelle landen, wenn nicht ist der Code vielleicht zu komplex bzw nicht
8
+ * richtig dokumentiert." Beide Haelften stecken hier drin:
9
+ *
10
+ * - die SUCHE — hybrid aus dichtem Vektor, eigenem BM25 und exakten
11
+ * Symbolnamen, fusioniert per RRF, mit einer Score-Zerlegung, die jeden
12
+ * Rang begruendet;
13
+ * - der BEFUND — dieselbe Zerlegung, gelesen als Aussage ueber den Code:
14
+ * `findability.ts` laesst ein Symbol sich selbst suchen und benennt die
15
+ * Ursache, wenn es sich nicht findet.
16
+ *
17
+ * Dieses Modul ist der EINZIGE oeffentliche Export des Pakets.
18
+ */
19
+
20
+ // Bezeichner-bewusste Tokenisierung (Grundlage des lexikalischen Zweigs).
21
+ export * from "./tokenize.js";
22
+
23
+ // Deterministisches BM25 ueber den Kandidatenpool.
24
+ export * from "./bm25.js";
25
+
26
+ // Glob-Abgleich fuer Pfad-Ein-/Ausschluss.
27
+ export * from "./glob.js";
28
+
29
+ // Skip-Ledger-Sammler (E4).
30
+ export * from "./ledger.js";
31
+
32
+ // Gespeicherter Chunk-Payload → Kandidat.
33
+ export * from "./payload.js";
34
+
35
+ // Normalisierte Filter → PayloadFilter (plus die Filter, die nicht pushdown-faehig sind).
36
+ export * from "./filter.js";
37
+
38
+ // Exakte und nahe Symbolnamen-Treffer.
39
+ export * from "./symbol-match.js";
40
+
41
+ // Reciprocal Rank Fusion der drei Ranglisten.
42
+ export * from "./fusion.js";
43
+
44
+ // Score-Zerlegung samt Boost-Begrenzung (spec.md §13.3).
45
+ export * from "./scoring.js";
46
+
47
+ // Belegbare Relevanzentscheidung und Abwesenheitsaussage (WP-231).
48
+ export * from "./relevance.js";
49
+
50
+ // Dedupe (spec.md §13.5) und Diversifizierung (architecture.md §18.5).
51
+ export * from "./dedupe.js";
52
+ export * from "./diversify.js";
53
+
54
+ // Sortierung der Trefferliste.
55
+ export * from "./sort.js";
56
+
57
+ // Paket-eigene Vertraege (Modus, grep, Formate).
58
+ export * from "./types.js";
59
+
60
+ // Der Port und seine Injektionspunkte.
61
+ export * from "./port.js";
62
+
63
+ // Post-Ranking-Anreicherung aus persistierten Graphen.
64
+ export * from "./enrichment.js";
65
+
66
+ // Die Referenzimplementierung.
67
+ export * from "./engine.js";
68
+
69
+ // Ablehnung von Suchmustern mit exponentiellem Zurueckziehen.
70
+ export * from "./regex-safety.js";
71
+
72
+ // grep-Modus ueber den indexierten Inhalt.
73
+ export * from "./grep.js";
74
+
75
+ // Ausgabeformate (Text, JSON, JSONL).
76
+ export * from "./format.js";
77
+
78
+ // Findability je Symbol (vision.md §1d).
79
+ export * from "./findability.js";
80
+ export * from "./product-complexity.js";
81
+
82
+ // Harte Gueltigkeitsgrenze fuer reproduzierbare Live-Benchmarks.
83
+ export * from "./benchmark-integrity.js";