@beignet/cli 0.0.40 → 0.0.42

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 (111) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +139 -17
  3. package/dist/analysis/source-index.d.ts +38 -0
  4. package/dist/analysis/source-index.d.ts.map +1 -0
  5. package/dist/analysis/source-index.js +271 -0
  6. package/dist/analysis/source-index.js.map +1 -0
  7. package/dist/analysis/workspace.d.ts +16 -0
  8. package/dist/analysis/workspace.d.ts.map +1 -0
  9. package/dist/analysis/workspace.js +134 -0
  10. package/dist/analysis/workspace.js.map +1 -0
  11. package/dist/app-map-schema.d.ts +5 -0
  12. package/dist/app-map-schema.d.ts.map +1 -0
  13. package/dist/app-map-schema.js +26 -0
  14. package/dist/app-map-schema.js.map +1 -0
  15. package/dist/app-map.d.ts +96 -0
  16. package/dist/app-map.d.ts.map +1 -0
  17. package/dist/app-map.js +1141 -0
  18. package/dist/app-map.js.map +1 -0
  19. package/dist/check.d.ts +7 -0
  20. package/dist/check.d.ts.map +1 -1
  21. package/dist/check.js +20 -5
  22. package/dist/check.js.map +1 -1
  23. package/dist/choices.d.ts +18 -0
  24. package/dist/choices.d.ts.map +1 -1
  25. package/dist/choices.js +35 -0
  26. package/dist/choices.js.map +1 -1
  27. package/dist/db.d.ts +49 -13
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +202 -21
  30. package/dist/db.js.map +1 -1
  31. package/dist/doctor-fixes.d.ts +64 -0
  32. package/dist/doctor-fixes.d.ts.map +1 -0
  33. package/dist/doctor-fixes.js +142 -0
  34. package/dist/doctor-fixes.js.map +1 -0
  35. package/dist/explain.d.ts +100 -0
  36. package/dist/explain.d.ts.map +1 -0
  37. package/dist/explain.js +606 -0
  38. package/dist/explain.js.map +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +165 -21
  41. package/dist/index.js.map +1 -1
  42. package/dist/inspect.d.ts +33 -9
  43. package/dist/inspect.d.ts.map +1 -1
  44. package/dist/inspect.js +406 -119
  45. package/dist/inspect.js.map +1 -1
  46. package/dist/lib.d.ts +10 -2
  47. package/dist/lib.d.ts.map +1 -1
  48. package/dist/lib.js +5 -2
  49. package/dist/lib.js.map +1 -1
  50. package/dist/make/shared.js +3 -3
  51. package/dist/make/shared.js.map +1 -1
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +63 -2
  54. package/dist/make.js.map +1 -1
  55. package/dist/mcp.d.ts +1 -1
  56. package/dist/mcp.d.ts.map +1 -1
  57. package/dist/mcp.js +188 -12
  58. package/dist/mcp.js.map +1 -1
  59. package/dist/preflight.d.ts.map +1 -1
  60. package/dist/preflight.js +10 -0
  61. package/dist/preflight.js.map +1 -1
  62. package/dist/provider-add.d.ts.map +1 -1
  63. package/dist/provider-add.js +167 -13
  64. package/dist/provider-add.js.map +1 -1
  65. package/dist/provider-audit.d.ts +6 -0
  66. package/dist/provider-audit.d.ts.map +1 -1
  67. package/dist/provider-audit.js +63 -15
  68. package/dist/provider-audit.js.map +1 -1
  69. package/dist/templates/agents.d.ts.map +1 -1
  70. package/dist/templates/agents.js +35 -6
  71. package/dist/templates/agents.js.map +1 -1
  72. package/dist/templates/base.d.ts.map +1 -1
  73. package/dist/templates/base.js +13 -7
  74. package/dist/templates/base.js.map +1 -1
  75. package/dist/templates/index.d.ts +1 -1
  76. package/dist/templates/index.d.ts.map +1 -1
  77. package/dist/templates/index.js +8 -1
  78. package/dist/templates/index.js.map +1 -1
  79. package/dist/templates/server.d.ts +3 -0
  80. package/dist/templates/server.d.ts.map +1 -1
  81. package/dist/templates/server.js +55 -1
  82. package/dist/templates/server.js.map +1 -1
  83. package/dist/templates/shared.d.ts +2 -1
  84. package/dist/templates/shared.d.ts.map +1 -1
  85. package/dist/templates/shared.js +8 -5
  86. package/dist/templates/shared.js.map +1 -1
  87. package/package.json +3 -2
  88. package/skills/app-structure/SKILL.md +48 -6
  89. package/src/analysis/source-index.ts +395 -0
  90. package/src/analysis/workspace.ts +180 -0
  91. package/src/app-map-schema.ts +28 -0
  92. package/src/app-map.ts +1705 -0
  93. package/src/check.ts +27 -4
  94. package/src/choices.ts +57 -0
  95. package/src/db.ts +276 -28
  96. package/src/doctor-fixes.ts +252 -0
  97. package/src/explain.ts +894 -0
  98. package/src/index.ts +237 -31
  99. package/src/inspect.ts +567 -147
  100. package/src/lib.ts +64 -1
  101. package/src/make/shared.ts +3 -3
  102. package/src/make.ts +90 -2
  103. package/src/mcp.ts +292 -12
  104. package/src/preflight.ts +14 -0
  105. package/src/provider-add.ts +211 -12
  106. package/src/provider-audit.ts +127 -22
  107. package/src/templates/agents.ts +35 -6
  108. package/src/templates/base.ts +25 -6
  109. package/src/templates/index.ts +18 -1
  110. package/src/templates/server.ts +58 -1
  111. package/src/templates/shared.ts +15 -7
package/src/explain.ts ADDED
@@ -0,0 +1,894 @@
1
+ import {
2
+ type AppMapEdge,
3
+ type AppMapNode,
4
+ type AppMapNodeKind,
5
+ type AppMapNodeStatus,
6
+ type AppMapResult,
7
+ type AppMapSource,
8
+ mapApp,
9
+ projectAppMap,
10
+ } from "./app-map.js";
11
+ import { appMapNodeKinds } from "./app-map-schema.js";
12
+
13
+ /** Targets supported by `beignet explain` and the MCP `explain` tool. */
14
+ export const explainTargetKinds = [...appMapNodeKinds, "diagnostic"] as const;
15
+
16
+ /** One supported explanation target kind. */
17
+ export type ExplainTargetKind = (typeof explainTargetKinds)[number];
18
+
19
+ /** Options for resolving one app concept into a deterministic explanation. */
20
+ export type ExplainAppOptions = {
21
+ cwd?: string;
22
+ kind: ExplainTargetKind;
23
+ target: string;
24
+ };
25
+
26
+ /** Identity and source metadata for the resolved explanation target. */
27
+ export type ExplainTarget = {
28
+ kind: ExplainTargetKind;
29
+ id: string;
30
+ name: string;
31
+ runtimeName?: string;
32
+ feature?: string;
33
+ source?: AppMapSource;
34
+ status?: AppMapNodeStatus;
35
+ details?: Record<string, unknown>;
36
+ };
37
+
38
+ /** Compact app-map node embedded in an explained relationship. */
39
+ export type ExplainRelatedNode = {
40
+ id: string;
41
+ kind: AppMapNodeKind;
42
+ name: string;
43
+ runtimeName?: string;
44
+ feature?: string;
45
+ source: AppMapSource;
46
+ status?: AppMapNodeStatus;
47
+ details?: Record<string, unknown>;
48
+ };
49
+
50
+ /** One source-backed relationship connected to the explanation scope. */
51
+ export type ExplainRelationship = {
52
+ kind: AppMapEdge["kind"];
53
+ from: ExplainRelatedNode;
54
+ to: ExplainRelatedNode;
55
+ evidence: AppMapEdge["evidence"];
56
+ details?: Record<string, unknown>;
57
+ };
58
+
59
+ /** Normalized doctor, lint, or app-map finding relevant to the target. */
60
+ export type ExplainFinding = {
61
+ source: "doctor" | "lint" | "app-map";
62
+ severity: "error" | "warning" | "hint";
63
+ code: string;
64
+ message: string;
65
+ location?: AppMapSource;
66
+ details?: Record<string, unknown>;
67
+ };
68
+
69
+ /** Source location supporting one part of the explanation. */
70
+ export type ExplainEvidence = {
71
+ source: AppMapSource;
72
+ reason: string;
73
+ };
74
+
75
+ /** Source file suggested for inspection, with the reasons it is relevant. */
76
+ export type ExplainSuggestedFile = {
77
+ file: string;
78
+ line?: number;
79
+ column?: number;
80
+ reasons: string[];
81
+ };
82
+
83
+ /** Deterministic next action derived from the mapped target and findings. */
84
+ export type ExplainSuggestedAction = {
85
+ kind: "inspect" | "address-finding" | "validate";
86
+ description: string;
87
+ command?: string;
88
+ cwd?: string;
89
+ file?: string;
90
+ };
91
+
92
+ /** Version 1 deterministic explanation of one app-map target. */
93
+ export type ExplainAppResult = {
94
+ schemaVersion: 1;
95
+ targetDir: string;
96
+ app: AppMapResult["app"];
97
+ query: {
98
+ kind: ExplainTargetKind;
99
+ target: string;
100
+ };
101
+ target: ExplainTarget;
102
+ summary: string;
103
+ scope: {
104
+ nodes: number;
105
+ relationships: number;
106
+ returnedRelationships: number;
107
+ findings: number;
108
+ truncated: boolean;
109
+ };
110
+ evidence: ExplainEvidence[];
111
+ relationships: ExplainRelationship[];
112
+ conventions: string[];
113
+ findings: ExplainFinding[];
114
+ suggestedFiles: ExplainSuggestedFile[];
115
+ suggestedActions: ExplainSuggestedAction[];
116
+ };
117
+
118
+ type ExplanationScope = {
119
+ nodes: AppMapNode[];
120
+ edges: AppMapEdge[];
121
+ findings: ExplainFinding[];
122
+ };
123
+
124
+ /** Explain one source-backed Beignet app concept without generating advice. */
125
+ export async function explainApp(
126
+ options: ExplainAppOptions,
127
+ ): Promise<ExplainAppResult> {
128
+ const targetText = options.target.trim();
129
+ if (!targetText) {
130
+ throw new Error(
131
+ `Pass a non-empty ${options.kind} target to beignet explain.`,
132
+ );
133
+ }
134
+
135
+ const appMap = await mapApp({ cwd: options.cwd, strict: true });
136
+ const scope =
137
+ options.kind === "diagnostic"
138
+ ? diagnosticScope(appMap, targetText)
139
+ : nodeScope(appMap, resolveTargetNode(appMap, options.kind, targetText));
140
+ const target =
141
+ options.kind === "diagnostic"
142
+ ? diagnosticTarget(targetText, scope.findings)
143
+ : nodeTarget(options.kind, scope.nodes[0]);
144
+ const selectedEdges = selectExplanationEdges(scope.edges, target.id);
145
+ const relationships = explainRelationships(scope.nodes, selectedEdges);
146
+ const evidenceNodes = scope.nodes.filter((node) => node.id === target.id);
147
+ const evidence = explainEvidence(
148
+ evidenceNodes,
149
+ relationships,
150
+ scope.findings,
151
+ target,
152
+ );
153
+ const suggestedFiles = suggestedFilesFromEvidence([
154
+ ...evidence,
155
+ ...relationshipNodeEvidence(relationships),
156
+ ]);
157
+
158
+ return {
159
+ schemaVersion: 1,
160
+ targetDir: appMap.targetDir,
161
+ app: appMap.app,
162
+ query: { kind: options.kind, target: targetText },
163
+ target,
164
+ summary: explainSummary(target, scope.nodes, scope.edges, scope.findings),
165
+ scope: {
166
+ nodes: scope.nodes.length,
167
+ relationships: scope.edges.length,
168
+ returnedRelationships: relationships.length,
169
+ findings: scope.findings.length,
170
+ truncated: relationships.length < scope.edges.length,
171
+ },
172
+ evidence,
173
+ relationships,
174
+ conventions: conventionsFor(options.kind),
175
+ findings: scope.findings,
176
+ suggestedFiles,
177
+ suggestedActions: suggestedActions(
178
+ target,
179
+ scope.findings,
180
+ appMap.targetDir,
181
+ ),
182
+ };
183
+ }
184
+
185
+ /** Format a concise, source-backed explanation for terminal use. */
186
+ export function formatExplain(result: ExplainAppResult): string {
187
+ const relationshipLines = result.relationships.map(
188
+ (relationship) =>
189
+ `- ${relationship.from.id} --${relationship.kind}--> ${relationship.to.id} (${formatSource(relationship.evidence)})`,
190
+ );
191
+ const findingLines = result.findings.map(
192
+ (finding) =>
193
+ `- [${finding.source}/${finding.severity}] ${finding.code}: ${finding.message}${finding.location ? ` (${formatSource(finding.location)})` : ""}`,
194
+ );
195
+ const fileLines = result.suggestedFiles.map(
196
+ (file) => `- ${formatSource(file)} — ${file.reasons.join("; ")}`,
197
+ );
198
+ const actionLines = result.suggestedActions.map((action) => {
199
+ const details = [
200
+ ...(action.cwd ? [` cwd: ${action.cwd}`] : []),
201
+ ...(action.command ? [` ${action.command}`] : []),
202
+ ];
203
+ return `- ${action.description}${details.length > 0 ? `\n${details.join("\n")}` : ""}`;
204
+ });
205
+
206
+ return [
207
+ `Explain ${result.query.kind}: ${result.target.name}`,
208
+ `App root: ${result.targetDir}`,
209
+ `Target: ${result.target.id}${result.target.status ? ` (${result.target.status})` : ""}`,
210
+ result.target.source ? `Source: ${formatSource(result.target.source)}` : "",
211
+ `Summary: ${result.summary}`,
212
+ `Scope: ${result.scope.nodes} nodes, ${result.scope.returnedRelationships} of ${result.scope.relationships} relationships, ${result.scope.findings} findings${result.scope.truncated ? " (relationship list truncated)" : ""}`,
213
+ "",
214
+ "Conventions:",
215
+ ...result.conventions.map((convention) => `- ${convention}`),
216
+ "",
217
+ "Relationships:",
218
+ ...(relationshipLines.length > 0 ? relationshipLines : ["- None found."]),
219
+ "",
220
+ "Findings:",
221
+ ...(findingLines.length > 0 ? findingLines : ["- None found."]),
222
+ "",
223
+ "Suggested files:",
224
+ ...(fileLines.length > 0 ? fileLines : ["- None found."]),
225
+ "",
226
+ "Suggested actions:",
227
+ ...actionLines,
228
+ ]
229
+ .filter((line, index, lines) => line !== "" || lines[index - 1] !== "")
230
+ .join("\n");
231
+ }
232
+
233
+ function nodeScope(
234
+ appMap: AppMapResult,
235
+ targetNode: AppMapNode,
236
+ ): ExplanationScope {
237
+ if (targetNode.kind === "feature") {
238
+ const projection = projectAppMap(appMap, { feature: targetNode.name });
239
+ return {
240
+ nodes: moveTargetFirst(projection.nodes, targetNode.id),
241
+ edges: projection.edges,
242
+ findings: allFindings(projection),
243
+ };
244
+ }
245
+
246
+ const connectedEdges = appMap.edges.filter(
247
+ (edge) => edge.from === targetNode.id || edge.to === targetNode.id,
248
+ );
249
+ const connectedIds = new Set<string>([targetNode.id]);
250
+ for (const edge of connectedEdges) {
251
+ connectedIds.add(edge.from);
252
+ connectedIds.add(edge.to);
253
+ }
254
+ const nodes = moveTargetFirst(
255
+ appMap.nodes.filter((node) => connectedIds.has(node.id)),
256
+ targetNode.id,
257
+ );
258
+ const files = scopeFiles(nodes, connectedEdges);
259
+ const terms = targetTerms(targetNode);
260
+ const findings = allFindings(appMap).filter(
261
+ (finding) =>
262
+ (finding.location && files.has(finding.location.file)) ||
263
+ terms.some((term) => normalized(finding.message).includes(term)),
264
+ );
265
+
266
+ return { nodes, edges: connectedEdges, findings };
267
+ }
268
+
269
+ function diagnosticScope(
270
+ appMap: AppMapResult,
271
+ target: string,
272
+ ): ExplanationScope {
273
+ const code = normalized(target);
274
+ const findings = allFindings(appMap).filter(
275
+ (finding) => normalized(finding.code) === code,
276
+ );
277
+ if (findings.length === 0) {
278
+ const codes = [
279
+ ...new Set(allFindings(appMap).map((finding) => finding.code)),
280
+ ]
281
+ .sort()
282
+ .slice(0, 20);
283
+ throw new Error(
284
+ `Could not explain diagnostic "${target}". ${codes.length > 0 ? `Available diagnostic codes: ${codes.join(", ")}.` : "This app has no diagnostics."}`,
285
+ );
286
+ }
287
+
288
+ const seedIds = new Set(
289
+ appMap.nodes
290
+ .filter((node) =>
291
+ findings.some((finding) => findingMatchesNode(finding, node)),
292
+ )
293
+ .map((node) => node.id),
294
+ );
295
+ const connectedEdges = appMap.edges.filter(
296
+ (edge) => seedIds.has(edge.from) || seedIds.has(edge.to),
297
+ );
298
+ const connectedIds = new Set(seedIds);
299
+ for (const edge of connectedEdges) {
300
+ connectedIds.add(edge.from);
301
+ connectedIds.add(edge.to);
302
+ }
303
+
304
+ return {
305
+ nodes: appMap.nodes.filter((node) => connectedIds.has(node.id)),
306
+ edges: connectedEdges,
307
+ findings,
308
+ };
309
+ }
310
+
311
+ function resolveTargetNode(
312
+ appMap: AppMapResult,
313
+ kind: Exclude<ExplainTargetKind, "diagnostic">,
314
+ target: string,
315
+ ): AppMapNode {
316
+ const nodeKind: AppMapNodeKind = kind;
317
+ const candidates = appMap.nodes.filter((node) => node.kind === nodeKind);
318
+ const query = normalized(target);
319
+ const idMatches = candidates.filter((node) => normalized(node.id) === query);
320
+ if (idMatches.length === 1) return idMatches[0];
321
+
322
+ let matches = candidates.filter((node) =>
323
+ aliasesForNode(node).some(
324
+ (alias) => normalized(alias) === query && normalized(node.id) !== query,
325
+ ),
326
+ );
327
+
328
+ if (kind === "provider" && matches.length === 0) {
329
+ const portIds = new Set(
330
+ appMap.nodes
331
+ .filter(
332
+ (node) =>
333
+ node.kind === "port" &&
334
+ [node.id, node.name].some((alias) => normalized(alias) === query),
335
+ )
336
+ .map((node) => node.id),
337
+ );
338
+ matches = candidates.filter((node) =>
339
+ appMap.edges.some(
340
+ (edge) =>
341
+ edge.kind === "provides" &&
342
+ edge.from === node.id &&
343
+ portIds.has(edge.to),
344
+ ),
345
+ );
346
+ const packageProviders = matches.filter(
347
+ (node) => node.details?.sourceType !== "app-binding",
348
+ );
349
+ if (packageProviders.length > 0) matches = packageProviders;
350
+ }
351
+
352
+ if (matches.length === 1) return matches[0];
353
+ if (matches.length > 1) {
354
+ throw new Error(
355
+ `The ${kind} target "${target}" is ambiguous. Matches: ${matches.map((node) => node.id).join(", ")}.`,
356
+ );
357
+ }
358
+
359
+ const available = [...new Set(candidates.map(canonicalNodeSelector))]
360
+ .sort()
361
+ .slice(0, 20);
362
+ throw new Error(
363
+ `Could not explain ${kind} "${target}". ${available.length > 0 ? `Available ${kind} targets: ${available.join(", ")}.` : `This app has no mapped ${kind} targets.`}`,
364
+ );
365
+ }
366
+
367
+ function canonicalNodeSelector(node: AppMapNode): string {
368
+ return node.runtimeName ?? node.name;
369
+ }
370
+
371
+ function aliasesForNode(node: AppMapNode): string[] {
372
+ const aliases = [node.id, node.name, node.source.file];
373
+ if (node.runtimeName) aliases.push(node.runtimeName);
374
+ if (node.source.exportName) {
375
+ aliases.push(
376
+ node.source.exportName,
377
+ `${node.source.file}#${node.source.exportName}`,
378
+ );
379
+ }
380
+ if (node.id.includes("#"))
381
+ aliases.push(node.id.slice(node.id.lastIndexOf("#") + 1));
382
+ if (node.kind === "route" && typeof node.details?.path === "string") {
383
+ aliases.push(node.details.path);
384
+ }
385
+ if (node.kind === "provider") {
386
+ aliases.push(node.name.replace(/^@beignet\//, ""));
387
+ if (typeof node.details?.displayName === "string") {
388
+ aliases.push(node.details.displayName);
389
+ }
390
+ }
391
+ return aliases;
392
+ }
393
+
394
+ function targetTerms(node: AppMapNode): string[] {
395
+ return aliasesForNode(node)
396
+ .map(normalized)
397
+ .filter((term) => term.length >= 4);
398
+ }
399
+
400
+ function diagnosticTarget(
401
+ query: string,
402
+ findings: ExplainFinding[],
403
+ ): ExplainTarget {
404
+ const code = findings[0]?.code ?? query;
405
+ return {
406
+ kind: "diagnostic",
407
+ id: `diagnostic:${code}`,
408
+ name: code,
409
+ ...(findings[0]?.location ? { source: findings[0].location } : {}),
410
+ details: {
411
+ matches: findings.length,
412
+ sources: [...new Set(findings.map((finding) => finding.source))].sort(),
413
+ },
414
+ };
415
+ }
416
+
417
+ function nodeTarget(
418
+ kind: Exclude<ExplainTargetKind, "diagnostic">,
419
+ node: AppMapNode | undefined,
420
+ ): ExplainTarget {
421
+ if (!node)
422
+ throw new Error(`Could not resolve the ${kind} explanation target.`);
423
+ return {
424
+ kind,
425
+ id: node.id,
426
+ name: node.name,
427
+ ...(node.runtimeName ? { runtimeName: node.runtimeName } : {}),
428
+ ...(node.feature ? { feature: node.feature } : {}),
429
+ source: node.source,
430
+ ...(node.status ? { status: node.status } : {}),
431
+ ...(node.details ? { details: node.details } : {}),
432
+ };
433
+ }
434
+
435
+ function explainRelationships(
436
+ nodes: AppMapNode[],
437
+ edges: AppMapEdge[],
438
+ ): ExplainRelationship[] {
439
+ const byId = new Map(nodes.map((node) => [node.id, node]));
440
+ return edges.flatMap((edge) => {
441
+ const from = byId.get(edge.from);
442
+ const to = byId.get(edge.to);
443
+ if (!from || !to) return [];
444
+ return [
445
+ {
446
+ kind: edge.kind,
447
+ from: relatedNode(from),
448
+ to: relatedNode(to),
449
+ evidence: edge.evidence,
450
+ ...(edge.details ? { details: edge.details } : {}),
451
+ },
452
+ ];
453
+ });
454
+ }
455
+
456
+ function selectExplanationEdges(
457
+ edges: AppMapEdge[],
458
+ targetId: string,
459
+ ): AppMapEdge[] {
460
+ const maximumRelationships = 80;
461
+ if (edges.length <= maximumRelationships) return edges;
462
+
463
+ return [...edges]
464
+ .sort((left, right) => {
465
+ const priority =
466
+ edgePriority(left, targetId) - edgePriority(right, targetId);
467
+ if (priority !== 0) return priority;
468
+ return `${left.kind}:${left.from}:${left.to}`.localeCompare(
469
+ `${right.kind}:${right.from}:${right.to}`,
470
+ );
471
+ })
472
+ .slice(0, maximumRelationships);
473
+ }
474
+
475
+ function edgePriority(edge: AppMapEdge, targetId: string): number {
476
+ if (edge.kind === "depends-on") return 0;
477
+ if (edge.kind === "executes" || edge.kind === "binds") return 1;
478
+ if (
479
+ [
480
+ "authorizes",
481
+ "dispatches",
482
+ "emits",
483
+ "listens-to",
484
+ "publishes",
485
+ "sends",
486
+ ].includes(edge.kind)
487
+ ) {
488
+ return 2;
489
+ }
490
+ if (edge.from === targetId || edge.to === targetId) return 3;
491
+ return 4;
492
+ }
493
+
494
+ function relationshipNodeEvidence(
495
+ relationships: ExplainRelationship[],
496
+ ): ExplainEvidence[] {
497
+ const evidence: ExplainEvidence[] = [];
498
+ for (const relationship of relationships) {
499
+ evidence.push({
500
+ source: relationship.from.source,
501
+ reason: `related ${relationship.from.kind} ${relationship.from.id}`,
502
+ });
503
+ evidence.push({
504
+ source: relationship.to.source,
505
+ reason: `related ${relationship.to.kind} ${relationship.to.id}`,
506
+ });
507
+ }
508
+ return evidence;
509
+ }
510
+
511
+ function relatedNode(node: AppMapNode): ExplainRelatedNode {
512
+ return {
513
+ id: node.id,
514
+ kind: node.kind,
515
+ name: node.name,
516
+ ...(node.runtimeName ? { runtimeName: node.runtimeName } : {}),
517
+ ...(node.feature ? { feature: node.feature } : {}),
518
+ source: node.source,
519
+ ...(node.status ? { status: node.status } : {}),
520
+ ...(node.details ? { details: node.details } : {}),
521
+ };
522
+ }
523
+
524
+ function allFindings(appMap: AppMapResult): ExplainFinding[] {
525
+ return [
526
+ ...appMap.diagnostics.doctor.map(
527
+ (diagnostic): ExplainFinding => ({
528
+ source: "doctor",
529
+ severity: diagnostic.severity,
530
+ code: diagnostic.code,
531
+ message: diagnostic.message,
532
+ ...(diagnostic.file
533
+ ? {
534
+ location: {
535
+ file: diagnostic.file,
536
+ ...(diagnostic.contract
537
+ ? { exportName: diagnostic.contract }
538
+ : {}),
539
+ },
540
+ }
541
+ : {}),
542
+ }),
543
+ ),
544
+ ...appMap.diagnostics.lint.map(
545
+ (diagnostic): ExplainFinding => ({
546
+ source: "lint",
547
+ severity: diagnostic.severity,
548
+ code: diagnostic.code,
549
+ message: diagnostic.message,
550
+ location: {
551
+ file: diagnostic.file,
552
+ ...(diagnostic.line ? { line: diagnostic.line } : {}),
553
+ ...(diagnostic.column ? { column: diagnostic.column } : {}),
554
+ },
555
+ details: { importPath: diagnostic.importPath },
556
+ }),
557
+ ),
558
+ ...appMap.unresolved.map(
559
+ (unresolved): ExplainFinding => ({
560
+ source: "app-map",
561
+ severity: "warning",
562
+ code: unresolved.code,
563
+ message: unresolved.message,
564
+ location: unresolved.source,
565
+ }),
566
+ ),
567
+ ].sort(compareFindings);
568
+ }
569
+
570
+ function findingMatchesNode(
571
+ finding: ExplainFinding,
572
+ node: AppMapNode,
573
+ ): boolean {
574
+ if (finding.location?.file === node.source.file) return true;
575
+ if (
576
+ finding.location?.exportName &&
577
+ finding.location.exportName === node.source.exportName
578
+ ) {
579
+ return true;
580
+ }
581
+ const message = normalized(finding.message);
582
+ if (
583
+ /\.(ts|tsx|mts|cts|json)$/.test(node.source.file) &&
584
+ message.includes(normalized(node.source.file))
585
+ ) {
586
+ return true;
587
+ }
588
+ return [node.runtimeName, node.source.exportName]
589
+ .filter((term): term is string => Boolean(term && term.length >= 4))
590
+ .some((term) => message.includes(normalized(term)));
591
+ }
592
+
593
+ function explainEvidence(
594
+ nodes: AppMapNode[],
595
+ relationships: ExplainRelationship[],
596
+ findings: ExplainFinding[],
597
+ target: ExplainTarget,
598
+ ): ExplainEvidence[] {
599
+ const evidence: ExplainEvidence[] = [];
600
+ for (const node of nodes) {
601
+ evidence.push({
602
+ source: node.source,
603
+ reason: `${node.kind} ${node.id}`,
604
+ });
605
+ }
606
+ for (const relationship of relationships) {
607
+ evidence.push({
608
+ source: relationship.evidence,
609
+ reason: `${relationship.from.id} ${relationship.kind} ${relationship.to.id}`,
610
+ });
611
+ }
612
+ for (const finding of findings) {
613
+ if (finding.location) {
614
+ evidence.push({
615
+ source: finding.location,
616
+ reason: `${finding.source} finding ${finding.code}`,
617
+ });
618
+ }
619
+ }
620
+ for (const source of providerRegistrationSources(target)) {
621
+ evidence.push({
622
+ source,
623
+ reason: `provider registration ${target.id}`,
624
+ });
625
+ }
626
+
627
+ const seen = new Set<string>();
628
+ return evidence.filter((item) => {
629
+ const key = `${sourceKey(item.source)}:${item.reason}`;
630
+ if (seen.has(key)) return false;
631
+ seen.add(key);
632
+ return true;
633
+ });
634
+ }
635
+
636
+ function suggestedFilesFromEvidence(
637
+ evidence: ExplainEvidence[],
638
+ ): ExplainSuggestedFile[] {
639
+ const files = new Map<string, ExplainSuggestedFile>();
640
+ for (const item of evidence) {
641
+ const key = item.source.file;
642
+ const existing = files.get(key);
643
+ if (existing) {
644
+ if (
645
+ existing.line !== item.source.line ||
646
+ existing.column !== item.source.column
647
+ ) {
648
+ delete existing.line;
649
+ delete existing.column;
650
+ }
651
+ if (!existing.reasons.includes(item.reason)) {
652
+ existing.reasons.push(item.reason);
653
+ }
654
+ continue;
655
+ }
656
+ files.set(key, {
657
+ file: item.source.file,
658
+ ...(item.source.line ? { line: item.source.line } : {}),
659
+ ...(item.source.column ? { column: item.source.column } : {}),
660
+ reasons: [item.reason],
661
+ });
662
+ }
663
+ return [...files.values()];
664
+ }
665
+
666
+ function suggestedActions(
667
+ target: ExplainTarget,
668
+ findings: ExplainFinding[],
669
+ targetDir: string,
670
+ ): ExplainSuggestedAction[] {
671
+ const inspectCommand = inspectCommandForTarget(target, findings);
672
+ const actions: ExplainSuggestedAction[] = [
673
+ {
674
+ kind: "inspect",
675
+ description: `Inspect the source-backed ${target.kind} context.`,
676
+ command: inspectCommand,
677
+ cwd: targetDir,
678
+ },
679
+ ];
680
+ for (const finding of findings) {
681
+ actions.push({
682
+ kind: "address-finding",
683
+ description: `${finding.code}: ${finding.message}`,
684
+ ...(finding.location ? { file: finding.location.file } : {}),
685
+ });
686
+ }
687
+ actions.push({
688
+ kind: "validate",
689
+ description: "Run the complete validation loop after making changes.",
690
+ command: "beignet check",
691
+ cwd: targetDir,
692
+ });
693
+ return actions;
694
+ }
695
+
696
+ function inspectCommandForTarget(
697
+ target: ExplainTarget,
698
+ findings: ExplainFinding[],
699
+ ): string {
700
+ if (target.kind === "feature") {
701
+ return `beignet map --feature ${target.name} --json`;
702
+ }
703
+ if (
704
+ target.kind === "contract" ||
705
+ target.kind === "route" ||
706
+ target.kind === "route-group" ||
707
+ target.kind === "openapi"
708
+ ) {
709
+ return "beignet routes --json";
710
+ }
711
+ if (target.kind === "port" || target.kind === "provider") {
712
+ return "beignet provider audit --json";
713
+ }
714
+ if (target.kind === "diagnostic") {
715
+ return findings.every((finding) => finding.source === "lint")
716
+ ? "beignet lint --json"
717
+ : "beignet doctor --strict --json";
718
+ }
719
+ if (target.feature) {
720
+ return `beignet map --feature ${target.feature} --json`;
721
+ }
722
+ return `beignet map --kind ${target.kind} --json`;
723
+ }
724
+
725
+ function providerRegistrationSources(target: ExplainTarget): AppMapSource[] {
726
+ if (target.kind !== "provider") return [];
727
+ const sources = target.details?.registrationSources;
728
+ if (!Array.isArray(sources)) return [];
729
+ return sources.filter(isAppMapSource);
730
+ }
731
+
732
+ function isAppMapSource(value: unknown): value is AppMapSource {
733
+ return (
734
+ typeof value === "object" &&
735
+ value !== null &&
736
+ "file" in value &&
737
+ typeof value.file === "string"
738
+ );
739
+ }
740
+
741
+ function explainSummary(
742
+ target: ExplainTarget,
743
+ nodes: AppMapNode[],
744
+ edges: AppMapEdge[],
745
+ findings: ExplainFinding[],
746
+ ): string {
747
+ const fileCount = new Set([
748
+ ...nodes.map((node) => node.source.file),
749
+ ...findings.flatMap((finding) =>
750
+ finding.location ? [finding.location.file] : [],
751
+ ),
752
+ ]).size;
753
+ if (target.kind === "diagnostic") {
754
+ return `${target.name} has ${countLabel(findings.length, "finding")} across ${countLabel(fileCount, "mapped source")}.`;
755
+ }
756
+ return `${target.name} is ${target.status ?? "mapped"} with ${countLabel(edges.length, "relationship")}, ${countLabel(fileCount, "source")}, and ${countLabel(findings.length, "relevant finding")}.`;
757
+ }
758
+
759
+ const conventionsByTargetKind = {
760
+ feature: [
761
+ "Feature-owned contracts, use cases, policies, ports, workflows, clients, components, and tests stay under features/<feature>/.",
762
+ "Feature route groups and workflow artifacts register through the central server entrypoints.",
763
+ ],
764
+ contract: [
765
+ "Contracts own HTTP method, path, schemas, responses, metadata, and route errors.",
766
+ "Contracts stay transport-focused and bind to application behavior through feature routes.",
767
+ ],
768
+ route: [
769
+ "Routes bind contracts to use cases and policies without importing concrete infrastructure.",
770
+ "Feature route groups compose centrally in server/routes.ts and runtime route adapters stay thin.",
771
+ ],
772
+ "route-group": [
773
+ "Feature route groups are declared in features/<feature>/routes.ts and composed centrally in server/routes.ts.",
774
+ "Route groups bind contracts to use cases while preserving the app's dependency direction.",
775
+ ],
776
+ "use-case": [
777
+ "Use cases own validated application workflows and may depend on ports, policies, and domain code, not concrete infrastructure or UI.",
778
+ "Use cases remain callable outside HTTP so routes, tasks, jobs, and agent capabilities can reuse the same behavior.",
779
+ ],
780
+ policy: [
781
+ "Feature policies own business authorization for resources owned by that feature.",
782
+ "Authorization remains enforced in application workflows even when contract metadata also declares an ability.",
783
+ ],
784
+ ability: [
785
+ "An ability belongs to the policy of the feature that owns the resource being authorized.",
786
+ "Contracts may name authorization abilities, while policies and use cases enforce the business rule.",
787
+ ],
788
+ event: [
789
+ "Events represent facts that happened and belong to the feature that owns the fact.",
790
+ "Durable event publication registers through the app's workflow and outbox boundaries.",
791
+ ],
792
+ listener: [
793
+ "Listeners react to registered events and delegate application work through ports, jobs, notifications, or use cases.",
794
+ "Feature listeners register through the central listener registry used by runtime wiring and integrity checks.",
795
+ ],
796
+ job: [
797
+ "Jobs represent background work and remain explicit at provider-backed execution boundaries.",
798
+ "Feature jobs register through central runtime or outbox registries before workers can dispatch them.",
799
+ ],
800
+ schedule: [
801
+ "Schedules represent time-based triggers and delegate work to application workflows.",
802
+ "Feature schedules register centrally so runtime wiring, doctor, and beignet schedule run inspect the same surface.",
803
+ ],
804
+ task: [
805
+ "Tasks are explicit operational entrypoints for maintenance, backfills, and other bounded application work.",
806
+ "Feature tasks register centrally so beignet task run and runtime integrity checks use the same definitions.",
807
+ ],
808
+ notification: [
809
+ "Notifications represent user-facing communication intent, separate from provider-specific delivery.",
810
+ "Queued notification definitions register with the central notification and job runtime.",
811
+ ],
812
+ upload: [
813
+ "Uploads define feature-owned validation and storage workflows while storage remains behind an app port.",
814
+ "Upload route adapters stay thin and reuse the registered feature upload definition.",
815
+ ],
816
+ "agent-capability": [
817
+ "Agent capabilities expose curated, validated inputs and outputs by adapting existing application use cases.",
818
+ "Delegated context and authorization resolve centrally before an agent capability executes.",
819
+ ],
820
+ registry: [
821
+ "Registries explicitly compose feature declarations into the runtime surface inspected by doctor and app-map.",
822
+ "Central registries remain the source of truth for runtime wiring and integrity checks.",
823
+ ],
824
+ port: [
825
+ "Ports define app-facing dependency interfaces; infrastructure adapters provide their concrete implementations.",
826
+ "App ports wire through infra/port-wiring.ts and remain available to use cases through application context.",
827
+ ],
828
+ provider: [
829
+ "Ports define app-facing interfaces, providers adapt external systems, and app infrastructure wires them together.",
830
+ "Lifecycle providers register in server/providers.ts and provider-backed app ports wire through infra/port-wiring.ts.",
831
+ ],
832
+ table: [
833
+ "Database tables remain infrastructure-owned persistence details rather than domain or use-case dependencies.",
834
+ "Configured schema sources and migrations must describe the same deployed database surface.",
835
+ ],
836
+ openapi: [
837
+ "OpenAPI documents derive from the same registered contract surface served by the application.",
838
+ "Documented methods, paths, schemas, errors, and operation IDs stay aligned with their contracts.",
839
+ ],
840
+ entrypoint: [
841
+ "Runtime entrypoints compose the server or expose thin framework adapters over registered routes.",
842
+ "Entrypoints reuse central context, routes, providers, and workflow registries instead of duplicating application behavior.",
843
+ ],
844
+ test: [
845
+ "Feature behavior tests stay under features/<feature>/tests and exercise use cases, routes, policies, workflows, and UI.",
846
+ "Infrastructure and server integration tests may remain adjacent to the modules they exercise.",
847
+ ],
848
+ diagnostic: [
849
+ "Doctor findings describe framework convention drift; lint findings describe dependency-direction violations.",
850
+ "Fix the app structure or wiring named by the finding, then run the complete validation loop.",
851
+ ],
852
+ } as const satisfies Record<ExplainTargetKind, readonly string[]>;
853
+
854
+ function conventionsFor(kind: ExplainTargetKind): string[] {
855
+ return [...conventionsByTargetKind[kind]];
856
+ }
857
+
858
+ function scopeFiles(nodes: AppMapNode[], edges: AppMapEdge[]): Set<string> {
859
+ return new Set([
860
+ ...nodes.map((node) => node.source.file),
861
+ ...edges.map((edge) => edge.evidence.file),
862
+ ]);
863
+ }
864
+
865
+ function moveTargetFirst(nodes: AppMapNode[], id: string): AppMapNode[] {
866
+ const target = nodes.find((node) => node.id === id);
867
+ return target ? [target, ...nodes.filter((node) => node.id !== id)] : nodes;
868
+ }
869
+
870
+ function compareFindings(left: ExplainFinding, right: ExplainFinding): number {
871
+ return `${left.code}:${left.location?.file ?? ""}:${left.location?.line ?? 0}:${left.message}`.localeCompare(
872
+ `${right.code}:${right.location?.file ?? ""}:${right.location?.line ?? 0}:${right.message}`,
873
+ );
874
+ }
875
+
876
+ function sourceKey(source: AppMapSource): string {
877
+ return `${source.file}:${source.line ?? 0}:${source.column ?? 0}:${source.exportName ?? ""}`;
878
+ }
879
+
880
+ function formatSource(source: {
881
+ file: string;
882
+ line?: number;
883
+ column?: number;
884
+ }): string {
885
+ return `${source.file}${source.line ? `:${source.line}${source.column ? `:${source.column}` : ""}` : ""}`;
886
+ }
887
+
888
+ function normalized(value: string): string {
889
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
890
+ }
891
+
892
+ function countLabel(count: number, noun: string): string {
893
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
894
+ }