@optave/codegraph 2.5.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/export.js CHANGED
@@ -4,6 +4,25 @@ import { isTestFile } from './queries.js';
4
4
 
5
5
  const DEFAULT_MIN_CONFIDENCE = 0.5;
6
6
 
7
+ /** Escape special XML characters. */
8
+ function escapeXml(s) {
9
+ return String(s)
10
+ .replace(/&/g, '&')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&apos;');
15
+ }
16
+
17
+ /** RFC 4180 CSV field escaping — quote fields containing commas, quotes, or newlines. */
18
+ function escapeCsv(s) {
19
+ const str = String(s);
20
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
21
+ return `"${str.replace(/"/g, '""')}"`;
22
+ }
23
+ return str;
24
+ }
25
+
7
26
  /**
8
27
  * Export the dependency graph in DOT (Graphviz) format.
9
28
  */
@@ -374,3 +393,289 @@ export function exportJSON(db, opts = {}) {
374
393
  const base = { nodes, edges };
375
394
  return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset });
376
395
  }
396
+
397
+ /**
398
+ * Export the dependency graph in GraphML (XML) format.
399
+ */
400
+ export function exportGraphML(db, opts = {}) {
401
+ const fileLevel = opts.fileLevel !== false;
402
+ const noTests = opts.noTests || false;
403
+ const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
404
+ const edgeLimit = opts.limit;
405
+
406
+ const lines = [
407
+ '<?xml version="1.0" encoding="UTF-8"?>',
408
+ '<graphml xmlns="http://graphml.graphstruct.net/graphml">',
409
+ ];
410
+
411
+ if (fileLevel) {
412
+ lines.push(' <key id="d0" for="node" attr.name="name" attr.type="string"/>');
413
+ lines.push(' <key id="d1" for="node" attr.name="file" attr.type="string"/>');
414
+ lines.push(' <key id="d2" for="edge" attr.name="kind" attr.type="string"/>');
415
+ lines.push(' <graph id="codegraph" edgedefault="directed">');
416
+
417
+ let edges = db
418
+ .prepare(`
419
+ SELECT DISTINCT n1.file AS source, n2.file AS target
420
+ FROM edges e
421
+ JOIN nodes n1 ON e.source_id = n1.id
422
+ JOIN nodes n2 ON e.target_id = n2.id
423
+ WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
424
+ AND e.confidence >= ?
425
+ `)
426
+ .all(minConf);
427
+ if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
428
+ if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
429
+
430
+ const files = new Set();
431
+ for (const { source, target } of edges) {
432
+ files.add(source);
433
+ files.add(target);
434
+ }
435
+
436
+ const fileIds = new Map();
437
+ let nIdx = 0;
438
+ for (const f of files) {
439
+ const id = `n${nIdx++}`;
440
+ fileIds.set(f, id);
441
+ lines.push(` <node id="${id}">`);
442
+ lines.push(` <data key="d0">${escapeXml(path.basename(f))}</data>`);
443
+ lines.push(` <data key="d1">${escapeXml(f)}</data>`);
444
+ lines.push(' </node>');
445
+ }
446
+
447
+ let eIdx = 0;
448
+ for (const { source, target } of edges) {
449
+ lines.push(
450
+ ` <edge id="e${eIdx++}" source="${fileIds.get(source)}" target="${fileIds.get(target)}">`,
451
+ );
452
+ lines.push(' <data key="d2">imports</data>');
453
+ lines.push(' </edge>');
454
+ }
455
+ } else {
456
+ lines.push(' <key id="d0" for="node" attr.name="name" attr.type="string"/>');
457
+ lines.push(' <key id="d1" for="node" attr.name="kind" attr.type="string"/>');
458
+ lines.push(' <key id="d2" for="node" attr.name="file" attr.type="string"/>');
459
+ lines.push(' <key id="d3" for="node" attr.name="line" attr.type="int"/>');
460
+ lines.push(' <key id="d4" for="node" attr.name="role" attr.type="string"/>');
461
+ lines.push(' <key id="d5" for="edge" attr.name="kind" attr.type="string"/>');
462
+ lines.push(' <key id="d6" for="edge" attr.name="confidence" attr.type="double"/>');
463
+ lines.push(' <graph id="codegraph" edgedefault="directed">');
464
+
465
+ let edges = db
466
+ .prepare(`
467
+ SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind,
468
+ n1.file AS source_file, n1.line AS source_line, n1.role AS source_role,
469
+ n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind,
470
+ n2.file AS target_file, n2.line AS target_line, n2.role AS target_role,
471
+ e.kind AS edge_kind, e.confidence
472
+ FROM edges e
473
+ JOIN nodes n1 ON e.source_id = n1.id
474
+ JOIN nodes n2 ON e.target_id = n2.id
475
+ WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
476
+ AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
477
+ AND e.kind = 'calls'
478
+ AND e.confidence >= ?
479
+ `)
480
+ .all(minConf);
481
+ if (noTests)
482
+ edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
483
+ if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
484
+
485
+ const emittedNodes = new Set();
486
+ function emitNode(id, name, kind, file, line, role) {
487
+ if (emittedNodes.has(id)) return;
488
+ emittedNodes.add(id);
489
+ lines.push(` <node id="n${id}">`);
490
+ lines.push(` <data key="d0">${escapeXml(name)}</data>`);
491
+ lines.push(` <data key="d1">${escapeXml(kind)}</data>`);
492
+ lines.push(` <data key="d2">${escapeXml(file)}</data>`);
493
+ lines.push(` <data key="d3">${line}</data>`);
494
+ if (role) lines.push(` <data key="d4">${escapeXml(role)}</data>`);
495
+ lines.push(' </node>');
496
+ }
497
+
498
+ let eIdx = 0;
499
+ for (const e of edges) {
500
+ emitNode(
501
+ e.source_id,
502
+ e.source_name,
503
+ e.source_kind,
504
+ e.source_file,
505
+ e.source_line,
506
+ e.source_role,
507
+ );
508
+ emitNode(
509
+ e.target_id,
510
+ e.target_name,
511
+ e.target_kind,
512
+ e.target_file,
513
+ e.target_line,
514
+ e.target_role,
515
+ );
516
+ lines.push(` <edge id="e${eIdx++}" source="n${e.source_id}" target="n${e.target_id}">`);
517
+ lines.push(` <data key="d5">${escapeXml(e.edge_kind)}</data>`);
518
+ lines.push(` <data key="d6">${e.confidence}</data>`);
519
+ lines.push(' </edge>');
520
+ }
521
+ }
522
+
523
+ lines.push(' </graph>');
524
+ lines.push('</graphml>');
525
+ return lines.join('\n');
526
+ }
527
+
528
+ /**
529
+ * Export the dependency graph in TinkerPop GraphSON v3 format.
530
+ */
531
+ export function exportGraphSON(db, opts = {}) {
532
+ const noTests = opts.noTests || false;
533
+ const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
534
+
535
+ let nodes = db
536
+ .prepare(`
537
+ SELECT id, name, kind, file, line, role FROM nodes
538
+ WHERE kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'file')
539
+ `)
540
+ .all();
541
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
542
+
543
+ let edges = db
544
+ .prepare(`
545
+ SELECT e.rowid AS id, n1.id AS outV, n2.id AS inV, e.kind, e.confidence
546
+ FROM edges e
547
+ JOIN nodes n1 ON e.source_id = n1.id
548
+ JOIN nodes n2 ON e.target_id = n2.id
549
+ WHERE e.confidence >= ?
550
+ `)
551
+ .all(minConf);
552
+ if (noTests) {
553
+ const nodeIds = new Set(nodes.map((n) => n.id));
554
+ edges = edges.filter((e) => nodeIds.has(e.outV) && nodeIds.has(e.inV));
555
+ }
556
+
557
+ const vertices = nodes.map((n) => ({
558
+ id: n.id,
559
+ label: n.kind,
560
+ properties: {
561
+ name: [{ id: 0, value: n.name }],
562
+ file: [{ id: 0, value: n.file }],
563
+ ...(n.line != null ? { line: [{ id: 0, value: n.line }] } : {}),
564
+ ...(n.role ? { role: [{ id: 0, value: n.role }] } : {}),
565
+ },
566
+ }));
567
+
568
+ const gEdges = edges.map((e) => ({
569
+ id: e.id,
570
+ label: e.kind,
571
+ inV: e.inV,
572
+ outV: e.outV,
573
+ properties: {
574
+ confidence: e.confidence,
575
+ },
576
+ }));
577
+
578
+ const base = { vertices, edges: gEdges };
579
+ return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset });
580
+ }
581
+
582
+ /**
583
+ * Export the dependency graph as Neo4j bulk-import CSV files.
584
+ * Returns { nodes: string, relationships: string }.
585
+ */
586
+ export function exportNeo4jCSV(db, opts = {}) {
587
+ const fileLevel = opts.fileLevel !== false;
588
+ const noTests = opts.noTests || false;
589
+ const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
590
+ const edgeLimit = opts.limit;
591
+
592
+ if (fileLevel) {
593
+ let edges = db
594
+ .prepare(`
595
+ SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind, e.confidence
596
+ FROM edges e
597
+ JOIN nodes n1 ON e.source_id = n1.id
598
+ JOIN nodes n2 ON e.target_id = n2.id
599
+ WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
600
+ AND e.confidence >= ?
601
+ `)
602
+ .all(minConf);
603
+ if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
604
+ if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
605
+
606
+ const files = new Map();
607
+ let idx = 0;
608
+ for (const { source, target } of edges) {
609
+ if (!files.has(source)) files.set(source, idx++);
610
+ if (!files.has(target)) files.set(target, idx++);
611
+ }
612
+
613
+ const nodeLines = ['nodeId:ID,name,file:string,:LABEL'];
614
+ for (const [file, id] of files) {
615
+ nodeLines.push(`${id},${escapeCsv(path.basename(file))},${escapeCsv(file)},File`);
616
+ }
617
+
618
+ const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
619
+ for (const e of edges) {
620
+ const edgeType = e.kind.toUpperCase().replace(/-/g, '_');
621
+ relLines.push(`${files.get(e.source)},${files.get(e.target)},${edgeType},${e.confidence}`);
622
+ }
623
+
624
+ return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
625
+ }
626
+
627
+ let edges = db
628
+ .prepare(`
629
+ SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind,
630
+ n1.file AS source_file, n1.line AS source_line, n1.role AS source_role,
631
+ n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind,
632
+ n2.file AS target_file, n2.line AS target_line, n2.role AS target_role,
633
+ e.kind AS edge_kind, e.confidence
634
+ FROM edges e
635
+ JOIN nodes n1 ON e.source_id = n1.id
636
+ JOIN nodes n2 ON e.target_id = n2.id
637
+ WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
638
+ AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
639
+ AND e.kind = 'calls'
640
+ AND e.confidence >= ?
641
+ `)
642
+ .all(minConf);
643
+ if (noTests)
644
+ edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
645
+ if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
646
+
647
+ const emitted = new Set();
648
+ const nodeLines = ['nodeId:ID,name,kind,file:string,line:int,role,:LABEL'];
649
+ function emitNode(id, name, kind, file, line, role) {
650
+ if (emitted.has(id)) return;
651
+ emitted.add(id);
652
+ const label = kind.charAt(0).toUpperCase() + kind.slice(1);
653
+ nodeLines.push(
654
+ `${id},${escapeCsv(name)},${escapeCsv(kind)},${escapeCsv(file)},${line},${escapeCsv(role || '')},${label}`,
655
+ );
656
+ }
657
+
658
+ const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
659
+ for (const e of edges) {
660
+ emitNode(
661
+ e.source_id,
662
+ e.source_name,
663
+ e.source_kind,
664
+ e.source_file,
665
+ e.source_line,
666
+ e.source_role,
667
+ );
668
+ emitNode(
669
+ e.target_id,
670
+ e.target_name,
671
+ e.target_kind,
672
+ e.target_file,
673
+ e.target_line,
674
+ e.target_role,
675
+ );
676
+ const edgeType = e.edge_kind.toUpperCase().replace(/-/g, '_');
677
+ relLines.push(`${e.source_id},${e.target_id},${edgeType},${e.confidence}`);
678
+ }
679
+
680
+ return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
681
+ }
@@ -33,11 +33,13 @@ export function extractCSharpSymbols(tree, _filePath) {
33
33
  case 'class_declaration': {
34
34
  const nameNode = node.childForFieldName('name');
35
35
  if (nameNode) {
36
+ const classChildren = extractCSharpClassFields(node);
36
37
  definitions.push({
37
38
  name: nameNode.text,
38
39
  kind: 'class',
39
40
  line: node.startPosition.row + 1,
40
41
  endLine: nodeEndLine(node),
42
+ children: classChildren.length > 0 ? classChildren : undefined,
41
43
  });
42
44
  extractCSharpBaseTypes(node, nameNode.text, classes);
43
45
  }
@@ -47,11 +49,13 @@ export function extractCSharpSymbols(tree, _filePath) {
47
49
  case 'struct_declaration': {
48
50
  const nameNode = node.childForFieldName('name');
49
51
  if (nameNode) {
52
+ const structChildren = extractCSharpClassFields(node);
50
53
  definitions.push({
51
54
  name: nameNode.text,
52
55
  kind: 'struct',
53
56
  line: node.startPosition.row + 1,
54
57
  endLine: nodeEndLine(node),
58
+ children: structChildren.length > 0 ? structChildren : undefined,
55
59
  });
56
60
  extractCSharpBaseTypes(node, nameNode.text, classes);
57
61
  }
@@ -105,11 +109,13 @@ export function extractCSharpSymbols(tree, _filePath) {
105
109
  case 'enum_declaration': {
106
110
  const nameNode = node.childForFieldName('name');
107
111
  if (nameNode) {
112
+ const enumChildren = extractCSharpEnumMembers(node);
108
113
  definitions.push({
109
114
  name: nameNode.text,
110
115
  kind: 'enum',
111
116
  line: node.startPosition.row + 1,
112
117
  endLine: nodeEndLine(node),
118
+ children: enumChildren.length > 0 ? enumChildren : undefined,
113
119
  });
114
120
  }
115
121
  break;
@@ -120,11 +126,13 @@ export function extractCSharpSymbols(tree, _filePath) {
120
126
  if (nameNode) {
121
127
  const parentType = findCSharpParentType(node);
122
128
  const fullName = parentType ? `${parentType}.${nameNode.text}` : nameNode.text;
129
+ const params = extractCSharpParameters(node.childForFieldName('parameters'));
123
130
  definitions.push({
124
131
  name: fullName,
125
132
  kind: 'method',
126
133
  line: node.startPosition.row + 1,
127
134
  endLine: nodeEndLine(node),
135
+ children: params.length > 0 ? params : undefined,
128
136
  });
129
137
  }
130
138
  break;
@@ -135,11 +143,13 @@ export function extractCSharpSymbols(tree, _filePath) {
135
143
  if (nameNode) {
136
144
  const parentType = findCSharpParentType(node);
137
145
  const fullName = parentType ? `${parentType}.${nameNode.text}` : nameNode.text;
146
+ const params = extractCSharpParameters(node.childForFieldName('parameters'));
138
147
  definitions.push({
139
148
  name: fullName,
140
149
  kind: 'method',
141
150
  line: node.startPosition.row + 1,
142
151
  endLine: nodeEndLine(node),
152
+ children: params.length > 0 ? params : undefined,
143
153
  });
144
154
  }
145
155
  break;
@@ -152,7 +162,7 @@ export function extractCSharpSymbols(tree, _filePath) {
152
162
  const fullName = parentType ? `${parentType}.${nameNode.text}` : nameNode.text;
153
163
  definitions.push({
154
164
  name: fullName,
155
- kind: 'method',
165
+ kind: 'property',
156
166
  line: node.startPosition.row + 1,
157
167
  endLine: nodeEndLine(node),
158
168
  });
@@ -220,6 +230,59 @@ export function extractCSharpSymbols(tree, _filePath) {
220
230
  return { definitions, calls, imports, classes, exports };
221
231
  }
222
232
 
233
+ // ── Child extraction helpers ────────────────────────────────────────────────
234
+
235
+ function extractCSharpParameters(paramListNode) {
236
+ const params = [];
237
+ if (!paramListNode) return params;
238
+ for (let i = 0; i < paramListNode.childCount; i++) {
239
+ const param = paramListNode.child(i);
240
+ if (!param || param.type !== 'parameter') continue;
241
+ const nameNode = param.childForFieldName('name');
242
+ if (nameNode) {
243
+ params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 });
244
+ }
245
+ }
246
+ return params;
247
+ }
248
+
249
+ function extractCSharpClassFields(classNode) {
250
+ const fields = [];
251
+ const body = classNode.childForFieldName('body') || findChild(classNode, 'declaration_list');
252
+ if (!body) return fields;
253
+ for (let i = 0; i < body.childCount; i++) {
254
+ const member = body.child(i);
255
+ if (!member || member.type !== 'field_declaration') continue;
256
+ const varDecl = findChild(member, 'variable_declaration');
257
+ if (!varDecl) continue;
258
+ for (let j = 0; j < varDecl.childCount; j++) {
259
+ const child = varDecl.child(j);
260
+ if (!child || child.type !== 'variable_declarator') continue;
261
+ const nameNode = child.childForFieldName('name');
262
+ if (nameNode) {
263
+ fields.push({ name: nameNode.text, kind: 'property', line: member.startPosition.row + 1 });
264
+ }
265
+ }
266
+ }
267
+ return fields;
268
+ }
269
+
270
+ function extractCSharpEnumMembers(enumNode) {
271
+ const constants = [];
272
+ const body =
273
+ enumNode.childForFieldName('body') || findChild(enumNode, 'enum_member_declaration_list');
274
+ if (!body) return constants;
275
+ for (let i = 0; i < body.childCount; i++) {
276
+ const member = body.child(i);
277
+ if (!member || member.type !== 'enum_member_declaration') continue;
278
+ const nameNode = member.childForFieldName('name');
279
+ if (nameNode) {
280
+ constants.push({ name: nameNode.text, kind: 'constant', line: member.startPosition.row + 1 });
281
+ }
282
+ }
283
+ return constants;
284
+ }
285
+
223
286
  function extractCSharpBaseTypes(node, className, classes) {
224
287
  const baseList = node.childForFieldName('bases');
225
288
  if (!baseList) return;
@@ -1,4 +1,4 @@
1
- import { nodeEndLine } from './helpers.js';
1
+ import { findChild, nodeEndLine } from './helpers.js';
2
2
 
3
3
  /**
4
4
  * Extract symbols from Go files.
@@ -15,11 +15,13 @@ export function extractGoSymbols(tree, _filePath) {
15
15
  case 'function_declaration': {
16
16
  const nameNode = node.childForFieldName('name');
17
17
  if (nameNode) {
18
+ const params = extractGoParameters(node.childForFieldName('parameters'));
18
19
  definitions.push({
19
20
  name: nameNode.text,
20
21
  kind: 'function',
21
22
  line: node.startPosition.row + 1,
22
23
  endLine: nodeEndLine(node),
24
+ children: params.length > 0 ? params : undefined,
23
25
  });
24
26
  }
25
27
  break;
@@ -46,11 +48,13 @@ export function extractGoSymbols(tree, _filePath) {
46
48
  }
47
49
  }
48
50
  const fullName = receiverType ? `${receiverType}.${nameNode.text}` : nameNode.text;
51
+ const params = extractGoParameters(node.childForFieldName('parameters'));
49
52
  definitions.push({
50
53
  name: fullName,
51
54
  kind: 'method',
52
55
  line: node.startPosition.row + 1,
53
56
  endLine: nodeEndLine(node),
57
+ children: params.length > 0 ? params : undefined,
54
58
  });
55
59
  }
56
60
  break;
@@ -64,11 +68,13 @@ export function extractGoSymbols(tree, _filePath) {
64
68
  const typeNode = spec.childForFieldName('type');
65
69
  if (nameNode && typeNode) {
66
70
  if (typeNode.type === 'struct_type') {
71
+ const fields = extractStructFields(typeNode);
67
72
  definitions.push({
68
73
  name: nameNode.text,
69
74
  kind: 'struct',
70
75
  line: node.startPosition.row + 1,
71
76
  endLine: nodeEndLine(node),
77
+ children: fields.length > 0 ? fields : undefined,
72
78
  });
73
79
  } else if (typeNode.type === 'interface_type') {
74
80
  definitions.push({
@@ -145,6 +151,23 @@ export function extractGoSymbols(tree, _filePath) {
145
151
  break;
146
152
  }
147
153
 
154
+ case 'const_declaration': {
155
+ for (let i = 0; i < node.childCount; i++) {
156
+ const spec = node.child(i);
157
+ if (!spec || spec.type !== 'const_spec') continue;
158
+ const constName = spec.childForFieldName('name');
159
+ if (constName) {
160
+ definitions.push({
161
+ name: constName.text,
162
+ kind: 'constant',
163
+ line: spec.startPosition.row + 1,
164
+ endLine: spec.endPosition.row + 1,
165
+ });
166
+ }
167
+ }
168
+ break;
169
+ }
170
+
148
171
  case 'call_expression': {
149
172
  const fn = node.childForFieldName('function');
150
173
  if (fn) {
@@ -170,3 +193,45 @@ export function extractGoSymbols(tree, _filePath) {
170
193
  walkGoNode(tree.rootNode);
171
194
  return { definitions, calls, imports, classes, exports };
172
195
  }
196
+
197
+ // ── Child extraction helpers ────────────────────────────────────────────────
198
+
199
+ function extractGoParameters(paramListNode) {
200
+ const params = [];
201
+ if (!paramListNode) return params;
202
+ for (let i = 0; i < paramListNode.childCount; i++) {
203
+ const param = paramListNode.child(i);
204
+ if (!param || param.type !== 'parameter_declaration') continue;
205
+ // A parameter_declaration may have multiple identifiers (e.g., `a, b int`)
206
+ for (let j = 0; j < param.childCount; j++) {
207
+ const child = param.child(j);
208
+ if (child && child.type === 'identifier') {
209
+ params.push({ name: child.text, kind: 'parameter', line: child.startPosition.row + 1 });
210
+ }
211
+ }
212
+ }
213
+ return params;
214
+ }
215
+
216
+ function extractStructFields(structTypeNode) {
217
+ const fields = [];
218
+ const fieldList = findChild(structTypeNode, 'field_declaration_list');
219
+ if (!fieldList) return fields;
220
+ for (let i = 0; i < fieldList.childCount; i++) {
221
+ const field = fieldList.child(i);
222
+ if (!field || field.type !== 'field_declaration') continue;
223
+ const nameNode = field.childForFieldName('name');
224
+ if (nameNode) {
225
+ fields.push({ name: nameNode.text, kind: 'property', line: field.startPosition.row + 1 });
226
+ } else {
227
+ // Struct fields may have multiple names or use first identifier child
228
+ for (let j = 0; j < field.childCount; j++) {
229
+ const child = field.child(j);
230
+ if (child && child.type === 'field_identifier') {
231
+ fields.push({ name: child.text, kind: 'property', line: field.startPosition.row + 1 });
232
+ }
233
+ }
234
+ }
235
+ }
236
+ return fields;
237
+ }
@@ -36,11 +36,33 @@ export function extractHCLSymbols(tree, _filePath) {
36
36
  }
37
37
 
38
38
  if (name) {
39
+ // Extract attributes as property children for variable/output blocks
40
+ let blockChildren;
41
+ if (blockType === 'variable' || blockType === 'output') {
42
+ blockChildren = [];
43
+ const body = children.find((c) => c.type === 'body');
44
+ if (body) {
45
+ for (let j = 0; j < body.childCount; j++) {
46
+ const attr = body.child(j);
47
+ if (attr && attr.type === 'attribute') {
48
+ const key = attr.childForFieldName('key') || attr.child(0);
49
+ if (key) {
50
+ blockChildren.push({
51
+ name: key.text,
52
+ kind: 'property',
53
+ line: attr.startPosition.row + 1,
54
+ });
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
39
60
  definitions.push({
40
61
  name,
41
62
  kind: blockType,
42
63
  line: node.startPosition.row + 1,
43
64
  endLine: nodeEndLine(node),
65
+ children: blockChildren?.length > 0 ? blockChildren : undefined,
44
66
  });
45
67
  }
46
68