preflight-mcp 0.5.2 → 0.5.3

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/README.md CHANGED
@@ -51,7 +51,7 @@ Preflight: 🔗 Trace links:
51
51
  - 📖 **Auto-generated guides** — `START_HERE.md`, `AGENTS.md`, `OVERVIEW.md`
52
52
  - ☁️ **Cloud sync** — Multi-path mirror backup for redundancy
53
53
  - 🧠 **EDDA (Evidence-Driven Deep Analysis)** — Auto-generate auditable claims with evidence
54
- - ⚡ **22 MCP tools + 5 prompts** — Complete toolkit for code exploration
54
+ - ⚡ **21 MCP tools + 5 prompts** — Complete toolkit for code exploration
55
55
  - 📄 **Cursor pagination** — Handle large result sets efficiently (RFC v2)
56
56
 
57
57
  <details>
@@ -76,7 +76,7 @@ Preflight: 🔗 Trace links:
76
76
  - [Demo](#demo)
77
77
  - [Core Features](#core-features)
78
78
  - [Quick Start](#quick-start)
79
- - [Tools](#tools-22-total)
79
+ - [Tools](#tools-21-total)
80
80
  - [Prompts](#prompts-5-total)
81
81
  - [Environment Variables](#environment-variables)
82
82
  - [Contributing](#contributing)
@@ -161,7 +161,7 @@ Run end-to-end smoke test:
161
161
  npm run smoke
162
162
  ```
163
163
 
164
- ## Tools (22 total)
164
+ ## Tools (21 total)
165
165
 
166
166
  ### `preflight_list_bundles`
167
167
  List bundle IDs in storage.
@@ -191,12 +191,31 @@ Input (example):
191
191
  **Note**: If the bundle contains code files, consider using `preflight_evidence_dependency_graph` for dependency analysis or `preflight_trace_upsert` for trace links.
192
192
 
193
193
  ### `preflight_read_file`
194
- Read file(s) from bundle. Two modes:
194
+ Read file(s) from bundle. Multiple modes:
195
195
  - **Batch mode** (omit `file`): Returns ALL key files (OVERVIEW.md, START_HERE.md, AGENTS.md, manifest.json, deps/dependency-graph.json, repo READMEs) in one call
196
196
  - **Single file mode** (provide `file`): Returns that specific file
197
197
  - **Evidence citation**: Use `withLineNumbers: true` to get `N|line` format; use `ranges: ["20-80"]` to read specific lines
198
198
  - Triggers: "查看bundle", "bundle概览", "项目信息", "show bundle", "读取依赖图"
199
199
 
200
+ **NEW in v0.5.3 - Symbol outline & reading:**
201
+ - `outline: true`: Returns symbol structure (function/class/method/interface/type/enum) with line ranges
202
+ - Supports: `.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.go`, `.rs`
203
+ - 90%+ token savings for understanding file structure
204
+ - `symbol: "name"`: Read a specific symbol by name
205
+ - Format: `"functionName"` or `"ClassName.methodName"`
206
+ - Auto-includes context lines and returns with line numbers
207
+
208
+ **Example - Outline mode:**
209
+ ```
210
+ [src/server.ts] Outline (15 top-level symbols, typescript):
211
+ ├── ⚡function startServer(): Promise<void> :174-200
212
+ ├── ⚡class McpServer :205-400
213
+ │ ├── method registerTool :210-250
214
+ │ └── method start :380-400
215
+ └── ⚡interface Config :45-71
216
+ ```
217
+ (`⚡` = exported)
218
+
200
219
  ### `preflight_repo_tree`
201
220
  Get repository structure overview without wasting tokens on search.
202
221
  - Returns: ASCII directory tree, file count by extension/directory, entry point candidates
package/README.zh-CN.md CHANGED
@@ -209,12 +209,31 @@ npm run smoke
209
209
  **💡 提示**:对于代码仓库,创建 bundle 后可进一步使用 `preflight_evidence_dependency_graph` 获取依赖图,或使用 `preflight_trace_upsert` 记录代码←→需求/测试的追溯链接。
210
210
 
211
211
  ### `preflight_read_file`
212
- 从 bundle 读取文件。两种模式:
213
- - **批量模式**(省略 `file`):返回所有关键文件
212
+ 从 bundle 读取文件。多种模式:
213
+ - **批量模式**(省略 `file`):返回所有关键文件(OVERVIEW.md、START_HERE.md、AGENTS.md、manifest.json 等)
214
214
  - **单文件模式**(提供 `file`):返回指定文件
215
215
  - **证据引用**:使用 `withLineNumbers: true` 获取 `N|行` 格式;使用 `ranges: ["20-80"]` 读取指定行
216
216
  - 触发词:「查看概览」「项目概览」「bundle详情」「读取依赖图」
217
217
 
218
+ **v0.5.3 新增 - 符号大纲与按符号读取:**
219
+ - `outline: true`:返回文件的符号结构(function/class/method/interface/type/enum),附带行号范围
220
+ - 支持:`.ts`、`.tsx`、`.js`、`.jsx`、`.py`、`.go`、`.rs`
221
+ - 理解文件结构可节省 90%+ token
222
+ - `symbol: "name"`:按名称读取特定符号
223
+ - 格式:`"functionName"` 或 `"ClassName.methodName"`
224
+ - 自动包含上下文行并返回带行号的内容
225
+
226
+ **示例 - 大纲模式:**
227
+ ```
228
+ [src/server.ts] Outline (15 top-level symbols, typescript):
229
+ ├── ⚡function startServer(): Promise<void> :174-200
230
+ ├── ⚡class McpServer :205-400
231
+ │ ├── method registerTool :210-250
232
+ │ └── method start :380-400
233
+ └── ⚡interface Config :45-71
234
+ ```
235
+ (`⚡` = 已导出)
236
+
218
237
  ### `preflight_repo_tree`
219
238
  获取仓库结构概览,避免浪费 token 搜索。
220
239
  - 返回:ASCII 目录树、按扩展名/目录统计文件数、入口点候选
@@ -586,3 +586,613 @@ export async function extractExportedSymbolsWasm(filePath, normalizedContent) {
586
586
  return null;
587
587
  return { language: res.language, exports: res.exports };
588
588
  }
589
+ // ============================================================================
590
+ // Outline extraction (symbol structure for code navigation)
591
+ // ============================================================================
592
+ /**
593
+ * Check if a node is preceded by an export keyword (for JS/TS).
594
+ */
595
+ function isExportedJsTs(node) {
596
+ const parent = node.parent;
597
+ if (!parent)
598
+ return false;
599
+ // Direct export: export function foo() {}
600
+ if (parent.type === 'export_statement')
601
+ return true;
602
+ // export default function foo() {}
603
+ if (parent.type === 'export_statement' && parent.text.includes('default'))
604
+ return true;
605
+ return false;
606
+ }
607
+ /**
608
+ * Extract function signature from a function declaration node.
609
+ */
610
+ function extractFunctionSignatureJsTs(node) {
611
+ const params = node.childForFieldName('parameters');
612
+ const returnType = node.childForFieldName('return_type');
613
+ if (!params)
614
+ return undefined;
615
+ let sig = params.text;
616
+ if (returnType) {
617
+ sig += `: ${returnType.text}`;
618
+ }
619
+ return sig;
620
+ }
621
+ /**
622
+ * Extract method signature from a method definition node.
623
+ */
624
+ function extractMethodSignatureJsTs(node) {
625
+ const params = node.childForFieldName('parameters');
626
+ const returnType = node.childForFieldName('return_type');
627
+ if (!params)
628
+ return undefined;
629
+ let sig = params.text;
630
+ if (returnType) {
631
+ sig += `: ${returnType.text}`;
632
+ }
633
+ return sig;
634
+ }
635
+ /**
636
+ * Extract class methods as children.
637
+ */
638
+ function extractClassMethodsJsTs(classNode) {
639
+ const methods = [];
640
+ const body = classNode.childForFieldName('body');
641
+ if (!body)
642
+ return methods;
643
+ for (const child of body.namedChildren) {
644
+ if (child.type === 'method_definition' || child.type === 'public_field_definition') {
645
+ const name = child.childForFieldName('name');
646
+ if (!name)
647
+ continue;
648
+ const isMethod = child.type === 'method_definition';
649
+ methods.push({
650
+ kind: isMethod ? 'method' : 'variable',
651
+ name: name.text,
652
+ signature: isMethod ? extractMethodSignatureJsTs(child) : undefined,
653
+ range: {
654
+ startLine: child.startPosition.row + 1,
655
+ endLine: child.endPosition.row + 1,
656
+ },
657
+ exported: true, // Class members are implicitly accessible
658
+ });
659
+ }
660
+ }
661
+ return methods;
662
+ }
663
+ /**
664
+ * Extract outline from JavaScript/TypeScript/TSX files.
665
+ */
666
+ function extractOutlineJsTs(root, lang) {
667
+ const outline = [];
668
+ // Collect all exported names for checking
669
+ const exportedNames = new Set();
670
+ for (const st of root.descendantsOfType('export_statement')) {
671
+ // export { name1, name2 }
672
+ const clause = st.descendantsOfType('export_clause')[0];
673
+ if (clause) {
674
+ for (const spec of clause.descendantsOfType('export_specifier')) {
675
+ const name = spec.childForFieldName('name');
676
+ if (name)
677
+ exportedNames.add(name.text);
678
+ }
679
+ }
680
+ }
681
+ for (const child of root.namedChildren) {
682
+ // Handle export statements
683
+ let actualNode = child;
684
+ let isExported = false;
685
+ if (child.type === 'export_statement') {
686
+ isExported = true;
687
+ // Get the actual declaration inside
688
+ const decl = child.namedChildren.find(n => n.type !== 'export_clause' &&
689
+ n.type !== 'string' &&
690
+ n.type !== 'comment');
691
+ if (decl) {
692
+ actualNode = decl;
693
+ }
694
+ else {
695
+ continue; // export { ... } without declaration
696
+ }
697
+ }
698
+ const name = actualNode.childForFieldName('name');
699
+ switch (actualNode.type) {
700
+ case 'function_declaration': {
701
+ if (!name)
702
+ continue;
703
+ outline.push({
704
+ kind: 'function',
705
+ name: name.text,
706
+ signature: extractFunctionSignatureJsTs(actualNode),
707
+ range: {
708
+ startLine: actualNode.startPosition.row + 1,
709
+ endLine: actualNode.endPosition.row + 1,
710
+ },
711
+ exported: isExported || exportedNames.has(name.text),
712
+ });
713
+ break;
714
+ }
715
+ case 'class_declaration': {
716
+ if (!name)
717
+ continue;
718
+ const children = extractClassMethodsJsTs(actualNode);
719
+ outline.push({
720
+ kind: 'class',
721
+ name: name.text,
722
+ range: {
723
+ startLine: actualNode.startPosition.row + 1,
724
+ endLine: actualNode.endPosition.row + 1,
725
+ },
726
+ exported: isExported || exportedNames.has(name.text),
727
+ children: children.length > 0 ? children : undefined,
728
+ });
729
+ break;
730
+ }
731
+ case 'interface_declaration': {
732
+ if (!name)
733
+ continue;
734
+ outline.push({
735
+ kind: 'interface',
736
+ name: name.text,
737
+ range: {
738
+ startLine: actualNode.startPosition.row + 1,
739
+ endLine: actualNode.endPosition.row + 1,
740
+ },
741
+ exported: isExported || exportedNames.has(name.text),
742
+ });
743
+ break;
744
+ }
745
+ case 'type_alias_declaration': {
746
+ if (!name)
747
+ continue;
748
+ outline.push({
749
+ kind: 'type',
750
+ name: name.text,
751
+ range: {
752
+ startLine: actualNode.startPosition.row + 1,
753
+ endLine: actualNode.endPosition.row + 1,
754
+ },
755
+ exported: isExported || exportedNames.has(name.text),
756
+ });
757
+ break;
758
+ }
759
+ case 'enum_declaration': {
760
+ if (!name)
761
+ continue;
762
+ outline.push({
763
+ kind: 'enum',
764
+ name: name.text,
765
+ range: {
766
+ startLine: actualNode.startPosition.row + 1,
767
+ endLine: actualNode.endPosition.row + 1,
768
+ },
769
+ exported: isExported || exportedNames.has(name.text),
770
+ });
771
+ break;
772
+ }
773
+ case 'lexical_declaration':
774
+ case 'variable_declaration': {
775
+ // const/let/var declarations
776
+ for (const decl of actualNode.descendantsOfType('variable_declarator')) {
777
+ const varName = decl.childForFieldName('name');
778
+ if (!varName || varName.type !== 'identifier')
779
+ continue;
780
+ // Check if it's an arrow function
781
+ const value = decl.childForFieldName('value');
782
+ const isArrowFn = value?.type === 'arrow_function';
783
+ outline.push({
784
+ kind: isArrowFn ? 'function' : 'variable',
785
+ name: varName.text,
786
+ signature: isArrowFn ? extractFunctionSignatureJsTs(value) : undefined,
787
+ range: {
788
+ startLine: decl.startPosition.row + 1,
789
+ endLine: decl.endPosition.row + 1,
790
+ },
791
+ exported: isExported || exportedNames.has(varName.text),
792
+ });
793
+ }
794
+ break;
795
+ }
796
+ }
797
+ }
798
+ return outline;
799
+ }
800
+ /**
801
+ * Extract function signature from Python function definition.
802
+ */
803
+ function extractFunctionSignaturePython(node) {
804
+ const params = node.childForFieldName('parameters');
805
+ const returnType = node.childForFieldName('return_type');
806
+ if (!params)
807
+ return undefined;
808
+ let sig = params.text;
809
+ if (returnType) {
810
+ sig += ` -> ${returnType.text}`;
811
+ }
812
+ return sig;
813
+ }
814
+ /**
815
+ * Extract class methods for Python.
816
+ */
817
+ function extractClassMethodsPython(classNode) {
818
+ const methods = [];
819
+ const body = classNode.childForFieldName('body');
820
+ if (!body)
821
+ return methods;
822
+ for (const child of body.namedChildren) {
823
+ if (child.type === 'function_definition') {
824
+ const name = child.childForFieldName('name');
825
+ if (!name)
826
+ continue;
827
+ const methodName = name.text;
828
+ // Skip private methods (starting with __) except __init__
829
+ const isPrivate = methodName.startsWith('_') && methodName !== '__init__';
830
+ methods.push({
831
+ kind: 'method',
832
+ name: methodName,
833
+ signature: extractFunctionSignaturePython(child),
834
+ range: {
835
+ startLine: child.startPosition.row + 1,
836
+ endLine: child.endPosition.row + 1,
837
+ },
838
+ exported: !isPrivate,
839
+ });
840
+ }
841
+ }
842
+ return methods;
843
+ }
844
+ /**
845
+ * Extract outline from Python files.
846
+ */
847
+ function extractOutlinePython(root) {
848
+ const outline = [];
849
+ // Check __all__ for explicit exports
850
+ const exportedNames = new Set();
851
+ for (const asn of root.descendantsOfType(['assignment', 'assignment_statement'])) {
852
+ const left = asn.childForFieldName('left') ?? asn.namedChild(0);
853
+ if (!left || left.text !== '__all__')
854
+ continue;
855
+ const right = asn.childForFieldName('right') ?? asn.namedChild(asn.namedChildCount - 1);
856
+ if (!right)
857
+ continue;
858
+ // Extract names from list/tuple
859
+ for (const s of right.descendantsOfType('string')) {
860
+ const inner = s.text.replace(/^['"]|['"]$/g, '');
861
+ if (inner)
862
+ exportedNames.add(inner);
863
+ }
864
+ }
865
+ const hasExplicitAll = exportedNames.size > 0;
866
+ for (const child of root.namedChildren) {
867
+ switch (child.type) {
868
+ case 'function_definition': {
869
+ const name = child.childForFieldName('name');
870
+ if (!name)
871
+ continue;
872
+ const funcName = name.text;
873
+ // Public if: in __all__, or doesn't start with _ (when no __all__)
874
+ const isPublic = hasExplicitAll
875
+ ? exportedNames.has(funcName)
876
+ : !funcName.startsWith('_');
877
+ outline.push({
878
+ kind: 'function',
879
+ name: funcName,
880
+ signature: extractFunctionSignaturePython(child),
881
+ range: {
882
+ startLine: child.startPosition.row + 1,
883
+ endLine: child.endPosition.row + 1,
884
+ },
885
+ exported: isPublic,
886
+ });
887
+ break;
888
+ }
889
+ case 'class_definition': {
890
+ const name = child.childForFieldName('name');
891
+ if (!name)
892
+ continue;
893
+ const className = name.text;
894
+ const isPublic = hasExplicitAll
895
+ ? exportedNames.has(className)
896
+ : !className.startsWith('_');
897
+ const methods = extractClassMethodsPython(child);
898
+ outline.push({
899
+ kind: 'class',
900
+ name: className,
901
+ range: {
902
+ startLine: child.startPosition.row + 1,
903
+ endLine: child.endPosition.row + 1,
904
+ },
905
+ exported: isPublic,
906
+ children: methods.length > 0 ? methods : undefined,
907
+ });
908
+ break;
909
+ }
910
+ }
911
+ }
912
+ return outline;
913
+ }
914
+ /**
915
+ * Extract outline from Go files.
916
+ */
917
+ function extractOutlineGo(root) {
918
+ const outline = [];
919
+ for (const child of root.namedChildren) {
920
+ switch (child.type) {
921
+ case 'function_declaration': {
922
+ const name = child.childForFieldName('name');
923
+ if (!name)
924
+ continue;
925
+ const funcName = name.text;
926
+ // Go: exported if starts with uppercase
927
+ const isExported = /^[A-Z]/.test(funcName);
928
+ // Extract signature
929
+ const params = child.childForFieldName('parameters');
930
+ const result = child.childForFieldName('result');
931
+ let sig = params?.text || '()';
932
+ if (result)
933
+ sig += ` ${result.text}`;
934
+ outline.push({
935
+ kind: 'function',
936
+ name: funcName,
937
+ signature: sig,
938
+ range: {
939
+ startLine: child.startPosition.row + 1,
940
+ endLine: child.endPosition.row + 1,
941
+ },
942
+ exported: isExported,
943
+ });
944
+ break;
945
+ }
946
+ case 'method_declaration': {
947
+ const name = child.childForFieldName('name');
948
+ if (!name)
949
+ continue;
950
+ const methodName = name.text;
951
+ const isExported = /^[A-Z]/.test(methodName);
952
+ // Get receiver type
953
+ const receiver = child.childForFieldName('receiver');
954
+ const params = child.childForFieldName('parameters');
955
+ const result = child.childForFieldName('result');
956
+ let sig = params?.text || '()';
957
+ if (result)
958
+ sig += ` ${result.text}`;
959
+ if (receiver)
960
+ sig = `${receiver.text} ${sig}`;
961
+ outline.push({
962
+ kind: 'method',
963
+ name: methodName,
964
+ signature: sig,
965
+ range: {
966
+ startLine: child.startPosition.row + 1,
967
+ endLine: child.endPosition.row + 1,
968
+ },
969
+ exported: isExported,
970
+ });
971
+ break;
972
+ }
973
+ case 'type_declaration': {
974
+ // type Foo struct { ... } or type Bar interface { ... }
975
+ for (const spec of child.descendantsOfType('type_spec')) {
976
+ const name = spec.childForFieldName('name');
977
+ if (!name)
978
+ continue;
979
+ const typeName = name.text;
980
+ const isExported = /^[A-Z]/.test(typeName);
981
+ // Determine kind based on type definition
982
+ const typeNode = spec.childForFieldName('type');
983
+ let kind = 'type';
984
+ if (typeNode?.type === 'struct_type')
985
+ kind = 'class'; // Treat struct as class
986
+ else if (typeNode?.type === 'interface_type')
987
+ kind = 'interface';
988
+ outline.push({
989
+ kind,
990
+ name: typeName,
991
+ range: {
992
+ startLine: spec.startPosition.row + 1,
993
+ endLine: spec.endPosition.row + 1,
994
+ },
995
+ exported: isExported,
996
+ });
997
+ }
998
+ break;
999
+ }
1000
+ }
1001
+ }
1002
+ return outline;
1003
+ }
1004
+ /**
1005
+ * Extract outline from Rust files.
1006
+ */
1007
+ function extractOutlineRust(root) {
1008
+ const outline = [];
1009
+ for (const child of root.namedChildren) {
1010
+ switch (child.type) {
1011
+ case 'function_item': {
1012
+ const name = child.childForFieldName('name');
1013
+ if (!name)
1014
+ continue;
1015
+ const funcName = name.text;
1016
+ const isExported = hasRustPubVisibility(child);
1017
+ // Extract signature
1018
+ const params = child.childForFieldName('parameters');
1019
+ const returnType = child.childForFieldName('return_type');
1020
+ let sig = params?.text || '()';
1021
+ if (returnType)
1022
+ sig += ` -> ${returnType.text}`;
1023
+ outline.push({
1024
+ kind: 'function',
1025
+ name: funcName,
1026
+ signature: sig,
1027
+ range: {
1028
+ startLine: child.startPosition.row + 1,
1029
+ endLine: child.endPosition.row + 1,
1030
+ },
1031
+ exported: isExported,
1032
+ });
1033
+ break;
1034
+ }
1035
+ case 'struct_item': {
1036
+ const name = child.childForFieldName('name');
1037
+ if (!name)
1038
+ continue;
1039
+ const structName = name.text;
1040
+ const isExported = hasRustPubVisibility(child);
1041
+ outline.push({
1042
+ kind: 'class', // Treat struct as class
1043
+ name: structName,
1044
+ range: {
1045
+ startLine: child.startPosition.row + 1,
1046
+ endLine: child.endPosition.row + 1,
1047
+ },
1048
+ exported: isExported,
1049
+ });
1050
+ break;
1051
+ }
1052
+ case 'enum_item': {
1053
+ const name = child.childForFieldName('name');
1054
+ if (!name)
1055
+ continue;
1056
+ const enumName = name.text;
1057
+ const isExported = hasRustPubVisibility(child);
1058
+ outline.push({
1059
+ kind: 'enum',
1060
+ name: enumName,
1061
+ range: {
1062
+ startLine: child.startPosition.row + 1,
1063
+ endLine: child.endPosition.row + 1,
1064
+ },
1065
+ exported: isExported,
1066
+ });
1067
+ break;
1068
+ }
1069
+ case 'trait_item': {
1070
+ const name = child.childForFieldName('name');
1071
+ if (!name)
1072
+ continue;
1073
+ const traitName = name.text;
1074
+ const isExported = hasRustPubVisibility(child);
1075
+ outline.push({
1076
+ kind: 'interface', // Treat trait as interface
1077
+ name: traitName,
1078
+ range: {
1079
+ startLine: child.startPosition.row + 1,
1080
+ endLine: child.endPosition.row + 1,
1081
+ },
1082
+ exported: isExported,
1083
+ });
1084
+ break;
1085
+ }
1086
+ case 'type_item': {
1087
+ const name = child.childForFieldName('name');
1088
+ if (!name)
1089
+ continue;
1090
+ const typeName = name.text;
1091
+ const isExported = hasRustPubVisibility(child);
1092
+ outline.push({
1093
+ kind: 'type',
1094
+ name: typeName,
1095
+ range: {
1096
+ startLine: child.startPosition.row + 1,
1097
+ endLine: child.endPosition.row + 1,
1098
+ },
1099
+ exported: isExported,
1100
+ });
1101
+ break;
1102
+ }
1103
+ case 'impl_item': {
1104
+ // Extract methods from impl blocks
1105
+ const typeNode = child.childForFieldName('type');
1106
+ const typeName = typeNode?.text || 'impl';
1107
+ // Find all function_items inside the impl
1108
+ const body = child.childForFieldName('body');
1109
+ if (!body)
1110
+ continue;
1111
+ const methods = [];
1112
+ for (const item of body.namedChildren) {
1113
+ if (item.type === 'function_item') {
1114
+ const name = item.childForFieldName('name');
1115
+ if (!name)
1116
+ continue;
1117
+ const methodName = name.text;
1118
+ const isExported = hasRustPubVisibility(item);
1119
+ const params = item.childForFieldName('parameters');
1120
+ const returnType = item.childForFieldName('return_type');
1121
+ let sig = params?.text || '()';
1122
+ if (returnType)
1123
+ sig += ` -> ${returnType.text}`;
1124
+ methods.push({
1125
+ kind: 'method',
1126
+ name: methodName,
1127
+ signature: sig,
1128
+ range: {
1129
+ startLine: item.startPosition.row + 1,
1130
+ endLine: item.endPosition.row + 1,
1131
+ },
1132
+ exported: isExported,
1133
+ });
1134
+ }
1135
+ }
1136
+ if (methods.length > 0) {
1137
+ outline.push({
1138
+ kind: 'class', // impl block as a class-like container
1139
+ name: `impl ${typeName}`,
1140
+ range: {
1141
+ startLine: child.startPosition.row + 1,
1142
+ endLine: child.endPosition.row + 1,
1143
+ },
1144
+ exported: true, // impl blocks are always "public" in terms of structure
1145
+ children: methods,
1146
+ });
1147
+ }
1148
+ break;
1149
+ }
1150
+ }
1151
+ }
1152
+ return outline;
1153
+ }
1154
+ /**
1155
+ * Extract symbol outline from a source file.
1156
+ * Returns null if the file type is not supported.
1157
+ */
1158
+ export async function extractOutlineWasm(filePath, normalizedContent) {
1159
+ const lang = languageForFile(filePath);
1160
+ if (!lang)
1161
+ return null;
1162
+ // Supported languages: JS/TS, Python, Go, Rust
1163
+ if (!['javascript', 'typescript', 'tsx', 'python', 'go', 'rust'].includes(lang)) {
1164
+ return null;
1165
+ }
1166
+ const language = await loadLanguage(lang);
1167
+ const parser = new Parser();
1168
+ try {
1169
+ parser.setLanguage(language);
1170
+ const tree = parser.parse(normalizedContent);
1171
+ if (!tree)
1172
+ return { language: lang, outline: [] };
1173
+ try {
1174
+ const root = tree.rootNode;
1175
+ let outline;
1176
+ switch (lang) {
1177
+ case 'python':
1178
+ outline = extractOutlinePython(root);
1179
+ break;
1180
+ case 'go':
1181
+ outline = extractOutlineGo(root);
1182
+ break;
1183
+ case 'rust':
1184
+ outline = extractOutlineRust(root);
1185
+ break;
1186
+ default:
1187
+ outline = extractOutlineJsTs(root, lang);
1188
+ }
1189
+ return { language: lang, outline };
1190
+ }
1191
+ finally {
1192
+ tree.delete();
1193
+ }
1194
+ }
1195
+ finally {
1196
+ parser.delete();
1197
+ }
1198
+ }
package/dist/server.js CHANGED
@@ -21,6 +21,7 @@ import { suggestTestedByTraces } from './trace/suggest.js';
21
21
  import { generateRepoTree, formatTreeResult } from './bundle/tree.js';
22
22
  import { buildDeepAnalysis, detectTestInfo } from './analysis/deep.js';
23
23
  import { validateReport } from './analysis/validate.js';
24
+ import { extractOutlineWasm } from './ast/treeSitter.js';
24
25
  // RFC v2: New aggregation tools
25
26
  import { ReadFilesInputSchema, createReadFilesHandler, readFilesToolDescription } from './tools/readFiles.js';
26
27
  import { SearchAndReadInputSchema, createSearchAndReadHandler, searchAndReadToolDescription } from './tools/searchAndRead.js';
@@ -147,7 +148,7 @@ export async function startServer() {
147
148
  startHttpServer(cfg);
148
149
  const server = new McpServer({
149
150
  name: 'preflight-mcp',
150
- version: '0.5.2',
151
+ version: '0.5.3',
151
152
  description: 'Create evidence-based preflight bundles for repositories (docs + code) with SQLite FTS search.',
152
153
  }, {
153
154
  capabilities: {
@@ -390,6 +391,16 @@ export async function startServer() {
390
391
  includeDepsGraph: z.boolean().optional().default(false).describe('Include deps/dependency-graph.json in batch mode.'),
391
392
  withLineNumbers: z.boolean().optional().default(false).describe('If true, prefix each line with line number in "N|" format for evidence citation.'),
392
393
  ranges: z.array(z.string()).optional().describe('Line ranges to read, e.g. ["20-80", "100-120"]. Each range is "start-end" (1-indexed, inclusive). If omitted, reads entire file.'),
394
+ // NEW: outline mode for code structure extraction
395
+ outline: z.boolean().optional().default(false).describe('If true, return symbol outline instead of file content. ' +
396
+ 'Returns function/class/method/interface/type/enum with line ranges. ' +
397
+ 'Saves tokens by showing code structure without full content. ' +
398
+ 'Supports: .ts, .tsx, .js, .jsx, .py, .go, .rs files.'),
399
+ // NEW: symbol-based reading
400
+ symbol: z.string().optional().describe('Read a specific symbol (function/class/method) by name. ' +
401
+ 'Format: "functionName" or "ClassName" or "ClassName.methodName". ' +
402
+ 'Automatically locates and returns the symbol\'s code with context. ' +
403
+ 'Requires outline-supported file types (.ts, .tsx, .js, .jsx, .py).'),
393
404
  },
394
405
  outputSchema: {
395
406
  bundleId: z.string(),
@@ -405,6 +416,16 @@ export async function startServer() {
405
416
  end: z.number(),
406
417
  })),
407
418
  }).optional(),
419
+ // NEW: outline output
420
+ outline: z.array(z.object({
421
+ kind: z.enum(['function', 'class', 'method', 'interface', 'type', 'enum', 'variable']),
422
+ name: z.string(),
423
+ signature: z.string().optional(),
424
+ range: z.object({ startLine: z.number(), endLine: z.number() }),
425
+ exported: z.boolean(),
426
+ children: z.array(z.any()).optional(),
427
+ })).optional().describe('Symbol outline (when outline=true).'),
428
+ language: z.string().optional().describe('Detected language (when outline=true).'),
408
429
  },
409
430
  annotations: {
410
431
  readOnlyHint: true,
@@ -460,6 +481,115 @@ export async function startServer() {
460
481
  if (args.file) {
461
482
  const absPath = safeJoin(bundleRoot, args.file);
462
483
  const rawContent = await fs.readFile(absPath, 'utf8');
484
+ const normalizedContent = rawContent.replace(/\r\n/g, '\n');
485
+ // NEW: Outline mode - extract symbol structure
486
+ if (args.outline) {
487
+ const outlineResult = await extractOutlineWasm(args.file, normalizedContent);
488
+ if (!outlineResult) {
489
+ // Unsupported file type
490
+ return {
491
+ content: [{ type: 'text', text: `[${args.file}] Outline not supported for this file type. Supported: .ts, .tsx, .js, .jsx` }],
492
+ structuredContent: {
493
+ bundleId: args.bundleId,
494
+ file: args.file,
495
+ outline: null,
496
+ language: null,
497
+ },
498
+ };
499
+ }
500
+ // Format outline as readable text
501
+ const formatOutlineText = (symbols, indent = '') => {
502
+ const lines = [];
503
+ for (let i = 0; i < symbols.length; i++) {
504
+ const sym = symbols[i];
505
+ const isLast = i === symbols.length - 1;
506
+ const prefix = indent + (isLast ? '└── ' : '├── ');
507
+ const exportMark = sym.exported ? '⚡' : '';
508
+ const sig = sym.signature ? sym.signature : '';
509
+ lines.push(`${prefix}${exportMark}${sym.kind} ${sym.name}${sig} :${sym.range.startLine}-${sym.range.endLine}`);
510
+ if (sym.children && sym.children.length > 0) {
511
+ const childIndent = indent + (isLast ? ' ' : '│ ');
512
+ lines.push(...formatOutlineText(sym.children, childIndent));
513
+ }
514
+ }
515
+ return lines;
516
+ };
517
+ const outlineText = formatOutlineText(outlineResult.outline);
518
+ const totalSymbols = outlineResult.outline.length;
519
+ const header = `[${args.file}] Outline (${totalSymbols} top-level symbols, ${outlineResult.language}):\n`;
520
+ return {
521
+ content: [{ type: 'text', text: header + outlineText.join('\n') }],
522
+ structuredContent: {
523
+ bundleId: args.bundleId,
524
+ file: args.file,
525
+ outline: outlineResult.outline,
526
+ language: outlineResult.language,
527
+ },
528
+ };
529
+ }
530
+ // NEW: Symbol-based reading - locate and read a specific symbol
531
+ if (args.symbol) {
532
+ const outlineResult = await extractOutlineWasm(args.file, normalizedContent);
533
+ if (!outlineResult) {
534
+ return {
535
+ content: [{ type: 'text', text: `[${args.file}] Symbol lookup not supported for this file type. Supported: .ts, .tsx, .js, .jsx, .py` }],
536
+ structuredContent: { bundleId: args.bundleId, file: args.file, error: 'unsupported_file_type' },
537
+ };
538
+ }
539
+ // Parse symbol query: "funcName" or "ClassName.methodName"
540
+ const parts = args.symbol.split('.');
541
+ const targetName = parts[0];
542
+ const methodName = parts[1];
543
+ // Find the symbol in outline
544
+ let foundSymbol;
545
+ let foundIn = 'top';
546
+ for (const sym of outlineResult.outline) {
547
+ if (sym.name === targetName) {
548
+ if (methodName && sym.children) {
549
+ // Looking for a method inside this class
550
+ const method = sym.children.find(c => c.name === methodName);
551
+ if (method) {
552
+ foundSymbol = method;
553
+ foundIn = 'child';
554
+ break;
555
+ }
556
+ }
557
+ else {
558
+ foundSymbol = sym;
559
+ break;
560
+ }
561
+ }
562
+ }
563
+ if (!foundSymbol) {
564
+ // Symbol not found - provide helpful error with available symbols
565
+ const available = outlineResult.outline.map(s => {
566
+ if (s.children && s.children.length > 0) {
567
+ return `${s.name} (${s.kind}, methods: ${s.children.map(c => c.name).join(', ')})`;
568
+ }
569
+ return `${s.name} (${s.kind})`;
570
+ }).join(', ');
571
+ return {
572
+ content: [{ type: 'text', text: `[${args.file}] Symbol "${args.symbol}" not found.\n\nAvailable symbols: ${available}` }],
573
+ structuredContent: { bundleId: args.bundleId, file: args.file, error: 'symbol_not_found', available: outlineResult.outline.map(s => s.name) },
574
+ };
575
+ }
576
+ // Read the symbol's code (with 2 lines of context before)
577
+ const contextLines = 2;
578
+ const startLine = Math.max(1, foundSymbol.range.startLine - contextLines);
579
+ const endLine = foundSymbol.range.endLine;
580
+ const { content, lineInfo } = formatContent(rawContent, true, [{ start: startLine, end: endLine }]);
581
+ const header = `[${args.file}:${startLine}-${endLine}] ${foundSymbol.kind} ${foundSymbol.name}${foundSymbol.signature || ''}\n\n`;
582
+ return {
583
+ content: [{ type: 'text', text: header + content }],
584
+ structuredContent: {
585
+ bundleId: args.bundleId,
586
+ file: args.file,
587
+ symbol: foundSymbol,
588
+ content,
589
+ lineInfo,
590
+ },
591
+ };
592
+ }
463
593
  // Parse ranges if provided
464
594
  let parsedRanges;
465
595
  if (args.ranges && args.ranges.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "preflight-mcp",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "MCP server that creates evidence-based preflight bundles for GitHub repositories and library docs.",
5
5
  "type": "module",
6
6
  "license": "MIT",