@grafema/cli 0.1.1-alpha → 0.2.1-beta

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 (79) hide show
  1. package/dist/cli.js +10 -0
  2. package/dist/commands/analyze.d.ts.map +1 -1
  3. package/dist/commands/analyze.js +69 -11
  4. package/dist/commands/check.d.ts +6 -0
  5. package/dist/commands/check.d.ts.map +1 -1
  6. package/dist/commands/check.js +177 -1
  7. package/dist/commands/coverage.d.ts.map +1 -1
  8. package/dist/commands/coverage.js +7 -0
  9. package/dist/commands/doctor/checks.d.ts +55 -0
  10. package/dist/commands/doctor/checks.d.ts.map +1 -0
  11. package/dist/commands/doctor/checks.js +534 -0
  12. package/dist/commands/doctor/output.d.ts +20 -0
  13. package/dist/commands/doctor/output.d.ts.map +1 -0
  14. package/dist/commands/doctor/output.js +94 -0
  15. package/dist/commands/doctor/types.d.ts +42 -0
  16. package/dist/commands/doctor/types.d.ts.map +1 -0
  17. package/dist/commands/doctor/types.js +4 -0
  18. package/dist/commands/doctor.d.ts +17 -0
  19. package/dist/commands/doctor.d.ts.map +1 -0
  20. package/dist/commands/doctor.js +80 -0
  21. package/dist/commands/explain.d.ts +16 -0
  22. package/dist/commands/explain.d.ts.map +1 -0
  23. package/dist/commands/explain.js +145 -0
  24. package/dist/commands/explore.d.ts +7 -1
  25. package/dist/commands/explore.d.ts.map +1 -1
  26. package/dist/commands/explore.js +204 -85
  27. package/dist/commands/get.d.ts.map +1 -1
  28. package/dist/commands/get.js +16 -4
  29. package/dist/commands/impact.d.ts.map +1 -1
  30. package/dist/commands/impact.js +48 -50
  31. package/dist/commands/init.d.ts.map +1 -1
  32. package/dist/commands/init.js +93 -15
  33. package/dist/commands/ls.d.ts +14 -0
  34. package/dist/commands/ls.d.ts.map +1 -0
  35. package/dist/commands/ls.js +132 -0
  36. package/dist/commands/overview.d.ts.map +1 -1
  37. package/dist/commands/overview.js +15 -2
  38. package/dist/commands/query.d.ts +98 -0
  39. package/dist/commands/query.d.ts.map +1 -1
  40. package/dist/commands/query.js +549 -136
  41. package/dist/commands/schema.d.ts +13 -0
  42. package/dist/commands/schema.d.ts.map +1 -0
  43. package/dist/commands/schema.js +279 -0
  44. package/dist/commands/server.d.ts.map +1 -1
  45. package/dist/commands/server.js +13 -6
  46. package/dist/commands/stats.d.ts.map +1 -1
  47. package/dist/commands/stats.js +7 -0
  48. package/dist/commands/trace.d.ts +73 -0
  49. package/dist/commands/trace.d.ts.map +1 -1
  50. package/dist/commands/trace.js +500 -5
  51. package/dist/commands/types.d.ts +12 -0
  52. package/dist/commands/types.d.ts.map +1 -0
  53. package/dist/commands/types.js +79 -0
  54. package/dist/utils/formatNode.d.ts +13 -0
  55. package/dist/utils/formatNode.d.ts.map +1 -1
  56. package/dist/utils/formatNode.js +35 -2
  57. package/package.json +3 -3
  58. package/src/cli.ts +10 -0
  59. package/src/commands/analyze.ts +84 -9
  60. package/src/commands/check.ts +201 -0
  61. package/src/commands/coverage.ts +7 -0
  62. package/src/commands/doctor/checks.ts +612 -0
  63. package/src/commands/doctor/output.ts +115 -0
  64. package/src/commands/doctor/types.ts +45 -0
  65. package/src/commands/doctor.ts +106 -0
  66. package/src/commands/explain.ts +173 -0
  67. package/src/commands/explore.tsx +247 -97
  68. package/src/commands/get.ts +20 -6
  69. package/src/commands/impact.ts +55 -61
  70. package/src/commands/init.ts +101 -14
  71. package/src/commands/ls.ts +166 -0
  72. package/src/commands/overview.ts +15 -2
  73. package/src/commands/query.ts +643 -149
  74. package/src/commands/schema.ts +345 -0
  75. package/src/commands/server.ts +13 -6
  76. package/src/commands/stats.ts +7 -0
  77. package/src/commands/trace.ts +647 -6
  78. package/src/commands/types.ts +94 -0
  79. package/src/utils/formatNode.ts +42 -2
@@ -4,12 +4,13 @@
4
4
  * Usage:
5
5
  * grafema trace "userId from authenticate"
6
6
  * grafema trace "config"
7
+ * grafema trace --to "addNode#0.type" (sink-based trace)
7
8
  */
8
9
 
9
10
  import { Command } from 'commander';
10
11
  import { resolve, join } from 'path';
11
12
  import { existsSync } from 'fs';
12
- import { RFDBServerBackend, parseSemanticId } from '@grafema/core';
13
+ import { RFDBServerBackend, parseSemanticId, traceValues, type ValueSource } from '@grafema/core';
13
14
  import { formatNodeDisplay, formatNodeInline } from '../utils/formatNode.js';
14
15
  import { exitWithError } from '../utils/errorFormatter.js';
15
16
 
@@ -17,6 +18,50 @@ interface TraceOptions {
17
18
  project: string;
18
19
  json?: boolean;
19
20
  depth: string;
21
+ to?: string;
22
+ fromRoute?: string;
23
+ }
24
+
25
+ // =============================================================================
26
+ // SINK-BASED TRACE TYPES (REG-230)
27
+ // =============================================================================
28
+
29
+ /**
30
+ * Parsed sink specification from "fn#0.property.path" format
31
+ */
32
+ export interface SinkSpec {
33
+ functionName: string;
34
+ argIndex: number;
35
+ propertyPath: string[];
36
+ raw: string;
37
+ }
38
+
39
+ /**
40
+ * Information about a call site
41
+ */
42
+ export interface CallSiteInfo {
43
+ id: string;
44
+ calleeFunction: string;
45
+ file: string;
46
+ line: number;
47
+ }
48
+
49
+ /**
50
+ * Result of sink resolution
51
+ */
52
+ export interface SinkResolutionResult {
53
+ sink: SinkSpec;
54
+ resolvedCallSites: CallSiteInfo[];
55
+ possibleValues: Array<{
56
+ value: unknown;
57
+ sources: ValueSource[];
58
+ }>;
59
+ statistics: {
60
+ callSites: number;
61
+ totalSources: number;
62
+ uniqueValues: number;
63
+ unknownElements: boolean;
64
+ };
20
65
  }
21
66
 
22
67
  interface NodeInfo {
@@ -35,12 +80,24 @@ interface TraceStep {
35
80
  }
36
81
 
37
82
  export const traceCommand = new Command('trace')
38
- .description('Trace data flow for a variable')
39
- .argument('<pattern>', 'Pattern: "varName from functionName" or just "varName"')
83
+ .description('Trace data flow for a variable or to a sink point')
84
+ .argument('[pattern]', 'Pattern: "varName from functionName" or just "varName"')
40
85
  .option('-p, --project <path>', 'Project path', '.')
41
86
  .option('-j, --json', 'Output as JSON')
42
87
  .option('-d, --depth <n>', 'Max trace depth', '10')
43
- .action(async (pattern: string, options: TraceOptions) => {
88
+ .option('-t, --to <sink>', 'Sink point: "fn#argIndex.property" (e.g., "addNode#0.type")')
89
+ .option('-r, --from-route <pattern>', 'Trace from route response (e.g., "GET /status" or "/status")')
90
+ .addHelpText('after', `
91
+ Examples:
92
+ grafema trace "userId" Trace all variables named "userId"
93
+ grafema trace "userId from authenticate" Trace userId within authenticate function
94
+ grafema trace "config" --depth 5 Limit trace depth to 5 levels
95
+ grafema trace "apiKey" --json Output trace as JSON
96
+ grafema trace --to "addNode#0.type" Trace values reaching sink point
97
+ grafema trace --from-route "GET /status" Trace values from route response
98
+ grafema trace -r "/status" Trace by path only
99
+ `)
100
+ .action(async (pattern: string | undefined, options: TraceOptions) => {
44
101
  const projectPath = resolve(options.project);
45
102
  const grafemaDir = join(projectPath, '.grafema');
46
103
  const dbPath = join(grafemaDir, 'graph.rfdb');
@@ -53,6 +110,24 @@ export const traceCommand = new Command('trace')
53
110
  await backend.connect();
54
111
 
55
112
  try {
113
+ // Handle sink-based trace if --to option is provided
114
+ if (options.to) {
115
+ await handleSinkTrace(backend, options.to, projectPath, options.json);
116
+ return;
117
+ }
118
+
119
+ // Handle route-based trace if --from-route option is provided
120
+ if (options.fromRoute) {
121
+ const maxDepth = parseInt(options.depth, 10);
122
+ await handleRouteTrace(backend, options.fromRoute, projectPath, options.json, maxDepth);
123
+ return;
124
+ }
125
+
126
+ // Regular trace requires pattern
127
+ if (!pattern) {
128
+ exitWithError('Pattern required', ['Provide a pattern, use --to for sink trace, or --from-route for route trace']);
129
+ }
130
+
56
131
  // Parse pattern: "varName from functionName" or just "varName"
57
132
  const { varName, scopeName } = parseTracePattern(pattern);
58
133
  const maxDepth = parseInt(options.depth, 10);
@@ -188,6 +263,7 @@ async function traceBackward(
188
263
  ): Promise<TraceStep[]> {
189
264
  const trace: TraceStep[] = [];
190
265
  const visited = new Set<string>();
266
+ const seenNodes = new Set<string>();
191
267
  const queue: Array<{ id: string; depth: number }> = [{ id: startId, depth: 0 }];
192
268
 
193
269
  while (queue.length > 0) {
@@ -203,6 +279,9 @@ async function traceBackward(
203
279
  const targetNode = await backend.getNode(edge.dst);
204
280
  if (!targetNode) continue;
205
281
 
282
+ if (seenNodes.has(targetNode.id)) continue;
283
+ seenNodes.add(targetNode.id);
284
+
206
285
  const nodeInfo: NodeInfo = {
207
286
  id: targetNode.id,
208
287
  type: targetNode.type || 'UNKNOWN',
@@ -214,7 +293,7 @@ async function traceBackward(
214
293
 
215
294
  trace.push({
216
295
  node: nodeInfo,
217
- edgeType: edge.edgeType || edge.type,
296
+ edgeType: edge.type,
218
297
  depth: depth + 1,
219
298
  });
220
299
 
@@ -242,6 +321,7 @@ async function traceForward(
242
321
  ): Promise<TraceStep[]> {
243
322
  const trace: TraceStep[] = [];
244
323
  const visited = new Set<string>();
324
+ const seenNodes = new Set<string>();
245
325
  const queue: Array<{ id: string; depth: number }> = [{ id: startId, depth: 0 }];
246
326
 
247
327
  while (queue.length > 0) {
@@ -258,6 +338,9 @@ async function traceForward(
258
338
  const sourceNode = await backend.getNode(edge.src);
259
339
  if (!sourceNode) continue;
260
340
 
341
+ if (seenNodes.has(sourceNode.id)) continue;
342
+ seenNodes.add(sourceNode.id);
343
+
261
344
  const nodeInfo: NodeInfo = {
262
345
  id: sourceNode.id,
263
346
  type: sourceNode.type || 'UNKNOWN',
@@ -268,7 +351,7 @@ async function traceForward(
268
351
 
269
352
  trace.push({
270
353
  node: nodeInfo,
271
- edgeType: edge.edgeType || edge.type,
354
+ edgeType: edge.type,
272
355
  depth: depth + 1,
273
356
  });
274
357
 
@@ -339,3 +422,561 @@ function displayTrace(trace: TraceStep[], _projectPath: string, indent: string):
339
422
  }
340
423
  }
341
424
 
425
+ // =============================================================================
426
+ // SINK-BASED TRACE IMPLEMENTATION (REG-230)
427
+ // =============================================================================
428
+
429
+ /**
430
+ * Parse sink specification string into structured format
431
+ *
432
+ * Format: "functionName#argIndex.property.path"
433
+ * Examples:
434
+ * - "addNode#0.type" -> {functionName: "addNode", argIndex: 0, propertyPath: ["type"]}
435
+ * - "fn#0" -> {functionName: "fn", argIndex: 0, propertyPath: []}
436
+ * - "add_node_v2#1.config.options" -> {functionName: "add_node_v2", argIndex: 1, propertyPath: ["config", "options"]}
437
+ *
438
+ * @throws Error if spec is invalid
439
+ */
440
+ export function parseSinkSpec(spec: string): SinkSpec {
441
+ if (!spec || spec.trim() === '') {
442
+ throw new Error('Invalid sink spec: empty string');
443
+ }
444
+
445
+ const trimmed = spec.trim();
446
+
447
+ // Must contain # separator
448
+ const hashIndex = trimmed.indexOf('#');
449
+ if (hashIndex === -1) {
450
+ throw new Error('Invalid sink spec: missing # separator');
451
+ }
452
+
453
+ // Extract function name (before #)
454
+ const functionName = trimmed.substring(0, hashIndex);
455
+ if (!functionName || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(functionName)) {
456
+ throw new Error('Invalid sink spec: invalid function name');
457
+ }
458
+
459
+ // Extract argument index and optional property path (after #)
460
+ const afterHash = trimmed.substring(hashIndex + 1);
461
+ if (!afterHash) {
462
+ throw new Error('Invalid sink spec: missing argument index');
463
+ }
464
+
465
+ // Split by first dot to separate argIndex from property path
466
+ const dotIndex = afterHash.indexOf('.');
467
+ const argIndexStr = dotIndex === -1 ? afterHash : afterHash.substring(0, dotIndex);
468
+ const propertyPathStr = dotIndex === -1 ? '' : afterHash.substring(dotIndex + 1);
469
+
470
+ // Parse argument index
471
+ if (!/^\d+$/.test(argIndexStr)) {
472
+ throw new Error('Invalid sink spec: argument index must be numeric');
473
+ }
474
+
475
+ const argIndex = parseInt(argIndexStr, 10);
476
+ if (argIndex < 0) {
477
+ throw new Error('Invalid sink spec: negative argument index');
478
+ }
479
+
480
+ // Parse property path (split by dots)
481
+ const propertyPath = propertyPathStr ? propertyPathStr.split('.').filter(p => p) : [];
482
+
483
+ return {
484
+ functionName,
485
+ argIndex,
486
+ propertyPath,
487
+ raw: trimmed,
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Find all call sites for a function by name
493
+ *
494
+ * Handles both:
495
+ * - Direct calls: fn() where name === targetFunctionName
496
+ * - Method calls: obj.fn() where method attribute === targetFunctionName
497
+ */
498
+ export async function findCallSites(
499
+ backend: RFDBServerBackend,
500
+ targetFunctionName: string
501
+ ): Promise<CallSiteInfo[]> {
502
+ const callSites: CallSiteInfo[] = [];
503
+
504
+ for await (const node of backend.queryNodes({ nodeType: 'CALL' as any })) {
505
+ const nodeName = node.name || '';
506
+ const nodeMethod = (node as any).method || '';
507
+
508
+ // Match direct calls (name === targetFunctionName)
509
+ // Or method calls (method === targetFunctionName)
510
+ if (nodeName === targetFunctionName || nodeMethod === targetFunctionName) {
511
+ callSites.push({
512
+ id: node.id,
513
+ calleeFunction: targetFunctionName,
514
+ file: node.file || '',
515
+ line: (node as any).line || 0,
516
+ });
517
+ }
518
+ }
519
+
520
+ return callSites;
521
+ }
522
+
523
+ /**
524
+ * Extract the argument node ID at a specific index from a call site
525
+ *
526
+ * Follows PASSES_ARGUMENT edges and matches by argIndex metadata
527
+ *
528
+ * @returns Node ID of the argument, or null if not found
529
+ */
530
+ export async function extractArgument(
531
+ backend: RFDBServerBackend,
532
+ callSiteId: string,
533
+ argIndex: number
534
+ ): Promise<string | null> {
535
+ const edges = await backend.getOutgoingEdges(callSiteId, ['PASSES_ARGUMENT' as any]);
536
+
537
+ for (const edge of edges) {
538
+ // argIndex is stored in edge metadata
539
+ const edgeArgIndex = edge.metadata?.argIndex as number | undefined;
540
+ if (edgeArgIndex === argIndex) {
541
+ return edge.dst;
542
+ }
543
+ }
544
+
545
+ return null;
546
+ }
547
+
548
+ /**
549
+ * Extract a property from a node by following HAS_PROPERTY edges
550
+ *
551
+ * If node is a VARIABLE, first traces through ASSIGNED_FROM to find OBJECT_LITERAL
552
+ *
553
+ * @returns Node ID of the property value, or null if not found
554
+ */
555
+ async function extractProperty(
556
+ backend: RFDBServerBackend,
557
+ nodeId: string,
558
+ propertyName: string
559
+ ): Promise<string | null> {
560
+ const node = await backend.getNode(nodeId);
561
+ if (!node) return null;
562
+
563
+ const nodeType = node.type || (node as any).nodeType;
564
+
565
+ // If it's an OBJECT_LITERAL, follow HAS_PROPERTY directly
566
+ if (nodeType === 'OBJECT_LITERAL') {
567
+ const edges = await backend.getOutgoingEdges(nodeId, ['HAS_PROPERTY' as any]);
568
+ for (const edge of edges) {
569
+ if (edge.metadata?.propertyName === propertyName) {
570
+ return edge.dst;
571
+ }
572
+ }
573
+ return null;
574
+ }
575
+
576
+ // If it's a VARIABLE, first trace to the object literal
577
+ if (nodeType === 'VARIABLE' || nodeType === 'CONSTANT') {
578
+ const assignedEdges = await backend.getOutgoingEdges(nodeId, ['ASSIGNED_FROM' as any]);
579
+ for (const edge of assignedEdges) {
580
+ const result = await extractProperty(backend, edge.dst, propertyName);
581
+ if (result) return result;
582
+ }
583
+ }
584
+
585
+ return null;
586
+ }
587
+
588
+ /**
589
+ * Trace a node to its literal values.
590
+ * Uses shared traceValues utility from @grafema/core (REG-244).
591
+ *
592
+ * @param backend - RFDBServerBackend for graph queries
593
+ * @param nodeId - Starting node ID
594
+ * @param _visited - Kept for API compatibility (internal cycle detection in shared utility)
595
+ * @param maxDepth - Maximum traversal depth
596
+ * @returns Array of traced values with sources
597
+ */
598
+ async function traceToLiterals(
599
+ backend: RFDBServerBackend,
600
+ nodeId: string,
601
+ _visited: Set<string> = new Set(),
602
+ maxDepth: number = 10
603
+ ): Promise<{ value: unknown; source: ValueSource; isUnknown: boolean }[]> {
604
+ // RFDBServerBackend implements TraceValuesGraphBackend interface
605
+ const traced = await traceValues(backend, nodeId, {
606
+ maxDepth,
607
+ followDerivesFrom: true,
608
+ detectNondeterministic: true,
609
+ });
610
+
611
+ // Map to expected format (strip reason field)
612
+ return traced.map(t => ({
613
+ value: t.value,
614
+ source: t.source,
615
+ isUnknown: t.isUnknown,
616
+ }));
617
+ }
618
+
619
+ /**
620
+ * Resolve a sink specification to all possible values
621
+ *
622
+ * This is the main entry point for sink-based trace.
623
+ * It finds all call sites, extracts the specified argument,
624
+ * optionally follows property path, and traces to literal values.
625
+ */
626
+ export async function resolveSink(
627
+ backend: RFDBServerBackend,
628
+ sink: SinkSpec
629
+ ): Promise<SinkResolutionResult> {
630
+ // Find all call sites for the function
631
+ const callSites = await findCallSites(backend, sink.functionName);
632
+
633
+ const resolvedCallSites: CallSiteInfo[] = [];
634
+ const valueMap = new Map<string, { value: unknown; sources: ValueSource[] }>();
635
+ let hasUnknown = false;
636
+ let totalSources = 0;
637
+
638
+ for (const callSite of callSites) {
639
+ resolvedCallSites.push(callSite);
640
+
641
+ // Extract the argument at the specified index
642
+ const argNodeId = await extractArgument(backend, callSite.id, sink.argIndex);
643
+ if (!argNodeId) {
644
+ // Argument doesn't exist at this call site
645
+ continue;
646
+ }
647
+
648
+ // If property path specified, navigate to that property
649
+ let targetNodeId = argNodeId;
650
+ for (const propName of sink.propertyPath) {
651
+ const propNodeId = await extractProperty(backend, targetNodeId, propName);
652
+ if (!propNodeId) {
653
+ // Property not found, mark as unknown
654
+ hasUnknown = true;
655
+ targetNodeId = '';
656
+ break;
657
+ }
658
+ targetNodeId = propNodeId;
659
+ }
660
+
661
+ if (!targetNodeId) continue;
662
+
663
+ // Trace to literal values
664
+ const literals = await traceToLiterals(backend, targetNodeId);
665
+
666
+ for (const lit of literals) {
667
+ if (lit.isUnknown) {
668
+ hasUnknown = true;
669
+ continue;
670
+ }
671
+
672
+ totalSources++;
673
+ const valueKey = JSON.stringify(lit.value);
674
+
675
+ if (valueMap.has(valueKey)) {
676
+ valueMap.get(valueKey)!.sources.push(lit.source);
677
+ } else {
678
+ valueMap.set(valueKey, {
679
+ value: lit.value,
680
+ sources: [lit.source],
681
+ });
682
+ }
683
+ }
684
+ }
685
+
686
+ // Convert map to array
687
+ const possibleValues = Array.from(valueMap.values());
688
+
689
+ return {
690
+ sink,
691
+ resolvedCallSites,
692
+ possibleValues,
693
+ statistics: {
694
+ callSites: callSites.length,
695
+ totalSources,
696
+ uniqueValues: possibleValues.length,
697
+ unknownElements: hasUnknown,
698
+ },
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Handle sink trace command (--to option)
704
+ */
705
+ async function handleSinkTrace(
706
+ backend: RFDBServerBackend,
707
+ sinkSpec: string,
708
+ projectPath: string,
709
+ jsonOutput?: boolean
710
+ ): Promise<void> {
711
+ // Parse the sink specification
712
+ const sink = parseSinkSpec(sinkSpec);
713
+
714
+ // Resolve the sink
715
+ const result = await resolveSink(backend, sink);
716
+
717
+ if (jsonOutput) {
718
+ console.log(JSON.stringify(result, null, 2));
719
+ return;
720
+ }
721
+
722
+ // Human-readable output
723
+ console.log(`Sink: ${sink.raw}`);
724
+ console.log(`Resolved to ${result.statistics.callSites} call site(s)`);
725
+ console.log('');
726
+
727
+ if (result.possibleValues.length === 0) {
728
+ if (result.statistics.unknownElements) {
729
+ console.log('Possible values: <unknown> (runtime/parameter values)');
730
+ } else {
731
+ console.log('No values found');
732
+ }
733
+ return;
734
+ }
735
+
736
+ console.log('Possible values:');
737
+ for (const pv of result.possibleValues) {
738
+ const sourcesCount = pv.sources.length;
739
+ console.log(` - ${JSON.stringify(pv.value)} (${sourcesCount} source${sourcesCount === 1 ? '' : 's'})`);
740
+ for (const src of pv.sources.slice(0, 3)) {
741
+ const relativePath = src.file.startsWith(projectPath)
742
+ ? src.file.substring(projectPath.length + 1)
743
+ : src.file;
744
+ console.log(` <- ${relativePath}:${src.line}`);
745
+ }
746
+ if (pv.sources.length > 3) {
747
+ console.log(` ... and ${pv.sources.length - 3} more`);
748
+ }
749
+ }
750
+
751
+ if (result.statistics.unknownElements) {
752
+ console.log('');
753
+ console.log('Note: Some values could not be determined (runtime/parameter inputs)');
754
+ }
755
+ }
756
+
757
+ // =============================================================================
758
+ // ROUTE-BASED TRACE IMPLEMENTATION (REG-326)
759
+ // =============================================================================
760
+
761
+ /**
762
+ * Find route by pattern.
763
+ *
764
+ * Supports:
765
+ * - "METHOD /path" format (e.g., "GET /status")
766
+ * - "/path" format (e.g., "/status")
767
+ *
768
+ * Matching strategy:
769
+ * 1. Try exact "METHOD PATH" match
770
+ * 2. Try "/PATH" only match (any method)
771
+ *
772
+ * @param backend - Graph backend
773
+ * @param pattern - Route pattern (with or without method)
774
+ * @returns Route node or null if not found
775
+ */
776
+ async function findRouteByPattern(
777
+ backend: RFDBServerBackend,
778
+ pattern: string
779
+ ): Promise<NodeInfo | null> {
780
+ const trimmed = pattern.trim();
781
+
782
+ for await (const node of backend.queryNodes({ type: 'http:route' })) {
783
+ const method = (node as NodeInfo & { method?: string }).method || '';
784
+ const path = (node as NodeInfo & { path?: string }).path || '';
785
+
786
+ // Match "METHOD /path"
787
+ if (`${method} ${path}` === trimmed) {
788
+ return {
789
+ id: node.id,
790
+ type: node.type || 'http:route',
791
+ name: `${method} ${path}`,
792
+ file: node.file || '',
793
+ line: node.line
794
+ };
795
+ }
796
+
797
+ // Match "/path" only (ignore method)
798
+ if (path === trimmed) {
799
+ return {
800
+ id: node.id,
801
+ type: node.type || 'http:route',
802
+ name: `${method} ${path}`,
803
+ file: node.file || '',
804
+ line: node.line
805
+ };
806
+ }
807
+ }
808
+
809
+ return null;
810
+ }
811
+
812
+ /**
813
+ * Handle route-based trace (--from-route option).
814
+ *
815
+ * Flow:
816
+ * 1. Find route by pattern
817
+ * 2. Get RESPONDS_WITH edges from route
818
+ * 3. For each response node: call traceValues()
819
+ * 4. Format and display results grouped by response call
820
+ *
821
+ * @param backend - Graph backend
822
+ * @param pattern - Route pattern (e.g., "GET /status" or "/status")
823
+ * @param projectPath - Project root path
824
+ * @param jsonOutput - Whether to output as JSON
825
+ * @param maxDepth - Maximum trace depth (default 10)
826
+ */
827
+ async function handleRouteTrace(
828
+ backend: RFDBServerBackend,
829
+ pattern: string,
830
+ projectPath: string,
831
+ jsonOutput?: boolean,
832
+ maxDepth: number = 10
833
+ ): Promise<void> {
834
+ // Find route
835
+ const route = await findRouteByPattern(backend, pattern);
836
+
837
+ if (!route) {
838
+ console.log(`Route not found: ${pattern}`);
839
+ console.log('');
840
+ console.log('Hint: Use "grafema query" to list available routes');
841
+ return;
842
+ }
843
+
844
+ // Get RESPONDS_WITH edges
845
+ const respondsWithEdges = await backend.getOutgoingEdges(route.id, ['RESPONDS_WITH']);
846
+
847
+ if (respondsWithEdges.length === 0) {
848
+ if (jsonOutput) {
849
+ console.log(JSON.stringify({
850
+ route: {
851
+ name: route.name,
852
+ file: route.file,
853
+ line: route.line
854
+ },
855
+ responses: [],
856
+ message: 'No response data found'
857
+ }, null, 2));
858
+ } else {
859
+ console.log(`Route: ${route.name} (${route.file}:${route.line || '?'})`);
860
+ console.log('');
861
+ console.log('No response data found for this route.');
862
+ console.log('');
863
+ console.log('Hint: Make sure ExpressResponseAnalyzer is in your config.');
864
+ }
865
+ return;
866
+ }
867
+
868
+ // Build response data
869
+ const responses: Array<{
870
+ index: number;
871
+ method: string;
872
+ line: number;
873
+ sources: Array<{
874
+ type: string;
875
+ value?: unknown;
876
+ reason?: string;
877
+ file: string;
878
+ line: number;
879
+ id: string;
880
+ name?: string;
881
+ }>;
882
+ }> = [];
883
+
884
+ // Trace each response
885
+ for (let i = 0; i < respondsWithEdges.length; i++) {
886
+ const edge = respondsWithEdges[i];
887
+ const responseNode = await backend.getNode(edge.dst);
888
+
889
+ if (!responseNode) continue;
890
+
891
+ const responseMethod = (edge.metadata?.responseMethod as string) || 'unknown';
892
+
893
+ // Trace values from this response node
894
+ const traced = await traceValues(backend, responseNode.id, {
895
+ maxDepth,
896
+ followDerivesFrom: true,
897
+ detectNondeterministic: true
898
+ });
899
+
900
+ // Format traced values
901
+ const sources = await Promise.all(
902
+ traced.map(async (t) => {
903
+ const relativePath = t.source.file.startsWith(projectPath)
904
+ ? t.source.file.substring(projectPath.length + 1)
905
+ : t.source.file;
906
+
907
+ if (t.isUnknown) {
908
+ return {
909
+ type: 'UNKNOWN',
910
+ reason: t.reason || 'runtime input',
911
+ file: relativePath,
912
+ line: t.source.line,
913
+ id: t.source.id
914
+ };
915
+ } else if (t.value !== undefined) {
916
+ return {
917
+ type: 'LITERAL',
918
+ value: t.value,
919
+ file: relativePath,
920
+ line: t.source.line,
921
+ id: t.source.id
922
+ };
923
+ } else {
924
+ // Look up node to get type and name
925
+ const sourceNode = await backend.getNode(t.source.id);
926
+ return {
927
+ type: sourceNode?.type || 'VALUE',
928
+ name: sourceNode?.name || '<unnamed>',
929
+ file: relativePath,
930
+ line: t.source.line,
931
+ id: t.source.id
932
+ };
933
+ }
934
+ })
935
+ );
936
+
937
+ responses.push({
938
+ index: i + 1,
939
+ method: responseMethod,
940
+ line: responseNode.line || 0,
941
+ sources: sources.length > 0 ? sources : []
942
+ });
943
+
944
+ if (!jsonOutput) {
945
+ // Display human-readable output
946
+ console.log(`Response ${i + 1} (res.${responseMethod} at line ${responseNode.line || '?'}):`);
947
+ if (sources.length === 0) {
948
+ console.log(' No data sources found (response may be external or complex)');
949
+ } else {
950
+ console.log(' Data sources:');
951
+ for (const src of sources) {
952
+ if (src.type === 'UNKNOWN') {
953
+ console.log(` [UNKNOWN] ${src.reason} at ${src.file}:${src.line}`);
954
+ } else if (src.type === 'LITERAL') {
955
+ console.log(` [LITERAL] ${JSON.stringify(src.value)} at ${src.file}:${src.line}`);
956
+ } else {
957
+ console.log(` [${src.type}] ${src.name} at ${src.file}:${src.line}`);
958
+ }
959
+ }
960
+ }
961
+ console.log('');
962
+ }
963
+ }
964
+
965
+ // Output results
966
+ if (jsonOutput) {
967
+ console.log(JSON.stringify({
968
+ route: {
969
+ name: route.name,
970
+ file: route.file,
971
+ line: route.line
972
+ },
973
+ responses
974
+ }, null, 2));
975
+ } else {
976
+ // Human-readable output header
977
+ if (responses.length > 0 && !jsonOutput) {
978
+ // Already printed above, just for clarity
979
+ }
980
+ }
981
+ }
982
+