@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.
- package/dist/cli.js +10 -0
- package/dist/commands/analyze.d.ts.map +1 -1
- package/dist/commands/analyze.js +69 -11
- package/dist/commands/check.d.ts +6 -0
- package/dist/commands/check.d.ts.map +1 -1
- package/dist/commands/check.js +177 -1
- package/dist/commands/coverage.d.ts.map +1 -1
- package/dist/commands/coverage.js +7 -0
- package/dist/commands/doctor/checks.d.ts +55 -0
- package/dist/commands/doctor/checks.d.ts.map +1 -0
- package/dist/commands/doctor/checks.js +534 -0
- package/dist/commands/doctor/output.d.ts +20 -0
- package/dist/commands/doctor/output.d.ts.map +1 -0
- package/dist/commands/doctor/output.js +94 -0
- package/dist/commands/doctor/types.d.ts +42 -0
- package/dist/commands/doctor/types.d.ts.map +1 -0
- package/dist/commands/doctor/types.js +4 -0
- package/dist/commands/doctor.d.ts +17 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +80 -0
- package/dist/commands/explain.d.ts +16 -0
- package/dist/commands/explain.d.ts.map +1 -0
- package/dist/commands/explain.js +145 -0
- package/dist/commands/explore.d.ts +7 -1
- package/dist/commands/explore.d.ts.map +1 -1
- package/dist/commands/explore.js +204 -85
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +16 -4
- package/dist/commands/impact.d.ts.map +1 -1
- package/dist/commands/impact.js +48 -50
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +93 -15
- package/dist/commands/ls.d.ts +14 -0
- package/dist/commands/ls.d.ts.map +1 -0
- package/dist/commands/ls.js +132 -0
- package/dist/commands/overview.d.ts.map +1 -1
- package/dist/commands/overview.js +15 -2
- package/dist/commands/query.d.ts +98 -0
- package/dist/commands/query.d.ts.map +1 -1
- package/dist/commands/query.js +549 -136
- package/dist/commands/schema.d.ts +13 -0
- package/dist/commands/schema.d.ts.map +1 -0
- package/dist/commands/schema.js +279 -0
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +13 -6
- package/dist/commands/stats.d.ts.map +1 -1
- package/dist/commands/stats.js +7 -0
- package/dist/commands/trace.d.ts +73 -0
- package/dist/commands/trace.d.ts.map +1 -1
- package/dist/commands/trace.js +500 -5
- package/dist/commands/types.d.ts +12 -0
- package/dist/commands/types.d.ts.map +1 -0
- package/dist/commands/types.js +79 -0
- package/dist/utils/formatNode.d.ts +13 -0
- package/dist/utils/formatNode.d.ts.map +1 -1
- package/dist/utils/formatNode.js +35 -2
- package/package.json +3 -3
- package/src/cli.ts +10 -0
- package/src/commands/analyze.ts +84 -9
- package/src/commands/check.ts +201 -0
- package/src/commands/coverage.ts +7 -0
- package/src/commands/doctor/checks.ts +612 -0
- package/src/commands/doctor/output.ts +115 -0
- package/src/commands/doctor/types.ts +45 -0
- package/src/commands/doctor.ts +106 -0
- package/src/commands/explain.ts +173 -0
- package/src/commands/explore.tsx +247 -97
- package/src/commands/get.ts +20 -6
- package/src/commands/impact.ts +55 -61
- package/src/commands/init.ts +101 -14
- package/src/commands/ls.ts +166 -0
- package/src/commands/overview.ts +15 -2
- package/src/commands/query.ts +643 -149
- package/src/commands/schema.ts +345 -0
- package/src/commands/server.ts +13 -6
- package/src/commands/stats.ts +7 -0
- package/src/commands/trace.ts +647 -6
- package/src/commands/types.ts +94 -0
- package/src/utils/formatNode.ts +42 -2
package/src/commands/trace.ts
CHANGED
|
@@ -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('
|
|
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
|
-
.
|
|
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.
|
|
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.
|
|
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
|
+
|