From 6928c8a177f5e8ab5c3bef4aa4377ce07a504947 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 6 Sep 2026 15:56:53 +0200 Subject: [PATCH] fix(ui): read the lean-contract footer lines in CodeAtlasWeb's compact parser On current main the compact text form of query_graph and search_graph carries footer lines the frontend's parser did not know (returned, total_relation, truncated; search_mode now follows the head): rows: 2 (cols: n.name n.file_path) loadConfig src/config.ts isProduction src/config.ts returned: 2 total: 2 total_relation: eq has_more: false truncated: false The parser refused the first unknown line, and since the reader resolves a file's module symbol through query_graph before it asks for the source, no file could be opened on main ("query_graph: Antwort war nicht lesbar: unbekannte Fusszeile einer kompakten Antwort: returned: 1"). Both parsers now read the new keys as fields (returned, totalRelation, hasMore, truncated) rather than skipping them, so a capped answer stays nameable, and refuse anything else as before. Two tests carry main's exact output for the fixture. The recorded fixtures from before the contract keep passing, as the fields are optional. Verified against the embedded build on the re-stacked branch: files open, the twin answers, search returns hits; provider tests 126 passed. Signed-off-by: Martin Vogel --- graph-ui/src/provider/compact-rows.test.ts | 51 +++++++++++++ graph-ui/src/provider/compact-rows.ts | 87 +++++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/graph-ui/src/provider/compact-rows.test.ts b/graph-ui/src/provider/compact-rows.test.ts index 25cd86467..da553800f 100644 --- a/graph-ui/src/provider/compact-rows.test.ts +++ b/graph-ui/src/provider/compact-rows.test.ts @@ -120,6 +120,28 @@ describe('parseCompactRows', () => { expect(() => parseCompactRows(broken)).toThrow(/rows: 2 an, geliefert wurden 1/); }); + it('liest die Zeilenform des schlanken Vertrags mit ihren Fusszeilen', () => { + const lean = [ + 'rows: 2 (cols: n.name n.file_path)', + ' loadConfig src/config.ts', + ' isProduction src/config.ts', + 'returned: 2', + 'total: 2', + 'total_relation: eq', + 'has_more: false', + 'truncated: false', + '', + ].join('\n'); + const parsed = parseCompactRows(lean); + expect(parsed.rows).toEqual([['loadConfig', 'src/config.ts'], ['isProduction', 'src/config.ts']]); + expect(parsed.total).toBe(2); + expect(parsed.returned).toBe(2); + expect(parsed.totalRelation).toBe('eq'); + expect(parsed.hasMore).toBe(false); + expect(parsed.truncated).toBe(false); + expect(parsed.hint).toBeUndefined(); + }); + it('wirft ohne total-Zeile, statt total zu erfinden', () => { const broken = ['rows: 1 (cols: b.name)', ' validateUser', ''].join('\n'); expect(() => parseCompactRows(broken)).toThrow(/ohne total-Zeile/); @@ -162,6 +184,35 @@ describe('parseSearchResults', () => { it('nimmt die Zeilenform nicht als Suchform an', () => { expect(() => parseSearchResults(CALLS_RESPONSE)).toThrow(/unbekannte Zeile|unbekannter Kopf/); }); + + /* + * Der schlanke Ausgabevertrag (#1597) haengt an beide Formen weitere + * Fusszeilen: returned, total_relation, truncated (und bei der Suche + * steht search_mode jetzt nach dem Kopf). Woertlich, was der Server auf + * main am 2026-09-06 fuer fixtures/atlas-sample geantwortet hat; ein + * Parser, der eine dieser Zeilen nicht kennt, lieferte vorher gar nichts. + */ + it('liest die Suchantwort des schlanken Vertrags mit ihren Fusszeilen', () => { + const lean = [ + 'results: 1 (cols: qn label file lines rank)', + ' atlas-sample.src.services.userService.createUser Function src/services/userService.ts 23-36 -15.26', + 'total: 1', + 'total_relation: eq', + 'search_mode: bm25', + 'returned: 1', + 'has_more: false', + 'truncated: false', + '', + ].join('\n'); + const parsed = parseSearchResults(lean); + expect(parsed.rows).toHaveLength(1); + expect(parsed.total).toBe(1); + expect(parsed.mode).toBe('bm25'); + expect(parsed.hasMore).toBe(false); + expect(parsed.returned).toBe(1); + expect(parsed.totalRelation).toBe('eq'); + expect(parsed.truncated).toBe(false); + }); }); describe('Hilfsfunktionen', () => { diff --git a/graph-ui/src/provider/compact-rows.ts b/graph-ui/src/provider/compact-rows.ts index 40a0fe0d1..ddf1433b6 100644 --- a/graph-ui/src/provider/compact-rows.ts +++ b/graph-ui/src/provider/compact-rows.ts @@ -29,6 +29,17 @@ export interface CompactRows { total: number; /** Der `hint:`-Text, den der Server bei leeren Ergebnissen mitschickt. */ hint?: string; + /** + * Die Fusszeilen, die der Server seit dem schlanken Ausgabevertrag + * (#1597) mitschickt: wie viele Zeilen geliefert wurden, ob `total` exakt + * oder eine Untergrenze ist (`eq` / `gte`), ob mehr da waere und ob + * gekappt wurde. Optional, weil die Aufzeichnungen von vor dem Vertrag + * sie nicht tragen; wo sie da sind, sind sie die ehrlichere Auskunft. + */ + returned?: number; + totalRelation?: string; + hasMore?: boolean; + truncated?: boolean; } /** Eine geparste search_graph-Antwort. */ @@ -43,6 +54,10 @@ export interface SearchResults { rows: string[][]; /** True, wenn der Server abgeschnitten hat und mehr da waere. */ hasMore: boolean; + /** Die weiteren Fusszeilen des schlanken Vertrags (#1597), siehe CompactRows. */ + returned?: number; + totalRelation?: string; + truncated?: boolean; } const ROWS_HEAD = /^rows:\s*(\d+)\s*\(cols:\s*([^)]*)\)\s*$/; @@ -51,6 +66,9 @@ const TOTAL_LINE = /^total:\s*(\d+)\s*$/; const HINT_LINE = /^hint:\s*(.*)$/; const MODE_LINE = /^search_mode:\s*(\S+)\s*$/; const HAS_MORE_LINE = /^has_more:\s*(true|false)\s*$/; +const RETURNED_LINE = /^returned:\s*(\d+)\s*$/; +const TOTAL_RELATION_LINE = /^total_relation:\s*(\S+)\s*$/; +const TRUNCATED_LINE = /^truncated:\s*(true|false)\s*$/; const DATA_LINE = /^ {2}\S/; /** Zerlegt eine Datenzeile in Zellen und nimmt Anfuehrungszeichen weg. */ @@ -177,6 +195,10 @@ export function parseCompactRows(text: string): CompactRows { let total: number | undefined; let hint: string | undefined; + let returned: number | undefined; + let totalRelation: string | undefined; + let hasMore: boolean | undefined; + let truncated: boolean | undefined; for (; i < lines.length; i += 1) { const line = lines[i]; if (line.trim().length === 0) { @@ -192,6 +214,26 @@ export function parseCompactRows(text: string): CompactRows { hint = unquote(hintMatch[1]); continue; } + const returnedMatch = RETURNED_LINE.exec(line); + if (returnedMatch !== null) { + returned = Number.parseInt(returnedMatch[1], 10); + continue; + } + const relationMatch = TOTAL_RELATION_LINE.exec(line); + if (relationMatch !== null) { + totalRelation = relationMatch[1]; + continue; + } + const hasMoreMatch = HAS_MORE_LINE.exec(line); + if (hasMoreMatch !== null) { + hasMore = hasMoreMatch[1] === 'true'; + continue; + } + const truncatedMatch = TRUNCATED_LINE.exec(line); + if (truncatedMatch !== null) { + truncated = truncatedMatch[1] === 'true'; + continue; + } throw new Error(`unbekannte Fusszeile einer kompakten Antwort: ${line.trim()}`); } @@ -203,6 +245,18 @@ export function parseCompactRows(text: string): CompactRows { if (hint !== undefined) { out.hint = hint; } + if (returned !== undefined) { + out.returned = returned; + } + if (totalRelation !== undefined) { + out.totalRelation = totalRelation; + } + if (hasMore !== undefined) { + out.hasMore = hasMore; + } + if (truncated !== undefined) { + out.truncated = truncated; + } return out; } @@ -218,6 +272,9 @@ export function parseSearchResults(text: string): SearchResults { let declared: number | undefined; let rows: string[][] | undefined; let hasMore: boolean | undefined; + let returned: number | undefined; + let totalRelation: string | undefined; + let truncated: boolean | undefined; let i = 0; while (i < lines.length) { @@ -245,6 +302,24 @@ export function parseSearchResults(text: string): SearchResults { i += 1; continue; } + const returnedMatch = RETURNED_LINE.exec(line); + if (returnedMatch !== null) { + returned = Number.parseInt(returnedMatch[1], 10); + i += 1; + continue; + } + const relationMatch = TOTAL_RELATION_LINE.exec(line); + if (relationMatch !== null) { + totalRelation = relationMatch[1]; + i += 1; + continue; + } + const truncatedMatch = TRUNCATED_LINE.exec(line); + if (truncatedMatch !== null) { + truncated = truncatedMatch[1] === 'true'; + i += 1; + continue; + } const head = RESULTS_HEAD.exec(line); if (head !== null) { declared = Number.parseInt(head[1], 10); @@ -277,7 +352,17 @@ export function parseSearchResults(text: string): SearchResults { throw new Error('Suchantwort ohne has_more-Zeile'); } - return { total, mode, columns, rows, hasMore }; + const out: SearchResults = { total, mode, columns, rows, hasMore }; + if (returned !== undefined) { + out.returned = returned; + } + if (totalRelation !== undefined) { + out.totalRelation = totalRelation; + } + if (truncated !== undefined) { + out.truncated = truncated; + } + return out; } /** Verbindet Spaltennamen und Zellen zu einem Objekt je Zeile. */