@brunobrise/xfeat 1.0.5 → 1.1.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.
Files changed (4) hide show
  1. package/README.md +1 -0
  2. package/index.js +177 -130
  3. package/index.test.js +71 -53
  4. package/package.json +2 -1
package/README.md CHANGED
@@ -93,6 +93,7 @@ The foundational Tree-sitter AST parser natively understands:
93
93
  - JavaScript (`.js`, `.jsx`)
94
94
  - TypeScript (`.ts`, `.tsx`)
95
95
  - Python (`.py`)
96
+ - Rust (`.rs`)
96
97
 
97
98
  ## License
98
99
 
package/index.js CHANGED
@@ -4,6 +4,7 @@ const path = require("path");
4
4
  const fg = require("fast-glob");
5
5
  const ignore = require("ignore");
6
6
  const { Parser, Language } = require("web-tree-sitter");
7
+ const { Listr } = require("listr2");
7
8
  require("dotenv").config({ path: path.join(process.cwd(), ".env") });
8
9
  const { Anthropic } = require("@anthropic-ai/sdk");
9
10
  const anthropic = new Anthropic({
@@ -587,43 +588,6 @@ async function main() {
587
588
  return;
588
589
  }
589
590
 
590
- // 1. Extract structural data
591
- const structuralData = [];
592
- for (const file of targetFiles) {
593
- // Only analyze files from the codebase (skip our own index.js if running locally)
594
- if (file.endsWith("index.js") && __dirname === targetDir) continue;
595
-
596
- console.log(`Analyzing file structure: ${file}...`);
597
- const data = await extractStructure(file);
598
-
599
- if (
600
- data &&
601
- (data.classes.length > 0 ||
602
- data.functions.length > 0 ||
603
- data.exports.length > 0)
604
- ) {
605
- structuralData.push({
606
- path: path.relative(targetDir, file),
607
- ...data,
608
- });
609
- } else {
610
- // Graceful Fallback for missing AST or empty extractions (LLM must read whole file)
611
- structuralData.push({
612
- path: path.relative(targetDir, file),
613
- classes: [],
614
- functions: [],
615
- exports: [],
616
- imports: [],
617
- note: "AST parsing unavailable. You MUST use view_file to extract features.",
618
- });
619
- }
620
- }
621
-
622
- if (structuralData.length === 0) {
623
- console.log("No valid structural data found.");
624
- return;
625
- }
626
-
627
591
  // --- CACHE INITIALIZATION ---
628
592
  const folderName = path.basename(path.resolve(targetDir));
629
593
  const outputPath = path.join(process.cwd(), `${folderName}-features.md`);
@@ -656,8 +620,6 @@ async function main() {
656
620
  await fs.writeFile(cachePath, JSON.stringify(cache, null, 2), "utf8");
657
621
  };
658
622
 
659
- // --- PIPELINE EXECUTION ---
660
-
661
623
  if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) {
662
624
  console.error(
663
625
  "\n❌ ERROR: Missing ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable.",
@@ -667,107 +629,192 @@ async function main() {
667
629
 
668
630
  const CONCURRENCY_LIMIT = parseInt(process.env.CONCURRENCY_LIMIT || "5", 10);
669
631
 
670
- console.log(`\n--- STAGE 1: Micro Analysis (File Level) ---`);
671
- console.log(`Using concurrency limit: ${CONCURRENCY_LIMIT}`);
672
-
673
- const fileAnalysesRaw = await pMap(
674
- structuralData,
675
- async (data) => {
676
- // Check cache first
677
- if (cache.fileAnalyses[data.path]) {
678
- console.log(`[File] Skipping ${data.path} (Already in cache)`);
679
- return cache.fileAnalyses[data.path];
680
- }
632
+ const tasks = new Listr(
633
+ [
634
+ {
635
+ title: "Analyzing File Structure",
636
+ task: async (ctx, task) => {
637
+ const structuralData = [];
638
+ let completed = 0;
639
+ const total = targetFiles.length;
640
+
641
+ for (const file of targetFiles) {
642
+ if (file.endsWith("index.js") && __dirname === targetDir) {
643
+ completed++;
644
+ continue;
645
+ }
681
646
 
682
- console.log(`[File] Analyzing ${data.path}...`);
683
- try {
684
- const featuresMd = await extractFeaturesWithClaude(data, targetDir);
685
- const result = {
686
- path: data.path,
687
- dir: path.dirname(data.path),
688
- features: featuresMd,
689
- };
690
-
691
- // Save to cache immediately
692
- cache.fileAnalyses[data.path] = result;
693
- await saveCache();
694
-
695
- return result;
696
- } catch (e) {
697
- console.error(`Failed to analyze ${data.path}`, e);
698
- return null;
699
- }
700
- },
701
- CONCURRENCY_LIMIT,
702
- );
647
+ task.output = `${path.relative(targetDir, file)} (${completed}/${total})`;
648
+ const data = await extractStructure(file);
649
+
650
+ if (
651
+ data &&
652
+ (data.classes.length > 0 ||
653
+ data.functions.length > 0 ||
654
+ data.exports.length > 0)
655
+ ) {
656
+ structuralData.push({
657
+ path: path.relative(targetDir, file),
658
+ ...data,
659
+ });
660
+ } else {
661
+ structuralData.push({
662
+ path: path.relative(targetDir, file),
663
+ classes: [],
664
+ functions: [],
665
+ exports: [],
666
+ imports: [],
667
+ note: "AST parsing unavailable. You MUST use view_file to extract features.",
668
+ });
669
+ }
670
+ completed++;
671
+ }
703
672
 
704
- const fileAnalyses = fileAnalysesRaw.filter(Boolean);
673
+ if (structuralData.length === 0) {
674
+ throw new Error("No valid structural data found.");
675
+ }
676
+ ctx.structuralData = structuralData;
677
+ },
678
+ },
679
+ {
680
+ title: "STAGE 1: Micro Analysis (File Level)",
681
+ task: async (ctx, task) => {
682
+ let completed = 0;
683
+ const total = ctx.structuralData.length;
684
+ task.output = `Completed 0/${total} files`;
685
+
686
+ const fileAnalysesRaw = await pMap(
687
+ ctx.structuralData,
688
+ async (data) => {
689
+ if (cache.fileAnalyses[data.path]) {
690
+ completed++;
691
+ task.output = `Completed ${completed}/${total} files`;
692
+ return cache.fileAnalyses[data.path];
693
+ }
694
+
695
+ try {
696
+ const featuresMd = await extractFeaturesWithClaude(
697
+ data,
698
+ targetDir,
699
+ );
700
+ const result = {
701
+ path: data.path,
702
+ dir: path.dirname(data.path),
703
+ features: featuresMd,
704
+ };
705
+
706
+ // Save to cache immediately
707
+ cache.fileAnalyses[data.path] = result;
708
+ await saveCache();
709
+
710
+ completed++;
711
+ task.output = `Completed ${completed}/${total} files`;
712
+ return result;
713
+ } catch (e) {
714
+ completed++;
715
+ task.output = `Failed to analyze ${data.path}`;
716
+ return null;
717
+ }
718
+ },
719
+ CONCURRENCY_LIMIT,
720
+ );
705
721
 
706
- console.log(`\n--- STAGE 2: Macro Analysis (Component Level) ---`);
707
- // Group files by directory
708
- const directories = {};
709
- for (const file of fileAnalyses) {
710
- if (!directories[file.dir]) directories[file.dir] = [];
711
- directories[file.dir].push(file);
712
- }
722
+ ctx.fileAnalyses = fileAnalysesRaw.filter(Boolean);
723
+ },
724
+ options: { bottomBar: Infinity },
725
+ },
726
+ {
727
+ title: "STAGE 2: Macro Analysis (Component Level)",
728
+ task: async (ctx, task) => {
729
+ const directories = {};
730
+ for (const file of ctx.fileAnalyses) {
731
+ if (!directories[file.dir]) directories[file.dir] = [];
732
+ directories[file.dir].push(file);
733
+ }
713
734
 
714
- const componentSummaries = {};
715
- const dirEntries = Object.entries(directories);
716
-
717
- await pMap(
718
- dirEntries,
719
- async ([dir, files]) => {
720
- // Check cache first
721
- if (cache.componentSummaries[dir]) {
722
- console.log(`[Component] Skipping ${dir} (Already in cache)`);
723
- componentSummaries[dir] = cache.componentSummaries[dir];
724
- return;
725
- }
735
+ const componentSummaries = {};
736
+ const dirEntries = Object.entries(directories);
737
+ let completed = 0;
738
+ const total = dirEntries.length;
739
+ task.output = `Completed 0/${total} components`;
740
+
741
+ await pMap(
742
+ dirEntries,
743
+ async ([dir, files]) => {
744
+ if (cache.componentSummaries[dir]) {
745
+ componentSummaries[dir] = cache.componentSummaries[dir];
746
+ completed++;
747
+ task.output = `Completed ${completed}/${total} components`;
748
+ return;
749
+ }
750
+
751
+ try {
752
+ const summary = await extractComponentSummary(dir, files);
753
+ componentSummaries[dir] = summary;
754
+
755
+ cache.componentSummaries[dir] = summary;
756
+ await saveCache();
757
+
758
+ completed++;
759
+ task.output = `Completed ${completed}/${total} components`;
760
+ } catch (e) {
761
+ completed++;
762
+ task.output = `Failed to synthesize component ${dir}`;
763
+ }
764
+ },
765
+ CONCURRENCY_LIMIT,
766
+ );
767
+ ctx.componentSummaries = componentSummaries;
768
+ },
769
+ options: { bottomBar: Infinity },
770
+ },
771
+ {
772
+ title: "STAGE 3: Global Architecture Mapping",
773
+ task: async (ctx, task) => {
774
+ let globalArchitecture = cache.globalArchitecture;
775
+ if (!globalArchitecture) {
776
+ globalArchitecture = await extractGlobalArchitecture(
777
+ ctx.componentSummaries,
778
+ );
779
+ cache.globalArchitecture = globalArchitecture;
780
+ await saveCache();
781
+ }
782
+ ctx.globalArchitecture = globalArchitecture;
783
+ },
784
+ },
785
+ {
786
+ title: "Final Assembly",
787
+ task: async (ctx, task) => {
788
+ let finalDocument = `# Codebase Architecture & Feature Map\n\n`;
789
+ finalDocument += `${ctx.globalArchitecture}\n\n`;
790
+ finalDocument += `---\n\n## Component Breakdown\n\n`;
791
+
792
+ for (const [dir, summary] of Object.entries(ctx.componentSummaries)) {
793
+ finalDocument += `### Directory: \`${dir}\`\n${summary}\n\n`;
794
+ }
726
795
 
727
- console.log(`[Component] Synthesizing ${dir} (${files.length} files)...`);
728
- try {
729
- const summary = await extractComponentSummary(dir, files);
730
- componentSummaries[dir] = summary;
796
+ finalDocument += `---\n\n## File-Level Details\n\n`;
797
+ for (const file of ctx.fileAnalyses) {
798
+ finalDocument += `#### \`${file.path}\`\n${file.features}\n\n`;
799
+ }
731
800
 
732
- // Save to cache immediately
733
- cache.componentSummaries[dir] = summary;
734
- await saveCache();
735
- } catch (e) {
736
- console.error(`Failed to synthesize component ${dir}`, e);
737
- }
801
+ await fs.writeFile(outputPath, finalDocument, "utf8");
802
+ task.title = `Codebase mapping complete! Saved to ${outputPath}`;
803
+ },
804
+ },
805
+ ],
806
+ {
807
+ rendererOptions: {
808
+ collapseSubtasks: false,
809
+ },
738
810
  },
739
- CONCURRENCY_LIMIT,
740
811
  );
741
812
 
742
- console.log(`\n--- STAGE 3: Global Architecture Mapping ---`);
743
- let globalArchitecture = cache.globalArchitecture;
744
- if (globalArchitecture) {
745
- console.log(`[Global] Skipping global architecture (Already in cache)`);
746
- } else {
747
- console.log(`[Global] Synthesizing final architecture...`);
748
- globalArchitecture = await extractGlobalArchitecture(componentSummaries);
749
- cache.globalArchitecture = globalArchitecture;
750
- await saveCache();
751
- }
752
-
753
- // --- FINAL ASSEMBLY ---
754
- console.log(`\nWriting final report to ${outputPath}...`);
755
-
756
- let finalDocument = `# Codebase Architecture & Feature Map\n\n`;
757
- finalDocument += `${globalArchitecture}\n\n`;
758
- finalDocument += `---\n\n## Component Breakdown\n\n`;
759
-
760
- for (const [dir, summary] of Object.entries(componentSummaries)) {
761
- finalDocument += `### Directory: \`${dir}\`\n${summary}\n\n`;
762
- }
763
-
764
- finalDocument += `---\n\n## File-Level Details\n\n`;
765
- for (const file of fileAnalyses) {
766
- finalDocument += `#### \`${file.path}\`\n${file.features}\n\n`;
813
+ try {
814
+ await tasks.run();
815
+ } catch (err) {
816
+ console.error("An error occurred during execution:", err.message);
767
817
  }
768
-
769
- await fs.writeFile(outputPath, finalDocument, "utf8");
770
- console.log(`\n✅ Codebase mapping complete! Saved to ${outputPath}`);
771
818
  }
772
819
 
773
820
  if (require.main === module) {
package/index.test.js CHANGED
@@ -1,13 +1,9 @@
1
- const path = require('path');
2
- const fs = require('fs/promises');
3
- const {
4
- getIgnores,
5
- initTreeSitter,
6
- extractStructure
7
- } = require('./index');
1
+ const path = require("path");
2
+ const fs = require("fs/promises");
3
+ const { getIgnores, initTreeSitter, extractStructure } = require("./index");
8
4
 
9
- describe('Code Features Extractor Unit Tests', () => {
10
- const testDir = path.join(__dirname, '__test_workspace__');
5
+ describe("Code Features Extractor Unit Tests", () => {
6
+ const testDir = path.join(__dirname, "__test_workspace__");
11
7
 
12
8
  beforeAll(async () => {
13
9
  // Create a temporary workspace for testing
@@ -15,24 +11,24 @@ describe('Code Features Extractor Unit Tests', () => {
15
11
 
16
12
  // Create a fake .gitignore
17
13
  await fs.writeFile(
18
- path.join(testDir, '.gitignore'),
19
- 'ignored_folder/\n*.log\n'
14
+ path.join(testDir, ".gitignore"),
15
+ "ignored_folder/\n*.log\n",
20
16
  );
21
17
 
22
18
  // Create some fake code files
23
19
  await fs.writeFile(
24
- path.join(testDir, 'sample.js'),
20
+ path.join(testDir, "sample.js"),
25
21
  `
26
22
  import { someLib } from 'some-lib';
27
23
  export class MyClass {
28
24
  myMethod() {}
29
25
  }
30
26
  export function myFunction() {}
31
- `
27
+ `,
32
28
  );
33
29
 
34
30
  await fs.writeFile(
35
- path.join(testDir, 'sample.py'),
31
+ path.join(testDir, "sample.py"),
36
32
  `
37
33
  from math import sqrt
38
34
  class PythonClass:
@@ -40,14 +36,25 @@ class PythonClass:
40
36
  pass
41
37
  def py_function():
42
38
  pass
43
- `
39
+ `,
44
40
  );
45
41
 
46
42
  await fs.writeFile(
47
- path.join(testDir, 'unsupported.txt'),
48
- 'Hello world'
43
+ path.join(testDir, "sample.rs"),
44
+ `
45
+ use std::collections::HashMap;
46
+ pub struct MyRustStruct {
47
+ field: i32,
48
+ }
49
+ impl MyRustStruct {
50
+ pub fn rs_method(&self) {}
51
+ }
52
+ pub fn rs_function() {}
53
+ `,
49
54
  );
50
55
 
56
+ await fs.writeFile(path.join(testDir, "unsupported.txt"), "Hello world");
57
+
51
58
  // Initialize TreeSitter before testing extraction
52
59
  await initTreeSitter();
53
60
  });
@@ -57,71 +64,82 @@ def py_function():
57
64
  await fs.rm(testDir, { recursive: true, force: true });
58
65
  });
59
66
 
60
- describe('getIgnores', () => {
61
- it('should parse .gitignore and include defaults', async () => {
67
+ describe("getIgnores", () => {
68
+ it("should parse .gitignore and include defaults", async () => {
62
69
  const ig = await getIgnores(testDir);
63
-
70
+
64
71
  // Defaults
65
- expect(ig.ignores('node_modules')).toBe(true);
66
- expect(ig.ignores('.git')).toBe(true);
67
-
72
+ expect(ig.ignores("node_modules")).toBe(true);
73
+ expect(ig.ignores(".git")).toBe(true);
74
+
68
75
  // Custom rules
69
- expect(ig.ignores('ignored_folder/file.js')).toBe(true);
70
- expect(ig.ignores('test.log')).toBe(true);
71
-
76
+ expect(ig.ignores("ignored_folder/file.js")).toBe(true);
77
+ expect(ig.ignores("test.log")).toBe(true);
78
+
72
79
  // Non-ignored
73
- expect(ig.ignores('src/main.js')).toBe(false);
74
- expect(ig.ignores('index.js')).toBe(false);
80
+ expect(ig.ignores("src/main.js")).toBe(false);
81
+ expect(ig.ignores("index.js")).toBe(false);
75
82
  });
76
83
 
77
- it('should handle missing .gitignore by returning defaults', async () => {
78
- const emptyDir = path.join(__dirname, '__empty_test_dir__');
84
+ it("should handle missing .gitignore by returning defaults", async () => {
85
+ const emptyDir = path.join(__dirname, "__empty_test_dir__");
79
86
  await fs.mkdir(emptyDir, { recursive: true });
80
87
 
81
88
  const ig = await getIgnores(emptyDir);
82
- expect(ig.ignores('node_modules')).toBe(true);
83
- expect(ig.ignores('.git')).toBe(true);
84
- expect(ig.ignores('test.log')).toBe(false); // custom rule should be false
89
+ expect(ig.ignores("node_modules")).toBe(true);
90
+ expect(ig.ignores(".git")).toBe(true);
91
+ expect(ig.ignores("test.log")).toBe(false); // custom rule should be false
85
92
 
86
93
  await fs.rm(emptyDir, { recursive: true, force: true });
87
94
  });
88
95
  });
89
96
 
90
- describe('extractStructure', () => {
91
- it('should extract AST structure for JavaScript files', async () => {
92
- const jsFilePath = path.join(testDir, 'sample.js');
97
+ describe("extractStructure", () => {
98
+ it("should extract AST structure for JavaScript files", async () => {
99
+ const jsFilePath = path.join(testDir, "sample.js");
93
100
  const structure = await extractStructure(jsFilePath);
94
-
101
+
95
102
  expect(structure).not.toBeNull();
96
103
  expect(structure.file).toBe(jsFilePath);
97
- expect(structure.classes).toContain('MyClass');
98
- expect(structure.functions).toContain('myMethod');
99
- expect(structure.functions).toContain('myFunction');
100
- expect(structure.exports).toContain('MyClass');
101
- expect(structure.exports).toContain('myFunction');
104
+ expect(structure.classes).toContain("MyClass");
105
+ expect(structure.functions).toContain("myMethod");
106
+ expect(structure.functions).toContain("myFunction");
107
+ expect(structure.exports).toContain("MyClass");
108
+ expect(structure.exports).toContain("myFunction");
102
109
  expect(structure.imports).toContain("'some-lib'");
103
110
  });
104
111
 
105
- it('should extract AST structure for Python files', async () => {
106
- const pyFilePath = path.join(testDir, 'sample.py');
112
+ it("should extract AST structure for Python files", async () => {
113
+ const pyFilePath = path.join(testDir, "sample.py");
107
114
  const structure = await extractStructure(pyFilePath);
108
-
115
+
109
116
  expect(structure).not.toBeNull();
110
117
  expect(structure.file).toBe(pyFilePath);
111
- expect(structure.classes).toContain('PythonClass');
112
- expect(structure.functions).toContain('py_method');
113
- expect(structure.functions).toContain('py_function');
114
- expect(structure.imports).toContain('math');
118
+ expect(structure.classes).toContain("PythonClass");
119
+ expect(structure.functions).toContain("py_method");
120
+ expect(structure.functions).toContain("py_function");
121
+ expect(structure.imports).toContain("math");
122
+ });
123
+
124
+ it("should extract AST structure for Rust files", async () => {
125
+ const rsFilePath = path.join(testDir, "sample.rs");
126
+ const structure = await extractStructure(rsFilePath);
127
+
128
+ expect(structure).not.toBeNull();
129
+ expect(structure.file).toBe(rsFilePath);
130
+ expect(structure.classes).toContain("MyRustStruct");
131
+ expect(structure.functions).toContain("rs_method");
132
+ expect(structure.functions).toContain("rs_function");
115
133
  });
116
134
 
117
- it('should return null for unsupported file extensions', async () => {
118
- const txtFilePath = path.join(testDir, 'unsupported.txt');
135
+ it("should return null for unsupported file extensions", async () => {
136
+ const txtFilePath = path.join(testDir, "unsupported.txt");
119
137
  const structure = await extractStructure(txtFilePath);
120
138
  expect(structure).toBeNull();
121
139
  });
122
140
 
123
- it('should return null (graceful failure) when parsing a nonexistent file', async () => {
124
- const nonexistentPath = path.join(testDir, 'does-not-exist.js');
141
+ it("should return null (graceful failure) when parsing a nonexistent file", async () => {
142
+ const nonexistentPath = path.join(testDir, "does-not-exist.js");
125
143
  const structure = await extractStructure(nonexistentPath);
126
144
  expect(structure).toBeNull();
127
145
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunobrise/xfeat",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -37,6 +37,7 @@
37
37
  "dotenv": "^17.3.1",
38
38
  "fast-glob": "^3.3.3",
39
39
  "ignore": "^7.0.5",
40
+ "listr2": "^6.6.1",
40
41
  "map-stream": "^0.0.7",
41
42
  "tree-sitter-bash": "^0.25.1",
42
43
  "tree-sitter-css": "^0.25.0",