kodu 2.0.2 → 2.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodu",
3
- "version": "2.0.2",
3
+ "version": "2.1.1",
4
4
  "description": "High-performance CLI to prepare codebase for LLMs, automate reviews, and draft commits.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -7,6 +7,7 @@ import { PromptService } from '../../core/config/prompt.service';
7
7
  import { FsService } from '../../core/file-system/fs.service';
8
8
  import { UiService } from '../../core/ui/ui.service';
9
9
  import { CleanerService } from '../../shared/cleaner/cleaner.service';
10
+ import { DepsService } from '../../shared/deps/deps.service';
10
11
  import { TokenizerService } from '../../shared/tokenizer/tokenizer.service';
11
12
 
12
13
  type OutputFormat = 'xml' | 'text';
@@ -20,6 +21,9 @@ type PackOptions = {
20
21
  list?: boolean;
21
22
  format?: OutputFormat;
22
23
  clean?: boolean;
24
+ deps?: boolean;
25
+ depsDepth?: number;
26
+ explain?: boolean;
23
27
  };
24
28
 
25
29
  type TemplateContext = {
@@ -41,6 +45,7 @@ export class PackCommand extends CommandRunner {
41
45
  private readonly fsService: FsService,
42
46
  private readonly tokenizer: TokenizerService,
43
47
  private readonly cleaner: CleanerService,
48
+ private readonly depsService: DepsService,
44
49
  ) {
45
50
  super();
46
51
  }
@@ -98,6 +103,36 @@ export class PackCommand extends CommandRunner {
98
103
  return true;
99
104
  }
100
105
 
106
+ @Option({
107
+ flags: '--deps',
108
+ description:
109
+ 'Trace imports from entry point(s) and include their dependencies',
110
+ })
111
+ parseDeps(): boolean {
112
+ return true;
113
+ }
114
+
115
+ @Option({
116
+ flags: '--deps-depth <n>',
117
+ description: 'Max import depth when using --deps (default: unlimited)',
118
+ })
119
+ parseDepsDepth(value: string): number {
120
+ const n = Number.parseInt(value, 10);
121
+ if (Number.isNaN(n) || n < 1) {
122
+ this.ui.log.warn(`Invalid --deps-depth "${value}", ignoring`);
123
+ return Infinity;
124
+ }
125
+ return n;
126
+ }
127
+
128
+ @Option({
129
+ flags: '--explain',
130
+ description: 'Print why each file was included (requires --deps)',
131
+ })
132
+ parseExplain(): boolean {
133
+ return true;
134
+ }
135
+
101
136
  @Option({
102
137
  flags: '-f, --format <format>',
103
138
  description: 'Output format: xml (default) or text',
@@ -110,7 +145,7 @@ export class PackCommand extends CommandRunner {
110
145
  return value;
111
146
  }
112
147
 
113
- async run(_inputs: string[], options: PackOptions): Promise<void> {
148
+ async run(inputs: string[], options: PackOptions): Promise<void> {
114
149
  const spinner = this.ui
115
150
  .createSpinner({ text: 'Collecting files...' })
116
151
  .start();
@@ -118,13 +153,39 @@ export class PackCommand extends CommandRunner {
118
153
  try {
119
154
  const { packer } = this.configService.getConfig();
120
155
  const extraExcludes = options.exclude ?? [];
121
- const files = await this.fsService.findProjectFiles({
122
- excludeBinary: true,
123
- useGitignore: packer.useGitignore,
124
- ignore: [...packer.ignore, ...extraExcludes],
125
- contentBasedBinaryDetection: packer.contentBasedBinaryDetection,
126
- rootPaths: options.path,
127
- });
156
+
157
+ let files: string[];
158
+ let explainMap: Map<string, string> | undefined;
159
+
160
+ if (options.deps) {
161
+ if (inputs.length === 0) {
162
+ spinner.error('--deps requires at least one entry file as argument');
163
+ this.ui.log.error('Usage: kodu pack <entry.ts> [more.ts...] --deps');
164
+ process.exitCode = 1;
165
+ return;
166
+ }
167
+
168
+ spinner.text = 'Resolving dependency graph...';
169
+ const result = this.depsService.collectDependencies(
170
+ inputs,
171
+ process.cwd(),
172
+ {
173
+ maxDepth: options.depsDepth,
174
+ includeTypes: true,
175
+ includeDynamic: false,
176
+ },
177
+ );
178
+ files = result.files;
179
+ explainMap = result.explain;
180
+ } else {
181
+ files = await this.fsService.findProjectFiles({
182
+ excludeBinary: true,
183
+ useGitignore: packer.useGitignore,
184
+ ignore: [...packer.ignore, ...extraExcludes],
185
+ contentBasedBinaryDetection: packer.contentBasedBinaryDetection,
186
+ rootPaths: inputs.length > 0 ? inputs : options.path,
187
+ });
188
+ }
128
189
 
129
190
  if (files.length === 0) {
130
191
  spinner.stop('No files to pack.');
@@ -135,11 +196,28 @@ export class PackCommand extends CommandRunner {
135
196
  if (options.list) {
136
197
  spinner.success(`Found ${files.length} files`);
137
198
  for (const file of files) {
138
- this.ui.log.info(file);
199
+ if (options.explain && explainMap) {
200
+ const absFile = path.resolve(process.cwd(), file);
201
+ const reason =
202
+ explainMap.get(absFile) ?? explainMap.get(file) ?? '';
203
+ this.ui.log.info(`${file} ← ${reason}`);
204
+ } else {
205
+ this.ui.log.info(file);
206
+ }
139
207
  }
140
208
  return;
141
209
  }
142
210
 
211
+ if (options.explain && explainMap) {
212
+ spinner.success(`Found ${files.length} files`);
213
+ this.ui.log.info('Dependency explanation:');
214
+ for (const file of files) {
215
+ const absFile = path.resolve(process.cwd(), file);
216
+ const reason = explainMap.get(absFile) ?? explainMap.get(file) ?? '';
217
+ this.ui.log.info(` ${file} ← ${reason}`);
218
+ }
219
+ }
220
+
143
221
  const format: OutputFormat = options.format ?? 'xml';
144
222
  const context = await this.buildContext(files, format, options.clean);
145
223
  const fileList = files.join('\n');
@@ -3,11 +3,12 @@ import { ConfigModule } from '../../core/config/config.module';
3
3
  import { FsModule } from '../../core/file-system/fs.module';
4
4
  import { UiModule } from '../../core/ui/ui.module';
5
5
  import { CleanerService } from '../../shared/cleaner/cleaner.service';
6
+ import { DepsModule } from '../../shared/deps/deps.module';
6
7
  import { TokenizerModule } from '../../shared/tokenizer/tokenizer.module';
7
8
  import { PackCommand } from './pack.command';
8
9
 
9
10
  @Module({
10
- imports: [ConfigModule, UiModule, FsModule, TokenizerModule],
11
+ imports: [ConfigModule, UiModule, FsModule, TokenizerModule, DepsModule],
11
12
  providers: [PackCommand, CleanerService],
12
13
  })
13
14
  export class PackModule {}
@@ -0,0 +1,8 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { DepsService } from './deps.service';
3
+
4
+ @Module({
5
+ providers: [DepsService],
6
+ exports: [DepsService],
7
+ })
8
+ export class DepsModule {}
@@ -0,0 +1,175 @@
1
+ import path from 'node:path';
2
+ import { Injectable } from '@nestjs/common';
3
+ import { Project } from 'ts-morph';
4
+
5
+ export type DepsResult = {
6
+ files: string[];
7
+ explain: Map<string, string>;
8
+ };
9
+
10
+ type CollectOptions = {
11
+ maxDepth?: number;
12
+ includeTypes?: boolean;
13
+ includeDynamic?: boolean;
14
+ };
15
+
16
+ @Injectable()
17
+ export class DepsService {
18
+ collectDependencies(
19
+ entryFiles: string[],
20
+ projectRoot: string,
21
+ options: CollectOptions = {},
22
+ ): DepsResult {
23
+ const {
24
+ maxDepth = Infinity,
25
+ includeTypes = true,
26
+ includeDynamic = false,
27
+ } = options;
28
+
29
+ const tsConfigPath = this.findTsConfig(projectRoot);
30
+ const project = tsConfigPath
31
+ ? new Project({
32
+ tsConfigFilePath: tsConfigPath,
33
+ skipAddingFilesFromTsConfig: true,
34
+ })
35
+ : new Project({
36
+ compilerOptions: {
37
+ allowJs: true,
38
+ resolveJsonModule: true,
39
+ moduleResolution: 2, // NodeJs
40
+ },
41
+ });
42
+
43
+ const visited = new Set<string>();
44
+ const explain = new Map<string, string>();
45
+
46
+ const absEntries = entryFiles.map((f) =>
47
+ path.isAbsolute(f) ? f : path.resolve(projectRoot, f),
48
+ );
49
+
50
+ for (const entry of absEntries) {
51
+ explain.set(entry, 'entry point');
52
+ this.collect(
53
+ project,
54
+ entry,
55
+ projectRoot,
56
+ visited,
57
+ explain,
58
+ 0,
59
+ maxDepth,
60
+ includeTypes,
61
+ includeDynamic,
62
+ );
63
+ }
64
+
65
+ const files = [...visited].map((abs) =>
66
+ path.relative(projectRoot, abs).split(path.sep).join(path.posix.sep),
67
+ );
68
+
69
+ return { files, explain };
70
+ }
71
+
72
+ private collect(
73
+ project: Project,
74
+ absFile: string,
75
+ projectRoot: string,
76
+ visited: Set<string>,
77
+ explain: Map<string, string>,
78
+ depth: number,
79
+ maxDepth: number,
80
+ includeTypes: boolean,
81
+ includeDynamic: boolean,
82
+ ): void {
83
+ if (visited.has(absFile)) return;
84
+ visited.add(absFile);
85
+
86
+ if (depth >= maxDepth) return;
87
+
88
+ let sourceFile = project.getSourceFile(absFile);
89
+ if (!sourceFile) {
90
+ try {
91
+ sourceFile = project.addSourceFileAtPath(absFile);
92
+ } catch {
93
+ return;
94
+ }
95
+ }
96
+
97
+ const relFrom = path
98
+ .relative(projectRoot, absFile)
99
+ .split(path.sep)
100
+ .join(path.posix.sep);
101
+
102
+ for (const importDecl of sourceFile.getImportDeclarations()) {
103
+ if (!includeTypes && importDecl.isTypeOnly()) continue;
104
+
105
+ const resolved = importDecl.getModuleSpecifierSourceFile();
106
+ if (!resolved) continue;
107
+
108
+ const absResolved = resolved.getFilePath();
109
+ if (absResolved.includes('node_modules')) continue;
110
+
111
+ if (!explain.has(absResolved)) {
112
+ const what = importDecl.isTypeOnly() ? 'type import' : 'import';
113
+ explain.set(absResolved, `${what} from ${relFrom}`);
114
+ }
115
+
116
+ this.collect(
117
+ project,
118
+ absResolved,
119
+ projectRoot,
120
+ visited,
121
+ explain,
122
+ depth + 1,
123
+ maxDepth,
124
+ includeTypes,
125
+ includeDynamic,
126
+ );
127
+ }
128
+
129
+ void includeDynamic;
130
+
131
+ for (const exportDecl of sourceFile.getExportDeclarations()) {
132
+ const resolved = exportDecl.getModuleSpecifierSourceFile();
133
+ if (!resolved) continue;
134
+
135
+ const absResolved = resolved.getFilePath();
136
+ if (absResolved.includes('node_modules')) continue;
137
+
138
+ if (!explain.has(absResolved)) {
139
+ const relFrom2 = path
140
+ .relative(projectRoot, absFile)
141
+ .split(path.sep)
142
+ .join(path.posix.sep);
143
+ explain.set(absResolved, `re-export from ${relFrom2}`);
144
+ }
145
+
146
+ this.collect(
147
+ project,
148
+ absResolved,
149
+ projectRoot,
150
+ visited,
151
+ explain,
152
+ depth + 1,
153
+ maxDepth,
154
+ includeTypes,
155
+ includeDynamic,
156
+ );
157
+ }
158
+ }
159
+
160
+ private findTsConfig(projectRoot: string): string | undefined {
161
+ const candidates = [
162
+ path.join(projectRoot, 'tsconfig.json'),
163
+ path.join(projectRoot, 'tsconfig.base.json'),
164
+ ];
165
+ for (const c of candidates) {
166
+ try {
167
+ require('node:fs').accessSync(c);
168
+ return c;
169
+ } catch {
170
+ // not found
171
+ }
172
+ }
173
+ return undefined;
174
+ }
175
+ }