@catheadowl/ukp 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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,505 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { loadManifest } from "../config/manifest.ts";
5
+ import { resolveExternalToolCapability, EXTERNAL_PROVIDER } from "../config/external-tool.ts";
6
+ import { localPathOf, readRegistry } from "../registry.ts";
7
+ import { resolveScope } from "../scope.ts";
8
+ import { isInsideRealRoot } from "../path-safety.ts";
9
+
10
+ // rg capability (ADR-RG-001..004): an independent atomic
11
+ // capability parallel to `search` — base lexical search over the endpoint's
12
+ // own files via ripgrep. Thin shaping only (vision principle 6): UKP owns
13
+ // name→cwd resolution, scope boundaries, and output shaping into
14
+ // read/file-ready `ukp://` references; rg's own semantics pass through.
15
+ // Structured outcome per ADR 0021 — never rendered text, never exit codes.
16
+
17
+ export interface RgRequest {
18
+ query: string;
19
+ limit: number;
20
+ }
21
+
22
+ export interface RgOptions {
23
+ explicitEndpoints?: string[];
24
+ global: boolean;
25
+ glob?: string;
26
+ type?: string;
27
+ ignoreCase?: boolean;
28
+ count?: boolean;
29
+ /** Validated passthrough args (allowlist + scope protection, ADR-RG-002). */
30
+ passthrough: readonly string[];
31
+ /** Surface concern (propose precedent): the capability never consumes it —
32
+ * the adapter picks the render mode; the outcome is always structured. */
33
+ json?: boolean;
34
+ }
35
+
36
+ export interface ParsedRg {
37
+ request: RgRequest;
38
+ options: RgOptions;
39
+ warnings: string[];
40
+ }
41
+
42
+ export interface RgContext {
43
+ currentDirectory: string;
44
+ registryPath: string;
45
+ rgCommand?: readonly string[];
46
+ }
47
+
48
+ /** ADR 0021 aggregate classification — the adapter maps it onto exit codes;
49
+ * D-036 semantics (skip never changes the success state; a tool failure
50
+ * fails the run; an interrupt is 130). */
51
+ export type RgAggregateStatus =
52
+ | "succeeded"
53
+ | "failed"
54
+ | "cancelled"
55
+ | "no-success";
56
+
57
+ /** One match in the contract zone: endpoint-relative path, optional line,
58
+ * matched-line excerpt, and the durable slot key. `ukp_uri` is emitted only
59
+ * when the path safely resolves inside the Service folder (ADR 0019 stance);
60
+ * rg results are endpoint-local scans so this is the common case. */
61
+ export interface RgMatch {
62
+ path: string;
63
+ line?: number;
64
+ text?: string;
65
+ ukp_uri?: string;
66
+ }
67
+
68
+ export interface RgCountEntry {
69
+ path: string;
70
+ count: number;
71
+ }
72
+
73
+ export interface RgEndpointOutcome {
74
+ name: string;
75
+ provider: typeof EXTERNAL_PROVIDER;
76
+ status?:
77
+ | "succeeded"
78
+ | "no_matches"
79
+ | "skipped"
80
+ | "failed"
81
+ /** human mode only: this endpoint's SIGINT stopped the run — its header
82
+ * was already rendered, its block never is. */
83
+ | "interrupted"
84
+ /** not reached because an earlier endpoint was interrupted. */
85
+ | "cancelled";
86
+ /** Factual skip/failure message (no surface prefix). */
87
+ message?: string;
88
+ /** Match mode results (client-side capped at the request limit). */
89
+ matches?: RgMatch[];
90
+ /** Count mode results (`--count`). */
91
+ counts?: RgCountEntry[];
92
+ /** True when matches were truncated at the request limit. */
93
+ truncated?: boolean;
94
+ }
95
+
96
+ export interface RgResult {
97
+ query: string;
98
+ limit: number;
99
+ endpoints: RgEndpointOutcome[];
100
+ warnings: string[];
101
+ aggregate: RgAggregateStatus;
102
+ }
103
+
104
+ export class RgUsageError extends Error {
105
+ constructor(message: string) {
106
+ super(message);
107
+ this.name = "RgUsageError";
108
+ }
109
+ }
110
+
111
+ export class RgPlanningError extends Error {
112
+ constructor(message: string) {
113
+ super(message);
114
+ this.name = "RgPlanningError";
115
+ }
116
+ }
117
+
118
+ export const RG_DEFAULT_LIMIT = 50;
119
+ export const RG_MAX_LIMIT = 1000;
120
+
121
+ export function defaultRgCommand(): readonly string[] {
122
+ return ["rg"];
123
+ }
124
+
125
+ /** Base-tool availability probe (diagnose / provider resolver): true when a
126
+ * ripgrep executable starts. */
127
+ export function rgExecutableAvailable(): boolean {
128
+ const result = spawnSync("rg", ["--version"], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
129
+ return !result.error;
130
+ }
131
+
132
+ export function rgTimeoutMs(): number {
133
+ const raw = process.env.UKP_RG_TIMEOUT_MS;
134
+ if (raw === undefined || raw.length === 0) return 60_000;
135
+ const parsed = Number(raw);
136
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 60_000;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Passthrough validation (ADR-RG-002): allowlist + scope protection.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /** Value flags consume the next token (or an attached value: `-C2`). */
144
+ const PASSTHROUGH_VALUE_FLAGS = new Set(["-A", "-B", "-C", "-m", "--max-count", "-g", "--glob", "-t", "--type", "--max-filesize"]);
145
+ const PASSTHROUGH_BOOL_FLAGS = new Set(["-i", "--ignore-case", "-S", "--smart-case", "-s", "--case-sensitive", "-w", "--word-regexp", "-F", "--fixed-strings", "-v", "--invert-match", "-U", "--multiline", "--no-ignore", "--hidden", "--no-messages", "--column", "--no-heading"]);
146
+
147
+ /** Validates passthrough args: allowlist membership + scope protection.
148
+ * Throws `RgUsageError` naming the rejected token and the reason; returns
149
+ * the validated args unchanged. Exported so the parse layer can fail as
150
+ * exit 2 before any endpoint work starts; the capability re-validates. */
151
+ export function validateRgPassthrough(args: readonly string[]): readonly string[] {
152
+ for (let index = 0; index < args.length; index++) {
153
+ const token = args[index]!;
154
+ if (!token.startsWith("-")) {
155
+ throw new RgUsageError(
156
+ `passthrough argument '${token}' is not allowed: rg targets the Service folder — no path operands (use the endpoint selector for scope)`,
157
+ );
158
+ }
159
+ if (PASSTHROUGH_BOOL_FLAGS.has(token)) continue;
160
+ if (PASSTHROUGH_VALUE_FLAGS.has(token)) {
161
+ if (index + 1 >= args.length) {
162
+ throw new RgUsageError(`passthrough flag '${token}' requires a value`);
163
+ }
164
+ index += 1; // consume the value token
165
+ continue;
166
+ }
167
+ if (/^-[ABCm]=/.test(token)) continue; // long-form value on a short flag
168
+ if (/^-[ABCm].+$/.test(token)) continue; // attached value (-C2)
169
+ if (/^--[\w-]+=/.test(token)) {
170
+ const longFlag = token.slice(0, token.indexOf("="));
171
+ if (PASSTHROUGH_VALUE_FLAGS.has(longFlag) || PASSTHROUGH_BOOL_FLAGS.has(longFlag)) continue;
172
+ }
173
+ throw new RgUsageError(
174
+ `passthrough flag '${token}' is not on the rg allowlist (rejected: path operands, output-changing flags such as --json/-r, config/pre filters; the frozen flag set covers the common surface)`,
175
+ );
176
+ }
177
+ return args;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Execution
182
+ // ---------------------------------------------------------------------------
183
+
184
+ interface PlannedEndpoint {
185
+ name: string;
186
+ folder: string;
187
+ }
188
+
189
+ function planRg(parsed: ParsedRg, context: RgContext): { plan: PlannedEndpoint[]; warnings: string[] } {
190
+ const registry = readRegistry(context.registryPath);
191
+ const scope = resolveScope({
192
+ currentDirectory: context.currentDirectory,
193
+ registry,
194
+ explicitEndpoints: parsed.options.explicitEndpoints,
195
+ global: parsed.options.global,
196
+ });
197
+ const warnings = [...parsed.warnings, ...scope.warnings];
198
+ const plan: PlannedEndpoint[] = [];
199
+ for (const binding of scope.bindings) {
200
+ // Remote bindings are split off to the remote transport by the surface
201
+ // adapter before this point (ukp_remote W6); reaching here with one
202
+ // means a programmatic caller — localPathOf refuses it with a
203
+ // classified error.
204
+ const service = loadManifest(localPathOf(binding));
205
+ if (service.effectiveName !== binding.name) {
206
+ throw new RgPlanningError(
207
+ `endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
208
+ );
209
+ }
210
+ // External-tool base tier (ADR-RG-003): rg is effectively present on
211
+ // every registered local Service; the declaration only overrides config.
212
+ resolveExternalToolCapability(service.manifest, "rg");
213
+ plan.push({ name: binding.name, folder: service.folder });
214
+ }
215
+ return { plan, warnings };
216
+ }
217
+
218
+ function encodeUkpUriSegment(segment: string): string {
219
+ return segment.replace(/[%#? ]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
220
+ }
221
+
222
+ /** rg reports paths relative to its cwd (the Service folder); normalize
223
+ * separators and shape the durable slot key. */
224
+ function toUkpUri(endpointName: string, path: string): string {
225
+ const rel = path.replace(/\\/g, "/");
226
+ return `ukp://${endpointName}/${rel.split("/").map(encodeUkpUriSegment).join("/")}`;
227
+ }
228
+
229
+ /** ADR 0019 emission rule (ADR 0023 / D-073, spec §3): the URI is a slot
230
+ * promise, so it is emitted only when the match path safely resolves inside
231
+ * the Service folder — resolved containment, same stance as search's
232
+ * provider-location mapping. A miss silently drops the `ukp_uri` field
233
+ * (the match and its `path` stay): rg output is tool output, not user
234
+ * error. Output-side filtering is the second line of defense; the
235
+ * passthrough allowlist (no path operands) is the first. */
236
+ function ukpUriIfInside(endpointFolder: string, endpointName: string, path: string): string | undefined {
237
+ const absolute = resolve(endpointFolder, ...path.replace(/\\/g, "/").split("/"));
238
+ let folderReal: string;
239
+ let targetReal: string;
240
+ try {
241
+ folderReal = realpathSync(endpointFolder);
242
+ targetReal = realpathSync(absolute);
243
+ } catch {
244
+ return undefined;
245
+ }
246
+ if (!isInsideRealRoot(folderReal, targetReal)) return undefined;
247
+ return toUkpUri(endpointName, path);
248
+ }
249
+
250
+ interface RgJsonEvent {
251
+ type?: string;
252
+ data?: {
253
+ path?: { text?: string };
254
+ line_number?: number;
255
+ lines?: { text?: string };
256
+ };
257
+ }
258
+
259
+ function rgToolArgs(parsed: ParsedRg): string[] {
260
+ const args: string[] = ["--no-heading", "--no-messages"];
261
+ if (parsed.options.count) {
262
+ args.push("--count");
263
+ } else {
264
+ args.push("--json");
265
+ }
266
+ if (parsed.options.ignoreCase) args.push("-i");
267
+ if (parsed.options.glob !== undefined) args.push("--glob", parsed.options.glob);
268
+ if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
269
+ args.push(...parsed.options.passthrough);
270
+ args.push("-e", parsed.request.query);
271
+ return args;
272
+ }
273
+
274
+ /** Runs the rg capability and returns the structured outcome. Scope and
275
+ * planning failures throw typed errors (`RgUsageError`, `RgPlanningError`,
276
+ * `ScopeError`, `ManifestError`) for the surface adapter to map. */
277
+ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
278
+ validateRgPassthrough(parsed.options.passthrough);
279
+ const { plan, warnings } = planRg(parsed, context);
280
+ const command = context.rgCommand ?? defaultRgCommand();
281
+ const endpoints: RgEndpointOutcome[] = [];
282
+ let succeeded = false;
283
+ let failed = false;
284
+ let interrupted = false;
285
+
286
+ for (const endpoint of plan) {
287
+ const outcome: RgEndpointOutcome = { name: endpoint.name, provider: EXTERNAL_PROVIDER };
288
+ endpoints.push(outcome);
289
+ if (interrupted) {
290
+ outcome.status = "cancelled";
291
+ continue;
292
+ }
293
+
294
+ // Explicit search root: an implicit-cwd search is environment-sensitive
295
+ // (first Linux CI run: rg 14.1.1 walked zero bytes with no path operand
296
+ // while an explicit "." matched). "." keeps rg's rendered paths
297
+ // endpoint-relative; the "./" prefix is stripped at both intakes below.
298
+ const result = spawnSync(command[0]!, [...command.slice(1), ...rgToolArgs(parsed), "."], {
299
+ cwd: endpoint.folder,
300
+ encoding: "utf8",
301
+ windowsHide: true,
302
+ maxBuffer: 64 * 1024 * 1024,
303
+ timeout: rgTimeoutMs(),
304
+ });
305
+
306
+ if (result.error) {
307
+ // ADR-RG-003/R-7: a missing base tool is a degradation, not a fault —
308
+ // skipped + warning, the run continues with other endpoints.
309
+ warnings.push(`endpoint '${endpoint.name}' rg unavailable: rg executable is not available`);
310
+ outcome.status = "skipped";
311
+ outcome.message = "rg executable is not available";
312
+ continue;
313
+ }
314
+ if (result.signal === "SIGTERM") {
315
+ failed = true;
316
+ const message = `endpoint '${endpoint.name}' rg timed out after ${rgTimeoutMs() / 1000}s (set UKP_RG_TIMEOUT_MS to adjust)`;
317
+ warnings.push(message);
318
+ outcome.status = "failed";
319
+ outcome.message = `rg timed out after ${rgTimeoutMs() / 1000}s`;
320
+ continue;
321
+ }
322
+ if (result.signal === "SIGINT" || result.status === 130) {
323
+ warnings.push(`endpoint '${endpoint.name}' rg cancelled`);
324
+ outcome.status = "interrupted";
325
+ interrupted = true;
326
+ continue;
327
+ }
328
+ if (result.status !== 0 && result.status !== 1) {
329
+ // rg exits 1 for "no matches" (a result, not a failure); anything else
330
+ // is a tool failure.
331
+ failed = true;
332
+ const detail = (result.stderr ?? "").trim().split(/\r?\n/).find((line) => line.length > 0);
333
+ warnings.push(`endpoint '${endpoint.name}' rg failed${detail ? `: ${detail}` : ""}`);
334
+ outcome.status = "failed";
335
+ outcome.message = detail ?? `rg exited with status ${result.status}`;
336
+ continue;
337
+ }
338
+
339
+ if (parsed.options.count) {
340
+ const counts: RgCountEntry[] = [];
341
+ for (const line of (result.stdout ?? "").split(/\r?\n/)) {
342
+ if (line.length === 0) continue;
343
+ const separator = line.lastIndexOf(":");
344
+ if (separator <= 0) continue;
345
+ const count = Number(line.slice(separator + 1));
346
+ if (!Number.isSafeInteger(count)) continue;
347
+ counts.push({ path: line.slice(0, separator).replace(/\\/g, "/").replace(/^\.\//, ""), count });
348
+ }
349
+ if (counts.length === 0) {
350
+ succeeded = true; // no matches is a successful result (D-036 stance)
351
+ outcome.status = "no_matches";
352
+ } else {
353
+ succeeded = true;
354
+ outcome.status = "succeeded";
355
+ outcome.counts = counts;
356
+ }
357
+ continue;
358
+ }
359
+
360
+ const matches: RgMatch[] = [];
361
+ let truncated = false;
362
+ for (const line of (result.stdout ?? "").split(/\r?\n/)) {
363
+ if (line.length === 0) continue;
364
+ let event: RgJsonEvent;
365
+ try {
366
+ event = JSON.parse(line) as RgJsonEvent;
367
+ } catch {
368
+ continue;
369
+ }
370
+ if (event.type !== "match") continue;
371
+ if (matches.length >= parsed.request.limit) {
372
+ truncated = true;
373
+ break;
374
+ }
375
+ const path = event.data?.path?.text;
376
+ if (typeof path !== "string" || path.length === 0) continue;
377
+ const entry: RgMatch = { path: path.replace(/\\/g, "/").replace(/^\.\//, "") };
378
+ const lineNumber = event.data?.line_number;
379
+ if (Number.isSafeInteger(lineNumber) && (lineNumber as number) > 0) entry.line = lineNumber;
380
+ const text = event.data?.lines?.text;
381
+ if (typeof text === "string") {
382
+ const firstLine = text.split(/\r?\n/)[0] ?? "";
383
+ if (firstLine.length > 0) entry.text = firstLine.length > 240 ? `${firstLine.slice(0, 240)}...` : firstLine;
384
+ }
385
+ const uri = ukpUriIfInside(endpoint.folder, endpoint.name, entry.path);
386
+ if (uri !== undefined) entry.ukp_uri = uri;
387
+ matches.push(entry);
388
+ }
389
+ if (matches.length === 0 && !truncated) {
390
+ // No matches is a successful result (search precedent D-036: a
391
+ // no-match endpoint never fails the run).
392
+ succeeded = true;
393
+ outcome.status = "no_matches";
394
+ } else {
395
+ succeeded = true;
396
+ outcome.status = "succeeded";
397
+ outcome.matches = matches;
398
+ if (truncated) outcome.truncated = true;
399
+ }
400
+ }
401
+
402
+ return {
403
+ query: parsed.request.query,
404
+ limit: parsed.request.limit,
405
+ endpoints,
406
+ warnings,
407
+ aggregate: interrupted
408
+ ? "cancelled"
409
+ : failed
410
+ ? "failed"
411
+ : succeeded
412
+ ? "succeeded"
413
+ : "no-success",
414
+ };
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Presentation (ADR 0021 two-stage form, surface-neutral view).
419
+ // ---------------------------------------------------------------------------
420
+
421
+ export interface RgEnvelope {
422
+ schema: "ukp.rg.v1";
423
+ command: "rg";
424
+ capability: "rg";
425
+ query: string;
426
+ limit: number;
427
+ count_mode: boolean;
428
+ endpoints: Array<{
429
+ name: string;
430
+ provider: string;
431
+ status: string;
432
+ match_count?: number;
433
+ truncated?: boolean;
434
+ message?: string;
435
+ matches?: RgMatch[];
436
+ counts?: RgCountEntry[];
437
+ }>;
438
+ warnings: string[];
439
+ }
440
+
441
+ export function projectRgEnvelope(result: RgResult, countMode: boolean): RgEnvelope {
442
+ return {
443
+ schema: "ukp.rg.v1",
444
+ command: "rg",
445
+ capability: "rg",
446
+ query: result.query,
447
+ limit: result.limit,
448
+ count_mode: countMode,
449
+ endpoints: result.endpoints.map((endpoint) => ({
450
+ name: endpoint.name,
451
+ provider: endpoint.provider,
452
+ status: endpoint.status ?? "failed",
453
+ ...(endpoint.matches !== undefined ? { match_count: endpoint.matches.length, matches: endpoint.matches } : {}),
454
+ ...(endpoint.counts !== undefined ? { counts: endpoint.counts } : {}),
455
+ ...(endpoint.truncated !== undefined ? { truncated: endpoint.truncated } : {}),
456
+ ...(endpoint.message !== undefined ? { message: endpoint.message } : {}),
457
+ })),
458
+ warnings: result.warnings,
459
+ };
460
+ }
461
+
462
+ export interface RgHumanView {
463
+ body: string;
464
+ diagnostics: string;
465
+ }
466
+
467
+ export function renderRgHuman(result: RgResult): RgHumanView {
468
+ const lines: string[] = [];
469
+ for (const endpoint of result.endpoints) {
470
+ if (endpoint.status === "skipped" || endpoint.status === "cancelled") continue;
471
+ if (lines.length > 0) lines.push("");
472
+ lines.push(`== ${endpoint.name} ==`);
473
+ if (endpoint.status === "interrupted") break;
474
+ if (endpoint.status === "failed") {
475
+ lines.push(`status: failed${endpoint.message ? `: ${endpoint.message}` : ""}`);
476
+ continue;
477
+ }
478
+ if (endpoint.status === "no_matches") {
479
+ lines.push("(no matches)");
480
+ continue;
481
+ }
482
+ if (endpoint.counts !== undefined) {
483
+ for (const entry of endpoint.counts) lines.push(`${entry.path}: ${entry.count}`);
484
+ if (endpoint.truncated) lines.push("(truncated at the result limit)");
485
+ continue;
486
+ }
487
+ let unitIndex = 1;
488
+ for (const match of endpoint.matches ?? []) {
489
+ if (unitIndex > 1) lines.push("");
490
+ const identity = match.line !== undefined ? `${match.path}:${match.line}` : match.path;
491
+ lines.push(`${unitIndex}. ${identity}`);
492
+ if (match.text !== undefined) lines.push(` ${match.text.replace(/\n/g, "\n ")}`);
493
+ if (match.ukp_uri !== undefined) {
494
+ const fragment = match.line !== undefined ? `#L${match.line}` : "";
495
+ lines.push(` uri: ${match.ukp_uri}${fragment}`);
496
+ }
497
+ unitIndex += 1;
498
+ }
499
+ if (endpoint.truncated) lines.push(`(truncated at the result limit ${result.limit}; raise --limit)`);
500
+ }
501
+ return {
502
+ body: lines.length > 0 ? `${lines.join("\n")}\n` : "",
503
+ diagnostics: result.warnings.length > 0 ? `${result.warnings.join("\n")}\n` : "",
504
+ };
505
+ }