@elench/testkit 0.1.60 → 0.1.62

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 (36) hide show
  1. package/lib/config/database.mjs +53 -0
  2. package/lib/config/database.test.mjs +29 -0
  3. package/lib/config/discovery-config.mjs +13 -0
  4. package/lib/config/env.mjs +55 -0
  5. package/lib/config/env.test.mjs +40 -0
  6. package/lib/config/index.mjs +21 -807
  7. package/lib/config/paths.mjs +28 -0
  8. package/lib/config/paths.test.mjs +27 -0
  9. package/lib/config/runtime.mjs +241 -0
  10. package/lib/config/runtime.test.mjs +56 -0
  11. package/lib/config/skip-config.mjs +189 -0
  12. package/lib/config/skip-config.test.mjs +63 -0
  13. package/lib/config/telemetry.mjs +28 -0
  14. package/lib/config/validation.mjs +124 -0
  15. package/lib/coverage/backend-discovery.mjs +183 -0
  16. package/lib/coverage/backend-discovery.test.mjs +52 -0
  17. package/lib/coverage/evidence.mjs +147 -0
  18. package/lib/coverage/evidence.test.mjs +77 -0
  19. package/lib/coverage/fs-walk.mjs +64 -0
  20. package/lib/coverage/graph-builder.mjs +181 -0
  21. package/lib/coverage/index.mjs +1 -816
  22. package/lib/coverage/index.test.mjs +330 -14
  23. package/lib/coverage/next-discovery.mjs +174 -0
  24. package/lib/coverage/next-static-analysis.mjs +763 -0
  25. package/lib/coverage/routing.mjs +86 -0
  26. package/lib/coverage/routing.test.mjs +52 -0
  27. package/lib/coverage/shared.mjs +198 -0
  28. package/lib/coverage/shared.test.mjs +39 -0
  29. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  30. package/node_modules/@elench/testkit-bridge/src/index.mjs +156 -13
  31. package/node_modules/@elench/testkit-bridge/src/index.test.mjs +39 -5
  32. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  33. package/node_modules/@elench/testkit-protocol/src/index.d.ts +26 -0
  34. package/node_modules/@elench/testkit-protocol/src/index.mjs +18 -0
  35. package/node_modules/@elench/testkit-protocol/src/index.test.mjs +75 -1
  36. package/package.json +5 -4
@@ -13,7 +13,7 @@ afterEach(() => {
13
13
  });
14
14
 
15
15
  describe("coverage graph builder", () => {
16
- it("builds a Next page -> request -> API route -> server capability graph", () => {
16
+ it("builds a Next page -> surface -> action -> request -> API route -> backend/data capability graph", () => {
17
17
  const productDir = createProduct();
18
18
  writeFile(
19
19
  productDir,
@@ -38,15 +38,35 @@ describe("coverage graph builder", () => {
38
38
  "src/app/campaigns/page.tsx",
39
39
  `
40
40
  "use client";
41
- import { getJson } from "@/client/http/http";
41
+ import { getJson, postJson } from "@/client/http/http";
42
42
 
43
- export default async function CampaignsPage() {
43
+ async function refreshCampaigns() {
44
44
  await getJson("/api/campaigns");
45
- return null;
45
+ }
46
+
47
+ async function createCampaign() {
48
+ await postJson("/api/campaigns");
49
+ }
50
+
51
+ export default function CampaignsPage() {
52
+ return (
53
+ <main>
54
+ <button data-testid="campaign-refresh-button" onClick={() => refreshCampaigns()}>
55
+ Refresh campaigns
56
+ </button>
57
+ <button data-testid="campaign-create-button" onClick={createCampaign}>
58
+ Create campaign
59
+ </button>
60
+ </main>
61
+ );
46
62
  }
47
63
  `
48
64
  );
49
- writeFile(productDir, "src/client/http/http.ts", `export function getJson() { return null; }`);
65
+ writeFile(
66
+ productDir,
67
+ "src/client/http/http.ts",
68
+ `export function getJson() { return null; }\nexport function postJson() { return null; }`
69
+ );
50
70
  writeFile(
51
71
  productDir,
52
72
  "src/app/api/campaigns/route.ts",
@@ -62,7 +82,26 @@ describe("coverage graph builder", () => {
62
82
  }
63
83
  `
64
84
  );
65
- writeFile(productDir, "src/backend/server/campaigns/index.ts", `export async function listCampaigns() {}\nexport async function createCampaign() {}`);
85
+ writeFile(
86
+ productDir,
87
+ "src/backend/server/campaigns/index.ts",
88
+ `
89
+ import { insertCampaignRow, selectCampaignRows } from "@/backend/data/campaigns";
90
+
91
+ export async function listCampaigns() {
92
+ return selectCampaignRows();
93
+ }
94
+
95
+ export async function createCampaign() {
96
+ return insertCampaignRow();
97
+ }
98
+ `
99
+ );
100
+ writeFile(
101
+ productDir,
102
+ "src/backend/data/campaigns/index.ts",
103
+ `export async function selectCampaignRows() {}\nexport async function insertCampaignRow() {}`
104
+ );
66
105
 
67
106
  const context = buildServiceCoverageContext(productDir, "web", {
68
107
  local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
@@ -73,6 +112,10 @@ describe("coverage graph builder", () => {
73
112
  route: "/campaigns",
74
113
  filePath: "src/app/campaigns/page.tsx",
75
114
  });
115
+ expect(context.pageByRoute.get("/campaigns").surfacesByTargetValue.get("campaign-refresh-button")).toMatchObject({
116
+ kind: "ui_surface",
117
+ label: "Refresh campaigns",
118
+ });
76
119
  expect(context.apiRouteByKey.get("GET:/api/campaigns").node).toMatchObject({
77
120
  kind: "api_route",
78
121
  route: "/campaigns",
@@ -83,8 +126,18 @@ describe("coverage graph builder", () => {
83
126
  expect(context.graph.edges).toEqual(
84
127
  expect.arrayContaining([
85
128
  expect.objectContaining({
86
- kind: "requests",
129
+ kind: "contains",
87
130
  from: "page_view:web:/campaigns",
131
+ to: "ui_surface:web:/campaigns:campaign-refresh-button",
132
+ }),
133
+ expect.objectContaining({
134
+ kind: "triggers",
135
+ from: "ui_surface:web:/campaigns:campaign-refresh-button",
136
+ to: expect.stringContaining("ui_action:web:/campaigns"),
137
+ }),
138
+ expect.objectContaining({
139
+ kind: "requests",
140
+ from: expect.stringContaining("ui_action:web:/campaigns"),
88
141
  }),
89
142
  expect.objectContaining({
90
143
  kind: "handles",
@@ -94,6 +147,11 @@ describe("coverage graph builder", () => {
94
147
  kind: "delegates_to",
95
148
  from: "api_route:web:GET:/api/campaigns",
96
149
  }),
150
+ expect.objectContaining({
151
+ kind: "delegates_to",
152
+ from: "server_capability:src/backend/server/campaigns#createCampaign",
153
+ to: "data_capability:src/backend/data/campaigns#insertCampaignRow",
154
+ }),
97
155
  ])
98
156
  );
99
157
  });
@@ -107,8 +165,13 @@ describe("coverage graph builder", () => {
107
165
  import { saveSettings } from "./actions";
108
166
 
109
167
  export default function SettingsPage() {
110
- void saveSettings;
111
- return null;
168
+ return (
169
+ <form action={saveSettings}>
170
+ <button data-testid="settings-save-button" type="submit">
171
+ Save settings
172
+ </button>
173
+ </form>
174
+ );
112
175
  }
113
176
  `
114
177
  );
@@ -138,7 +201,12 @@ describe("coverage graph builder", () => {
138
201
  expect.arrayContaining([
139
202
  expect.objectContaining({
140
203
  kind: "triggers",
141
- from: "page_view:web:/settings",
204
+ from: expect.stringContaining("ui_surface:web:/settings"),
205
+ to: "ui_action:web:/settings:saveSettings",
206
+ }),
207
+ expect.objectContaining({
208
+ kind: "triggers",
209
+ from: "ui_action:web:/settings:saveSettings",
142
210
  to: "server_action:web:src/app/settings/actions.ts#saveSettings",
143
211
  }),
144
212
  expect.objectContaining({
@@ -149,10 +217,48 @@ describe("coverage graph builder", () => {
149
217
  );
150
218
  });
151
219
 
152
- it("attaches convention-based test evidence to page and route nodes", () => {
220
+ it("attaches convention-based test evidence to page, surface, route, and data nodes", () => {
153
221
  const productDir = createProduct();
154
- writeFile(productDir, "src/app/campaigns/page.tsx", `export default function CampaignsPage() { return null; }`);
155
- writeFile(productDir, "src/app/api/campaigns/route.ts", `export async function GET() { return Response.json({ ok: true }); }`);
222
+ writeFile(
223
+ productDir,
224
+ "src/app/campaigns/page.tsx",
225
+ `
226
+ "use client";
227
+ import { getJson } from "@/client/http/http";
228
+
229
+ async function loadCampaigns() {
230
+ await getJson("/api/campaigns");
231
+ }
232
+
233
+ export default function CampaignsPage() {
234
+ return <button data-testid="campaign-save-button" onClick={loadCampaigns}>Save campaign</button>;
235
+ }
236
+ `
237
+ );
238
+ writeFile(productDir, "src/client/http/http.ts", `export async function getJson() { return null; }`);
239
+ writeFile(
240
+ productDir,
241
+ "src/app/api/campaigns/route.ts",
242
+ `
243
+ import { listCampaigns } from "@/backend/server/campaigns";
244
+
245
+ export async function GET() {
246
+ return listCampaigns();
247
+ }
248
+ `
249
+ );
250
+ writeFile(
251
+ productDir,
252
+ "src/backend/server/campaigns/index.ts",
253
+ `
254
+ import { saveCampaignRow } from "@/backend/data/campaigns";
255
+
256
+ export async function listCampaigns() {
257
+ return saveCampaignRow();
258
+ }
259
+ `
260
+ );
261
+ writeFile(productDir, "src/backend/data/campaigns/index.ts", `export async function saveCampaignRow() {}`);
156
262
  writeFile(
157
263
  productDir,
158
264
  "src/app/campaigns/__testkit__/campaigns.pw.testkit.ts",
@@ -165,6 +271,32 @@ describe("coverage graph builder", () => {
165
271
  });
166
272
  `
167
273
  );
274
+ writeFile(
275
+ productDir,
276
+ "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
277
+ `
278
+ import { defineHttpSuite } from "@elench/testkit";
279
+
280
+ const suite = defineHttpSuite(({ rawReq }) => {
281
+ rawReq("GET", "/api/campaigns");
282
+ });
283
+
284
+ export default suite;
285
+ `
286
+ );
287
+ writeFile(
288
+ productDir,
289
+ "src/backend/data/campaigns/__testkit__/campaigns.dal.testkit.ts",
290
+ `
291
+ import { defineDalSuite } from "@elench/testkit";
292
+
293
+ const suite = defineDalSuite(({ db }) => {
294
+ db.query("SELECT 1 AS value");
295
+ });
296
+
297
+ export default suite;
298
+ `
299
+ );
168
300
 
169
301
  const graph = buildCoverageGraph({
170
302
  productDir,
@@ -188,6 +320,13 @@ describe("coverage graph builder", () => {
188
320
  suiteName: "campaigns",
189
321
  filePath: "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
190
322
  },
323
+ {
324
+ serviceName: "web",
325
+ type: "dal",
326
+ framework: "k6",
327
+ suiteName: "campaigns",
328
+ filePath: "src/backend/data/campaigns/__testkit__/campaigns.dal.testkit.ts",
329
+ },
191
330
  ],
192
331
  });
193
332
 
@@ -195,7 +334,10 @@ describe("coverage graph builder", () => {
195
334
  expect.arrayContaining([
196
335
  expect.objectContaining({
197
336
  testFilePath: "src/app/campaigns/__testkit__/campaigns.pw.testkit.ts",
198
- coveredNodeIds: ["page_view:web:/campaigns"],
337
+ coveredNodeIds: expect.arrayContaining([
338
+ "page_view:web:/campaigns",
339
+ "ui_surface:web:/campaigns:campaign-save-button",
340
+ ]),
199
341
  details: expect.objectContaining({
200
342
  route: "/campaigns",
201
343
  targets: [
@@ -210,6 +352,12 @@ describe("coverage graph builder", () => {
210
352
  testFilePath: "src/app/api/campaigns/__testkit__/campaigns.int.testkit.ts",
211
353
  coveredNodeIds: ["api_route:web:GET:/api/campaigns"],
212
354
  }),
355
+ expect.objectContaining({
356
+ testFilePath: "src/backend/data/campaigns/__testkit__/campaigns.dal.testkit.ts",
357
+ coveredNodeIds: expect.arrayContaining([
358
+ expect.stringContaining("data_capability:src/backend/data/campaigns"),
359
+ ]),
360
+ }),
213
361
  ])
214
362
  );
215
363
  });
@@ -227,6 +375,174 @@ describe("coverage graph builder", () => {
227
375
  expect(context.apiRouteByKey.get("GET:/api/projects/[projectId]").node).toBeTruthy();
228
376
  });
229
377
 
378
+ it("resolves root-level PW test via page.goto() content extraction", () => {
379
+ const productDir = createProduct();
380
+ writeFile(
381
+ productDir,
382
+ "src/app/dashboard/page.tsx",
383
+ `export default function DashboardPage() { return <button data-testid="dash-header">Dashboard</button>; }`
384
+ );
385
+ writeFile(
386
+ productDir,
387
+ "__testkit__/dashboard.pw.testkit.ts",
388
+ `
389
+ import { test, expect } from "@playwright/test";
390
+ test("dashboard loads", async ({ page }) => {
391
+ await page.goto("/dashboard");
392
+ await expect(page.getByTestId("dash-header")).toBeVisible();
393
+ });
394
+ `
395
+ );
396
+
397
+ const graph = buildCoverageGraph({
398
+ productDir,
399
+ services: {
400
+ web: {
401
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
402
+ },
403
+ },
404
+ discoveryFiles: [
405
+ {
406
+ serviceName: "web",
407
+ type: "e2e",
408
+ framework: "playwright",
409
+ suiteName: "dashboard",
410
+ filePath: "__testkit__/dashboard.pw.testkit.ts",
411
+ },
412
+ ],
413
+ });
414
+
415
+ const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/dashboard.pw.testkit.ts");
416
+ expect(evidence.coveredNodeIds).toEqual(
417
+ expect.arrayContaining([
418
+ "page_view:web:/dashboard",
419
+ "ui_surface:web:/dashboard:dash-header",
420
+ ])
421
+ );
422
+ });
423
+
424
+ it("resolves root-level e2e test via rawReq content extraction", () => {
425
+ const productDir = createProduct();
426
+ writeFile(
427
+ productDir,
428
+ "src/app/api/projects/route.ts",
429
+ `export async function GET() { return Response.json({ ok: true }); }`
430
+ );
431
+ writeFile(
432
+ productDir,
433
+ "__testkit__/projects.e2e.testkit.ts",
434
+ `
435
+ import { defineHttpSuite } from "@elench/testkit";
436
+ export default defineHttpSuite(({ rawReq }) => {
437
+ rawReq("GET", "/api/projects");
438
+ });
439
+ `
440
+ );
441
+
442
+ const graph = buildCoverageGraph({
443
+ productDir,
444
+ services: {
445
+ web: {
446
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
447
+ },
448
+ },
449
+ discoveryFiles: [
450
+ {
451
+ serviceName: "web",
452
+ type: "e2e",
453
+ framework: "k6",
454
+ suiteName: "projects",
455
+ filePath: "__testkit__/projects.e2e.testkit.ts",
456
+ },
457
+ ],
458
+ });
459
+
460
+ const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/projects.e2e.testkit.ts");
461
+ expect(evidence.coveredNodeIds).toEqual(["api_route:web:GET:/api/projects"]);
462
+ });
463
+
464
+ it("resolves scenario test via rawReq content extraction", () => {
465
+ const productDir = createProduct();
466
+ writeFile(
467
+ productDir,
468
+ "src/app/api/campaigns/route.ts",
469
+ `export async function POST() { return Response.json({ ok: true }); }`
470
+ );
471
+ writeFile(
472
+ productDir,
473
+ "__testkit__/campaign-flow.scenario.testkit.ts",
474
+ `
475
+ import { defineHttpSuite } from "@elench/testkit";
476
+ export default defineHttpSuite(({ rawReq }) => {
477
+ rawReq("POST", "/api/campaigns");
478
+ });
479
+ `
480
+ );
481
+
482
+ const graph = buildCoverageGraph({
483
+ productDir,
484
+ services: {
485
+ web: {
486
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
487
+ },
488
+ },
489
+ discoveryFiles: [
490
+ {
491
+ serviceName: "web",
492
+ type: "scenario",
493
+ framework: "k6",
494
+ suiteName: "campaign-flow",
495
+ filePath: "__testkit__/campaign-flow.scenario.testkit.ts",
496
+ },
497
+ ],
498
+ });
499
+
500
+ const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/campaign-flow.scenario.testkit.ts");
501
+ expect(evidence.coveredNodeIds).toEqual(["api_route:web:POST:/api/campaigns"]);
502
+ });
503
+
504
+ it("emits zero-coverage-inferred diagnostic when no patterns match", () => {
505
+ const productDir = createProduct();
506
+ writeFile(productDir, "src/app/page.tsx", `export default function HomePage() { return null; }`);
507
+ writeFile(
508
+ productDir,
509
+ "__testkit__/misc.e2e.testkit.ts",
510
+ `// no recognizable patterns here\nconsole.log("hello");`
511
+ );
512
+
513
+ const graph = buildCoverageGraph({
514
+ productDir,
515
+ services: {
516
+ web: {
517
+ local: { cwd: ".", start: "node server.js", baseUrl: "http://127.0.0.1:3000", readyUrl: "http://127.0.0.1:3000" },
518
+ },
519
+ },
520
+ discoveryFiles: [
521
+ {
522
+ serviceName: "web",
523
+ type: "e2e",
524
+ framework: "k6",
525
+ suiteName: "misc",
526
+ filePath: "__testkit__/misc.e2e.testkit.ts",
527
+ },
528
+ ],
529
+ });
530
+
531
+ expect(graph.diagnostics).toEqual(
532
+ expect.arrayContaining([
533
+ expect.objectContaining({
534
+ level: "info",
535
+ code: "zero-coverage-inferred",
536
+ filePath: "__testkit__/misc.e2e.testkit.ts",
537
+ service: "web",
538
+ }),
539
+ ])
540
+ );
541
+
542
+ const evidence = graph.evidence.find((e) => e.testFilePath === "__testkit__/misc.e2e.testkit.ts");
543
+ expect(evidence.coveredNodeIds).toEqual([expect.stringContaining("test_file:web:")]);
544
+ });
545
+
230
546
  it("does not exclude app/coverage source routes while still allowing top-level coverage output to be ignored", () => {
231
547
  const productDir = createProduct();
232
548
  writeFile(productDir, "app/coverage/page.tsx", `export default function CoveragePage() { return null; }`);
@@ -0,0 +1,174 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { analyzeNextPageFile } from "./next-static-analysis.mjs";
4
+ import { extractBackendImports, extractExportedFunctions, extractExportedMethodBodies } from "./backend-discovery.mjs";
5
+ import { resolveImportToSourceFile, walkFiles } from "./fs-walk.mjs";
6
+ import { routeFromApiFile, routeFromAppFile } from "./routing.mjs";
7
+ import {
8
+ dedupeNodes,
9
+ hasWord,
10
+ HTTP_METHODS,
11
+ isServerActionFile,
12
+ normalizePath,
13
+ normalizeRoute,
14
+ pageLabelFromRoute,
15
+ toApiRequestPath,
16
+ } from "./shared.mjs";
17
+
18
+ export function discoverPageViews({ serviceName, serviceRoot, nextAppRoot, exclude = [], resolveImportToSourceFile }) {
19
+ const pageFiles = walkFiles(nextAppRoot, { baseDir: serviceRoot, exclude }).filter(
20
+ (filePath) => filePath.endsWith("/page.tsx") || filePath.endsWith("/page.ts")
21
+ );
22
+ const nodes = [];
23
+ const edges = [];
24
+ const pageEntries = [];
25
+
26
+ for (const absolutePath of pageFiles) {
27
+ const relativePath = normalizePath(path.relative(serviceRoot, absolutePath));
28
+ const route = routeFromAppFile(nextAppRoot, absolutePath);
29
+ const content = fs.readFileSync(absolutePath, "utf8");
30
+ const node = {
31
+ id: `page_view:${serviceName}:${route}`,
32
+ kind: "page_view",
33
+ service: serviceName,
34
+ label: pageLabelFromRoute(route),
35
+ route,
36
+ filePath: relativePath,
37
+ };
38
+ nodes.push(node);
39
+
40
+ const pageAnalysis = analyzeNextPageFile({
41
+ serviceName,
42
+ serviceRoot,
43
+ filePath: relativePath,
44
+ route,
45
+ content,
46
+ readSourceFile: (resolvedPath) => {
47
+ const absoluteResolved = path.join(serviceRoot, resolvedPath);
48
+ return fs.existsSync(absoluteResolved) ? fs.readFileSync(absoluteResolved, "utf8") : null;
49
+ },
50
+ resolveImportPath: (specifier) => resolveImportToSourceFile(serviceRoot, relativePath, specifier),
51
+ isServerActionFile,
52
+ normalizeRoute,
53
+ });
54
+
55
+ for (const analysisNode of pageAnalysis.nodes) nodes.push(analysisNode);
56
+ for (const analysisEdge of pageAnalysis.edges) edges.push(analysisEdge);
57
+
58
+ pageEntries.push({
59
+ node,
60
+ route,
61
+ filePath: relativePath,
62
+ requests: pageAnalysis.requests,
63
+ serverActionRefs: pageAnalysis.serverActionRefs,
64
+ surfacesByTargetValue: pageAnalysis.surfacesByTargetValue,
65
+ });
66
+ }
67
+
68
+ return { nodes, edges, pageEntries };
69
+ }
70
+
71
+ export function discoverApiRoutes({ serviceName, serviceRoot, nextAppRoot, exclude = [], resolveImportToSourceFile }) {
72
+ const routeFiles = walkFiles(path.join(nextAppRoot, "api"), { baseDir: serviceRoot, exclude }).filter(
73
+ (filePath) => filePath.endsWith("/route.ts") || filePath.endsWith("/route.tsx") || filePath.endsWith("/route.js")
74
+ );
75
+ const nodes = [];
76
+ const edges = [];
77
+ const routeEntries = [];
78
+
79
+ for (const absolutePath of routeFiles) {
80
+ const filePath = normalizePath(path.relative(serviceRoot, absolutePath));
81
+ const route = routeFromApiFile(nextAppRoot, absolutePath);
82
+ const requestPath = toApiRequestPath(route);
83
+ const content = fs.readFileSync(absolutePath, "utf8");
84
+ const methodBodies = extractExportedMethodBodies(content, HTTP_METHODS);
85
+ const backendImports = extractBackendImports({ serviceName, serviceRoot, filePath, content, resolveImportToSourceFile });
86
+
87
+ for (const [method, body] of methodBodies) {
88
+ const node = {
89
+ id: `api_route:${serviceName}:${method}:${requestPath}`,
90
+ kind: "api_route",
91
+ service: serviceName,
92
+ label: `${method} ${requestPath}`,
93
+ route,
94
+ method,
95
+ path: requestPath,
96
+ filePath,
97
+ };
98
+ nodes.push(node);
99
+
100
+ const matchedCapabilities = backendImports.filter((entry) => hasWord(body, entry.importName));
101
+ for (const capability of matchedCapabilities) {
102
+ nodes.push(capability.node);
103
+ edges.push({
104
+ id: `delegates_to:${node.id}:${capability.node.id}`,
105
+ kind: "delegates_to",
106
+ from: node.id,
107
+ to: capability.node.id,
108
+ confidence: "high",
109
+ });
110
+ }
111
+
112
+ routeEntries.push({ node, method, route, requestPath, filePath });
113
+ }
114
+ }
115
+
116
+ return { nodes: dedupeNodes(nodes), edges, routeEntries };
117
+ }
118
+
119
+ export function discoverServerActions({ serviceName, serviceRoot, exclude = [], resolveImportToSourceFile }) {
120
+ const appRoots = [path.join(serviceRoot, "app"), path.join(serviceRoot, "src", "app")].filter((candidate) =>
121
+ fs.existsSync(candidate)
122
+ );
123
+ const nodes = [];
124
+ const edges = [];
125
+ const actionEntries = [];
126
+
127
+ for (const appRoot of appRoots) {
128
+ for (const absolutePath of walkFiles(appRoot, { baseDir: serviceRoot, exclude })) {
129
+ if (!absolutePath.endsWith(".ts") && !absolutePath.endsWith(".tsx")) continue;
130
+ const content = fs.readFileSync(absolutePath, "utf8");
131
+ if (!isServerActionFile(content)) continue;
132
+
133
+ const relativePath = normalizePath(path.relative(serviceRoot, absolutePath));
134
+ const backendImports = extractBackendImports({
135
+ serviceName,
136
+ serviceRoot,
137
+ filePath: relativePath,
138
+ content,
139
+ resolveImportToSourceFile,
140
+ });
141
+ const exportedFunctions = extractExportedFunctions(content);
142
+
143
+ for (const exported of exportedFunctions) {
144
+ const node = {
145
+ id: `server_action:${serviceName}:${relativePath}#${exported.name}`,
146
+ kind: "server_action",
147
+ service: serviceName,
148
+ label: exported.name,
149
+ filePath: relativePath,
150
+ };
151
+ nodes.push(node);
152
+ actionEntries.push({
153
+ node,
154
+ exportName: exported.name,
155
+ sourceFile: relativePath,
156
+ });
157
+
158
+ const matchedCapabilities = backendImports.filter((entry) => hasWord(exported.body, entry.importName));
159
+ for (const capability of matchedCapabilities) {
160
+ nodes.push(capability.node);
161
+ edges.push({
162
+ id: `delegates_to:${node.id}:${capability.node.id}`,
163
+ kind: "delegates_to",
164
+ from: node.id,
165
+ to: capability.node.id,
166
+ confidence: "high",
167
+ });
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ return { nodes: dedupeNodes(nodes), edges, actionEntries };
174
+ }