@ian-pascoe/pi-dap 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.
@@ -0,0 +1,406 @@
1
+ import type { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
+ import { Value } from "typebox/value";
4
+
5
+ const DEFAULT_DAP_TIMEOUTS = {
6
+ executionMs: 30_000,
7
+ requestMs: 10_000,
8
+ shutdownMs: 5_000,
9
+ startupMs: 10_000,
10
+ } as const;
11
+
12
+ const NonEmptyStringSchema = Type.String({ minLength: 1 });
13
+ const PositiveMillisecondsSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
14
+ const JsonValueSchema = Type.Any();
15
+ const JsonObjectSchema = Type.Record(Type.String(), JsonValueSchema);
16
+ const EnvironmentSchema = Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()]));
17
+ const DapAdapterDefinitionSchema = Type.Object(
18
+ {
19
+ args: Type.Optional(Type.Array(Type.String())),
20
+ command: NonEmptyStringSchema,
21
+ environment: Type.Optional(EnvironmentSchema),
22
+ transport: JsonValueSchema,
23
+ },
24
+ { additionalProperties: false },
25
+ );
26
+ const DapTcpTransportSchema = Type.Object(
27
+ {
28
+ host: Type.Optional(NonEmptyStringSchema),
29
+ port: Type.Optional(Type.Integer({ minimum: 0, maximum: 65_535 })),
30
+ type: Type.Literal("tcp"),
31
+ },
32
+ { additionalProperties: false },
33
+ );
34
+ const DapLaunchProfileSchema = Type.Object(
35
+ {
36
+ adapter: NonEmptyStringSchema,
37
+ arguments: JsonObjectSchema,
38
+ },
39
+ { additionalProperties: false },
40
+ );
41
+ const SettingsDocumentSchema = Type.Object({ dap: Type.Optional(JsonValueSchema) });
42
+
43
+ type JsonObject = { readonly [key: string]: JsonValue };
44
+ type JsonValue = null | boolean | number | string | readonly JsonValue[] | JsonObject;
45
+ type DapAdapterDefinitionWire = Static<typeof DapAdapterDefinitionSchema>;
46
+ type DapLaunchProfileWire = Static<typeof DapLaunchProfileSchema>;
47
+ type DapTimeoutName = keyof typeof DEFAULT_DAP_TIMEOUTS;
48
+ type DapTimeoutOverrides = Partial<Record<DapTimeoutName, number>>;
49
+ type SettingsScope = "global" | "project";
50
+
51
+ const DAP_TIMEOUT_NAMES: readonly DapTimeoutName[] = [
52
+ "executionMs",
53
+ "requestMs",
54
+ "shutdownMs",
55
+ "startupMs",
56
+ ];
57
+
58
+ /** Describes the configured stdio or TCP connection used by one Debug Adapter. */
59
+ export type DapAdapterTransport =
60
+ | { readonly type: "stdio" }
61
+ | { readonly host: string; readonly port: number; readonly type: "tcp" };
62
+
63
+ /** Contains the parsed process and transport values for one configured Debug Adapter. */
64
+ export interface DapAdapterDefinition {
65
+ readonly args: readonly string[];
66
+ readonly command: string;
67
+ /** Environment overrides applied when the Debug Adapter starts; null removes an inherited key. */
68
+ readonly environment: Readonly<Record<string, string | null>>;
69
+ readonly id: string;
70
+ readonly transport: DapAdapterTransport;
71
+ }
72
+
73
+ /** Contains opaque launch arguments associated with one configured Debug Adapter. */
74
+ export interface DapLaunchProfile {
75
+ readonly adapterId: string;
76
+ readonly arguments: JsonObject;
77
+ readonly id: string;
78
+ }
79
+
80
+ /** Contains timeout budgets in milliseconds for Debug Adapter lifecycle operations. */
81
+ export interface DapTimeouts {
82
+ readonly executionMs: number;
83
+ readonly requestMs: number;
84
+ readonly shutdownMs: number;
85
+ readonly startupMs: number;
86
+ }
87
+
88
+ /** Reports resolved trusted DAP configuration while quarantining invalid map entries. */
89
+ export interface ResolvedDapSettings {
90
+ readonly adapters: ReadonlyMap<string, DapAdapterDefinition>;
91
+ readonly profiles: ReadonlyMap<string, DapLaunchProfile>;
92
+ readonly timeouts: DapTimeouts;
93
+ readonly warnings: readonly string[];
94
+ }
95
+
96
+ /** Reads Pi's already trust-filtered global and project settings documents. */
97
+ export interface DapSettingsReader {
98
+ getGlobalSettings(): DapSettingsDocumentInput;
99
+ getProjectSettings(): DapSettingsDocumentInput;
100
+ }
101
+
102
+ /** Minimal Pi settings document input carrying the extension-owned optional `dap` value. */
103
+ type PiSettingsDocument = ReturnType<SettingsManager["getGlobalSettings"]>;
104
+
105
+ /** Pi's core settings type or a test boundary document carrying the extension-owned `dap` value. */
106
+ export type DapSettingsDocumentInput = PiSettingsDocument | { readonly dap?: JsonValue };
107
+
108
+ type ParsedDapAdapter =
109
+ | { readonly kind: "excluded" }
110
+ | {
111
+ readonly kind: "valid";
112
+ readonly scope: SettingsScope;
113
+ readonly value: DapAdapterDefinitionWire;
114
+ readonly transport: DapAdapterTransport;
115
+ };
116
+ type ParsedDapProfile =
117
+ | { readonly kind: "excluded" }
118
+ | {
119
+ readonly kind: "valid";
120
+ readonly scope: SettingsScope;
121
+ readonly value: DapLaunchProfileWire;
122
+ };
123
+
124
+ interface ParsedDapLayer {
125
+ readonly adapters: ReadonlyMap<string, ParsedDapAdapter>;
126
+ readonly profiles: ReadonlyMap<string, ParsedDapProfile>;
127
+ readonly timeouts: DapTimeoutOverrides;
128
+ readonly warnings: readonly string[];
129
+ }
130
+
131
+ interface ParsedDapAdapterTransport {
132
+ readonly transport?: DapAdapterTransport;
133
+ readonly warning?: string;
134
+ }
135
+
136
+ function isJsonObject(value: JsonValue): value is JsonObject {
137
+ return Value.Check(JsonObjectSchema, value);
138
+ }
139
+
140
+ function schemaValidationWarning(
141
+ schema:
142
+ | typeof DapAdapterDefinitionSchema
143
+ | typeof DapTcpTransportSchema
144
+ | typeof DapLaunchProfileSchema
145
+ | typeof PositiveMillisecondsSchema,
146
+ value: JsonValue,
147
+ prefix: string,
148
+ ): string {
149
+ const error = Value.Errors(schema, value)[0];
150
+ const path = error?.instancePath.replaceAll("/", ".") ?? "";
151
+ const field =
152
+ error?.keyword === "additionalProperties"
153
+ ? error.params.additionalProperties[0]
154
+ : error?.keyword === "required"
155
+ ? error.params.requiredProperties[0]
156
+ : undefined;
157
+ const fieldSuffix = field === undefined ? "" : `.${String(field)}`;
158
+ return `${prefix}${path}${fieldSuffix}: ${error?.message ?? "invalid settings"}`;
159
+ }
160
+
161
+ function parseDapAdapterTransport(transport: JsonValue, path: string): ParsedDapAdapterTransport {
162
+ if (transport === "stdio") return { transport: { type: "stdio" } };
163
+ if (!Value.Check(DapTcpTransportSchema, transport)) {
164
+ return {
165
+ warning: schemaValidationWarning(DapTcpTransportSchema, transport, `${path}.transport`),
166
+ };
167
+ }
168
+ return {
169
+ transport: {
170
+ host: transport.host ?? "127.0.0.1",
171
+ port: transport.port ?? 0,
172
+ type: "tcp",
173
+ },
174
+ };
175
+ }
176
+
177
+ function parseDapAdapters(
178
+ value: JsonValue | undefined,
179
+ scope: SettingsScope,
180
+ ): Pick<ParsedDapLayer, "adapters" | "warnings"> {
181
+ const adapters = new Map<string, ParsedDapAdapter>();
182
+ const warnings: string[] = [];
183
+ if (value === undefined) return { adapters, warnings };
184
+ if (!isJsonObject(value)) {
185
+ return { adapters, warnings: [`${scope} dap.adapters: expected a JSON object`] };
186
+ }
187
+
188
+ for (const [id, adapter] of Object.entries(value)) {
189
+ const path = `${scope} dap.adapters.${id}`;
190
+ if (!Value.Check(NonEmptyStringSchema, id)) {
191
+ warnings.push(`${scope} dap.adapters: Adapter Definition ID must be a non-empty string`);
192
+ continue;
193
+ }
194
+ if (adapter === null) {
195
+ adapters.set(id, { kind: "excluded" });
196
+ continue;
197
+ }
198
+ if (!Value.Check(DapAdapterDefinitionSchema, adapter)) {
199
+ warnings.push(schemaValidationWarning(DapAdapterDefinitionSchema, adapter, path));
200
+ adapters.set(id, { kind: "excluded" });
201
+ continue;
202
+ }
203
+ const parsedTransport = parseDapAdapterTransport(adapter.transport, path);
204
+ if (parsedTransport.transport === undefined) {
205
+ warnings.push(parsedTransport.warning ?? `${path}.transport: invalid transport`);
206
+ adapters.set(id, { kind: "excluded" });
207
+ continue;
208
+ }
209
+ if (
210
+ parsedTransport.transport.type === "stdio" &&
211
+ (adapter.args ?? []).some((argument) => argument.includes("$PORT"))
212
+ ) {
213
+ warnings.push(`${path}.args: $PORT requires TCP transport`);
214
+ adapters.set(id, { kind: "excluded" });
215
+ continue;
216
+ }
217
+ adapters.set(id, {
218
+ kind: "valid",
219
+ scope,
220
+ transport: parsedTransport.transport,
221
+ value: adapter,
222
+ });
223
+ }
224
+ return { adapters, warnings };
225
+ }
226
+
227
+ function parseDapProfiles(
228
+ value: JsonValue | undefined,
229
+ scope: SettingsScope,
230
+ ): Pick<ParsedDapLayer, "profiles" | "warnings"> {
231
+ const profiles = new Map<string, ParsedDapProfile>();
232
+ const warnings: string[] = [];
233
+ if (value === undefined) return { profiles, warnings };
234
+ if (!isJsonObject(value)) {
235
+ return { profiles, warnings: [`${scope} dap.profiles: expected a JSON object`] };
236
+ }
237
+
238
+ for (const [id, profile] of Object.entries(value)) {
239
+ const path = `${scope} dap.profiles.${id}`;
240
+ if (!Value.Check(NonEmptyStringSchema, id)) {
241
+ warnings.push(`${scope} dap.profiles: Launch Profile ID must be a non-empty string`);
242
+ continue;
243
+ }
244
+ if (profile === null) {
245
+ profiles.set(id, { kind: "excluded" });
246
+ continue;
247
+ }
248
+ if (!Value.Check(DapLaunchProfileSchema, profile)) {
249
+ warnings.push(schemaValidationWarning(DapLaunchProfileSchema, profile, path));
250
+ profiles.set(id, { kind: "excluded" });
251
+ continue;
252
+ }
253
+ profiles.set(id, { kind: "valid", scope, value: profile });
254
+ }
255
+ return { profiles, warnings };
256
+ }
257
+
258
+ function isDapTimeoutName(name: string): name is DapTimeoutName {
259
+ return DAP_TIMEOUT_NAMES.some((timeoutName) => timeoutName === name);
260
+ }
261
+
262
+ function parseDapTimeouts(
263
+ value: JsonValue | undefined,
264
+ scope: SettingsScope,
265
+ ): Pick<ParsedDapLayer, "timeouts" | "warnings"> {
266
+ const timeouts: DapTimeoutOverrides = {};
267
+ const warnings: string[] = [];
268
+ if (value === undefined) return { timeouts, warnings };
269
+ if (!isJsonObject(value)) {
270
+ return { timeouts, warnings: [`${scope} dap.timeouts: expected a JSON object`] };
271
+ }
272
+ for (const [name, timeout] of Object.entries(value)) {
273
+ if (!isDapTimeoutName(name)) {
274
+ warnings.push(`${scope} dap.timeouts.${name}: unknown field`);
275
+ continue;
276
+ }
277
+ if (!Value.Check(PositiveMillisecondsSchema, timeout)) {
278
+ warnings.push(
279
+ schemaValidationWarning(
280
+ PositiveMillisecondsSchema,
281
+ timeout,
282
+ `${scope} dap.timeouts.${name}`,
283
+ ),
284
+ );
285
+ continue;
286
+ }
287
+ timeouts[name] = timeout;
288
+ }
289
+ return { timeouts, warnings };
290
+ }
291
+
292
+ function readDapLayer(settings: DapSettingsDocumentInput, scope: SettingsScope): ParsedDapLayer {
293
+ if (!Value.Check(SettingsDocumentSchema, settings)) {
294
+ return {
295
+ adapters: new Map(),
296
+ profiles: new Map(),
297
+ timeouts: {},
298
+ warnings: [`${scope} settings: expected a JSON object`],
299
+ };
300
+ }
301
+ if (settings.dap === undefined) {
302
+ return { adapters: new Map(), profiles: new Map(), timeouts: {}, warnings: [] };
303
+ }
304
+ if (!isJsonObject(settings.dap)) {
305
+ return {
306
+ adapters: new Map(),
307
+ profiles: new Map(),
308
+ timeouts: {},
309
+ warnings: [`${scope} dap: expected a JSON object`],
310
+ };
311
+ }
312
+
313
+ const unknownWarnings = Object.keys(settings.dap)
314
+ .filter((field) => field !== "adapters" && field !== "profiles" && field !== "timeouts")
315
+ .map((field) => `${scope} dap.${field}: unknown field`);
316
+ const parsedAdapters = parseDapAdapters(settings.dap.adapters, scope);
317
+ const parsedProfiles = parseDapProfiles(settings.dap.profiles, scope);
318
+ const parsedTimeouts = parseDapTimeouts(settings.dap.timeouts, scope);
319
+ return {
320
+ adapters: parsedAdapters.adapters,
321
+ profiles: parsedProfiles.profiles,
322
+ timeouts: parsedTimeouts.timeouts,
323
+ warnings: [
324
+ ...unknownWarnings,
325
+ ...parsedAdapters.warnings,
326
+ ...parsedProfiles.warnings,
327
+ ...parsedTimeouts.warnings,
328
+ ],
329
+ };
330
+ }
331
+
332
+ function mergeDapAdapters(
333
+ globalEntries: ReadonlyMap<string, ParsedDapAdapter>,
334
+ projectEntries: ReadonlyMap<string, ParsedDapAdapter>,
335
+ ): ReadonlyMap<string, Extract<ParsedDapAdapter, { readonly kind: "valid" }>> {
336
+ const entries = new Map<string, Extract<ParsedDapAdapter, { readonly kind: "valid" }>>();
337
+ for (const [id, entry] of globalEntries) {
338
+ if (entry.kind === "valid") entries.set(id, entry);
339
+ }
340
+ for (const [id, entry] of projectEntries) {
341
+ entries.delete(id);
342
+ if (entry.kind === "valid") entries.set(id, entry);
343
+ }
344
+ return new Map([...entries].sort(([left], [right]) => left.localeCompare(right)));
345
+ }
346
+
347
+ function mergeDapProfiles(
348
+ globalEntries: ReadonlyMap<string, ParsedDapProfile>,
349
+ projectEntries: ReadonlyMap<string, ParsedDapProfile>,
350
+ ): ReadonlyMap<string, Extract<ParsedDapProfile, { readonly kind: "valid" }>> {
351
+ const entries = new Map<string, Extract<ParsedDapProfile, { readonly kind: "valid" }>>();
352
+ for (const [id, entry] of globalEntries) {
353
+ if (entry.kind === "valid") entries.set(id, entry);
354
+ }
355
+ for (const [id, entry] of projectEntries) {
356
+ entries.delete(id);
357
+ if (entry.kind === "valid") entries.set(id, entry);
358
+ }
359
+ return new Map([...entries].sort(([left], [right]) => left.localeCompare(right)));
360
+ }
361
+
362
+ function resolveDapAdapter(
363
+ id: string,
364
+ adapter: Extract<ParsedDapAdapter, { readonly kind: "valid" }>,
365
+ ): DapAdapterDefinition {
366
+ return {
367
+ args: [...(adapter.value.args ?? [])],
368
+ command: adapter.value.command,
369
+ environment: { ...adapter.value.environment },
370
+ id,
371
+ transport: adapter.transport,
372
+ };
373
+ }
374
+
375
+ /** Resolve global and trusted-project DAP settings, preserving valid entries around warnings. */
376
+ export function resolveDapSettings(reader: DapSettingsReader): ResolvedDapSettings {
377
+ const globalLayer = readDapLayer(reader.getGlobalSettings(), "global");
378
+ const projectLayer = readDapLayer(reader.getProjectSettings(), "project");
379
+ const mergedAdapters = mergeDapAdapters(globalLayer.adapters, projectLayer.adapters);
380
+ const mergedProfiles = mergeDapProfiles(globalLayer.profiles, projectLayer.profiles);
381
+ const adapters = new Map(
382
+ [...mergedAdapters].map(([id, adapter]) => [id, resolveDapAdapter(id, adapter)]),
383
+ );
384
+ const profiles = new Map<string, DapLaunchProfile>();
385
+ const referenceWarnings: string[] = [];
386
+ for (const [id, profile] of mergedProfiles) {
387
+ if (!adapters.has(profile.value.adapter)) {
388
+ referenceWarnings.push(
389
+ `${profile.scope} dap.profiles.${id}.adapter: adapter "${profile.value.adapter}" is not configured`,
390
+ );
391
+ continue;
392
+ }
393
+ profiles.set(id, {
394
+ adapterId: profile.value.adapter,
395
+ arguments: structuredClone(profile.value.arguments),
396
+ id,
397
+ });
398
+ }
399
+
400
+ return {
401
+ adapters,
402
+ profiles,
403
+ timeouts: Object.assign({}, DEFAULT_DAP_TIMEOUTS, globalLayer.timeouts, projectLayer.timeouts),
404
+ warnings: [...globalLayer.warnings, ...projectLayer.warnings, ...referenceWarnings],
405
+ };
406
+ }