@principal-ai/codebase-composition 0.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 (129) hide show
  1. package/README.md +67 -0
  2. package/dist/index.d.ts +9 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +23 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/modules/DependencyLayerModule.d.ts +44 -0
  7. package/dist/modules/DependencyLayerModule.d.ts.map +1 -0
  8. package/dist/modules/DependencyLayerModule.js +286 -0
  9. package/dist/modules/DependencyLayerModule.js.map +1 -0
  10. package/dist/modules/FileSystemModule.d.ts +30 -0
  11. package/dist/modules/FileSystemModule.d.ts.map +1 -0
  12. package/dist/modules/FileSystemModule.js +46 -0
  13. package/dist/modules/FileSystemModule.js.map +1 -0
  14. package/dist/modules/FileTypeLayerModule.d.ts +34 -0
  15. package/dist/modules/FileTypeLayerModule.d.ts.map +1 -0
  16. package/dist/modules/FileTypeLayerModule.js +169 -0
  17. package/dist/modules/FileTypeLayerModule.js.map +1 -0
  18. package/dist/modules/FrameworkLayerModule.d.ts +22 -0
  19. package/dist/modules/FrameworkLayerModule.d.ts.map +1 -0
  20. package/dist/modules/FrameworkLayerModule.js +388 -0
  21. package/dist/modules/FrameworkLayerModule.js.map +1 -0
  22. package/dist/modules/PackageLayerModule.d.ts +23 -0
  23. package/dist/modules/PackageLayerModule.d.ts.map +1 -0
  24. package/dist/modules/PackageLayerModule.js +810 -0
  25. package/dist/modules/PackageLayerModule.js.map +1 -0
  26. package/dist/modules/TypeExtractionModule.d.ts +37 -0
  27. package/dist/modules/TypeExtractionModule.d.ts.map +1 -0
  28. package/dist/modules/TypeExtractionModule.js +180 -0
  29. package/dist/modules/TypeExtractionModule.js.map +1 -0
  30. package/dist/modules/VersionControlLayerModule.d.ts +10 -0
  31. package/dist/modules/VersionControlLayerModule.d.ts.map +1 -0
  32. package/dist/modules/VersionControlLayerModule.js +32 -0
  33. package/dist/modules/VersionControlLayerModule.js.map +1 -0
  34. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/index.d.ts +4 -0
  35. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/index.d.ts.map +1 -0
  36. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/index.js +7 -0
  37. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/index.js.map +1 -0
  38. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/models/category.d.ts +15 -0
  39. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/models/category.d.ts.map +1 -0
  40. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/models/category.js +3 -0
  41. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/models/category.js.map +1 -0
  42. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/models/product.d.ts +34 -0
  43. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/models/product.d.ts.map +1 -0
  44. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/models/product.js +23 -0
  45. package/dist/modules/__fixtures__/typescript-packages/complex-types/src/models/product.js.map +1 -0
  46. package/dist/modules/__fixtures__/typescript-packages/simple-types/index.d.ts +39 -0
  47. package/dist/modules/__fixtures__/typescript-packages/simple-types/index.d.ts.map +1 -0
  48. package/dist/modules/__fixtures__/typescript-packages/simple-types/index.js +39 -0
  49. package/dist/modules/__fixtures__/typescript-packages/simple-types/index.js.map +1 -0
  50. package/dist/modules/extractors/TypeScriptExtractor.d.ts +18 -0
  51. package/dist/modules/extractors/TypeScriptExtractor.d.ts.map +1 -0
  52. package/dist/modules/extractors/TypeScriptExtractor.js +361 -0
  53. package/dist/modules/extractors/TypeScriptExtractor.js.map +1 -0
  54. package/dist/modules/index.d.ts +13 -0
  55. package/dist/modules/index.d.ts.map +1 -0
  56. package/dist/modules/index.js +21 -0
  57. package/dist/modules/index.js.map +1 -0
  58. package/dist/providers/GitVersionControlProvider.d.ts +108 -0
  59. package/dist/providers/GitVersionControlProvider.d.ts.map +1 -0
  60. package/dist/providers/GitVersionControlProvider.js +380 -0
  61. package/dist/providers/GitVersionControlProvider.js.map +1 -0
  62. package/dist/providers/PackageManagerApiProvider.d.ts +78 -0
  63. package/dist/providers/PackageManagerApiProvider.d.ts.map +1 -0
  64. package/dist/providers/PackageManagerApiProvider.js +14 -0
  65. package/dist/providers/PackageManagerApiProvider.js.map +1 -0
  66. package/dist/providers/index.d.ts +4 -0
  67. package/dist/providers/index.d.ts.map +1 -0
  68. package/dist/providers/index.js +10 -0
  69. package/dist/providers/index.js.map +1 -0
  70. package/dist/services/FilesystemService.d.ts +59 -0
  71. package/dist/services/FilesystemService.d.ts.map +1 -0
  72. package/dist/services/FilesystemService.js +391 -0
  73. package/dist/services/FilesystemService.js.map +1 -0
  74. package/dist/services/index.d.ts +2 -0
  75. package/dist/services/index.d.ts.map +1 -0
  76. package/dist/services/index.js +7 -0
  77. package/dist/services/index.js.map +1 -0
  78. package/dist/types/file-system.d.ts +7 -0
  79. package/dist/types/file-system.d.ts.map +1 -0
  80. package/dist/types/file-system.js +7 -0
  81. package/dist/types/file-system.js.map +1 -0
  82. package/dist/types/index.d.ts +4 -0
  83. package/dist/types/index.d.ts.map +1 -0
  84. package/dist/types/index.js +3 -0
  85. package/dist/types/index.js.map +1 -0
  86. package/dist/types/layer-types.d.ts +187 -0
  87. package/dist/types/layer-types.d.ts.map +1 -0
  88. package/dist/types/layer-types.js +7 -0
  89. package/dist/types/layer-types.js.map +1 -0
  90. package/dist/types/version-control-layer.d.ts +53 -0
  91. package/dist/types/version-control-layer.d.ts.map +1 -0
  92. package/dist/types/version-control-layer.js +3 -0
  93. package/dist/types/version-control-layer.js.map +1 -0
  94. package/dist/types/workspace-boundaries.d.ts +17 -0
  95. package/dist/types/workspace-boundaries.d.ts.map +1 -0
  96. package/dist/types/workspace-boundaries.js +7 -0
  97. package/dist/types/workspace-boundaries.js.map +1 -0
  98. package/package.json +42 -0
  99. package/src/index.ts +62 -0
  100. package/src/modules/DependencyLayerModule.ts +329 -0
  101. package/src/modules/FileSystemModule.ts +65 -0
  102. package/src/modules/FileTypeLayerModule.ts +199 -0
  103. package/src/modules/FrameworkLayerModule.ts +437 -0
  104. package/src/modules/PackageLayerModule.ts +979 -0
  105. package/src/modules/TypeExtractionModule.test.ts +340 -0
  106. package/src/modules/TypeExtractionModule.ts +180 -0
  107. package/src/modules/VersionControlLayerModule.ts +31 -0
  108. package/src/modules/__fixtures__/typescript-packages/complex-types/package.json +6 -0
  109. package/src/modules/__fixtures__/typescript-packages/complex-types/src/index.ts +6 -0
  110. package/src/modules/__fixtures__/typescript-packages/complex-types/src/models/category.ts +15 -0
  111. package/src/modules/__fixtures__/typescript-packages/complex-types/src/models/product.ts +48 -0
  112. package/src/modules/__fixtures__/typescript-packages/javascript-only/index.js +18 -0
  113. package/src/modules/__fixtures__/typescript-packages/javascript-only/package.json +5 -0
  114. package/src/modules/__fixtures__/typescript-packages/simple-types/index.ts +53 -0
  115. package/src/modules/__fixtures__/typescript-packages/simple-types/package.json +6 -0
  116. package/src/modules/extractors/README.md +55 -0
  117. package/src/modules/extractors/TypeScriptExtractor.ts +409 -0
  118. package/src/modules/index.ts +13 -0
  119. package/src/providers/GitVersionControlProvider.ts +500 -0
  120. package/src/providers/PackageManagerApiProvider.ts +108 -0
  121. package/src/providers/README.md +88 -0
  122. package/src/providers/index.ts +17 -0
  123. package/src/services/FilesystemService.ts +530 -0
  124. package/src/services/index.ts +2 -0
  125. package/src/types/file-system.ts +11 -0
  126. package/src/types/index.ts +24 -0
  127. package/src/types/layer-types.ts +264 -0
  128. package/src/types/version-control-layer.ts +87 -0
  129. package/src/types/workspace-boundaries.ts +17 -0
@@ -0,0 +1,979 @@
1
+ import * as TOML from 'js-toml';
2
+ import { parsePipRequirementsFile, Requirement } from 'pip-requirements-js';
3
+
4
+ import { FileTree } from '@principal-ai/repository-abstraction';
5
+ import { PackageLayer, FileSet, PackageCommand, ConfigFile } from '../types/layer-types';
6
+ import { WorkspaceBoundary } from '../types/workspace-boundaries';
7
+
8
+ // Type aliases for configuration objects
9
+ type ESLintConfig = unknown;
10
+ type PrettierConfig = unknown;
11
+ type JestConfig = unknown;
12
+ type BabelConfig = unknown;
13
+
14
+ // Dependency value types
15
+ type DependencyObject = {
16
+ version?: string;
17
+ [key: string]: unknown;
18
+ };
19
+ type DependencyValue = string | DependencyObject;
20
+
21
+ // Package manifest types
22
+ type PackageManifest = NodePackageJson | PyProjectToml | CargoToml;
23
+
24
+ interface NodePackageJson {
25
+ name?: string;
26
+ version?: string;
27
+ dependencies?: Record<string, string>;
28
+ devDependencies?: Record<string, string>;
29
+ peerDependencies?: Record<string, string>;
30
+ scripts?: Record<string, string>;
31
+ workspaces?: string[] | { packages: string[] };
32
+ packageManager?: string;
33
+ eslintConfig?: ESLintConfig;
34
+ prettier?: PrettierConfig;
35
+ jest?: JestConfig;
36
+ babel?: BabelConfig;
37
+ }
38
+
39
+ interface PyProjectToml {
40
+ tool?: {
41
+ poetry?: {
42
+ name?: string;
43
+ version?: string;
44
+ dependencies?: Record<string, DependencyValue>;
45
+ 'dev-dependencies'?: Record<string, DependencyValue>;
46
+ };
47
+ };
48
+ project?: {
49
+ name?: string;
50
+ version?: string;
51
+ dependencies?: string[];
52
+ };
53
+ }
54
+
55
+ interface CargoToml {
56
+ package?: {
57
+ name?: string;
58
+ version?: string;
59
+ };
60
+ dependencies?: Record<string, DependencyValue>;
61
+ 'dev-dependencies'?: Record<string, DependencyValue>;
62
+ workspace?: {
63
+ members?: string[];
64
+ };
65
+ }
66
+
67
+ // Generic parser interface
68
+ interface PackageManifestParser<T> {
69
+ manifestFileName: string;
70
+ packageType: 'node' | 'python' | 'cargo';
71
+
72
+ canParse(filename: string): boolean;
73
+ parseContent(rawContent: string): T | null;
74
+ extractPackageData(content: T, path: string): PackageLayer['packageData'] | null;
75
+ detectWorkspaces(content: T): string[] | null;
76
+ detectPackageManager(content: T, lockFiles: string[]): string;
77
+ detectConfigs?(
78
+ packagePath: string,
79
+ fileTree: FileTree,
80
+ manifestContent: T,
81
+ ): PackageLayer['configFiles'];
82
+ detectDocsFolder?(
83
+ packagePath: string,
84
+ fileTree: FileTree,
85
+ manifestContent: T,
86
+ ): string | undefined;
87
+ }
88
+
89
+ // Node.js package.json parser
90
+ class NodePackageParser implements PackageManifestParser<NodePackageJson> {
91
+ manifestFileName = 'package.json';
92
+ packageType = 'node' as const;
93
+
94
+ canParse(filename: string): boolean {
95
+ return filename.endsWith('package.json');
96
+ }
97
+
98
+ parseContent(rawContent: string): NodePackageJson | null {
99
+ try {
100
+ return JSON.parse(rawContent) as NodePackageJson;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ extractPackageData(content: NodePackageJson, path: string): PackageLayer['packageData'] | null {
107
+ // Handle cases where path is just "package.json" (root package)
108
+ // or a proper path like "packages/foo/package.json"
109
+ let packagePath: string;
110
+ if (path === 'package.json') {
111
+ packagePath = ''; // Root directory - empty string for root
112
+ } else if (path.endsWith('/package.json')) {
113
+ packagePath = path.slice(0, -13); // Remove '/package.json'
114
+ } else {
115
+ // Shouldn't happen, but handle gracefully
116
+ packagePath = path;
117
+ }
118
+ const availableCommands = this.extractCommands(content, packagePath);
119
+
120
+ return {
121
+ name: content.name || 'unnamed',
122
+ version: content.version,
123
+ path: packagePath,
124
+ packageManager: 'unknown' as const,
125
+ dependencies: content.dependencies || {},
126
+ devDependencies: content.devDependencies || {},
127
+ peerDependencies: content.peerDependencies || {},
128
+ isMonorepoRoot: !!content.workspaces,
129
+ isWorkspace: false, // Will be determined by context
130
+ parentPackage: undefined,
131
+ availableCommands,
132
+ };
133
+ }
134
+
135
+ private extractCommands(content: NodePackageJson, packagePath: string): PackageCommand[] {
136
+ const commands: PackageCommand[] = [];
137
+
138
+ // Extract npm scripts
139
+ if (content.scripts) {
140
+ Object.entries(content.scripts).forEach(([name, script]) => {
141
+ // Use npm as default, will be updated with correct package manager later
142
+ commands.push({
143
+ name,
144
+ command: `npm run ${name}`,
145
+ description: script.length > 50 ? script.substring(0, 47) + '...' : script,
146
+ type: 'script',
147
+ workingDirectory: packagePath,
148
+ });
149
+ });
150
+ }
151
+
152
+ // Add standard npm commands - these will also be updated with correct package manager
153
+ const standardCommands = [
154
+ { name: 'install', command: 'npm install', description: 'Install dependencies' },
155
+ { name: 'ci', command: 'npm ci', description: 'Clean install dependencies' },
156
+ { name: 'update', command: 'npm update', description: 'Update dependencies' },
157
+ ];
158
+
159
+ standardCommands.forEach(cmd => {
160
+ commands.push({
161
+ ...cmd,
162
+ type: 'standard',
163
+ workingDirectory: packagePath,
164
+ });
165
+ });
166
+
167
+ return commands;
168
+ }
169
+
170
+ detectWorkspaces(content: NodePackageJson): string[] | null {
171
+ if (Array.isArray(content.workspaces)) {
172
+ return content.workspaces;
173
+ }
174
+ if (
175
+ content.workspaces &&
176
+ typeof content.workspaces === 'object' &&
177
+ 'packages' in content.workspaces
178
+ ) {
179
+ return content.workspaces.packages || null;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ detectPackageManager(content: NodePackageJson, lockFiles: string[]): string {
185
+ // Check for packageManager field (corepack)
186
+ if (content.packageManager) {
187
+ const pm = content.packageManager.split('@')[0];
188
+ if (['npm', 'yarn', 'pnpm'].includes(pm)) {
189
+ return pm;
190
+ }
191
+ }
192
+
193
+ // Check lock files
194
+ if (lockFiles.some((f: string) => f.endsWith('yarn.lock'))) return 'yarn';
195
+ if (lockFiles.some((f: string) => f.endsWith('pnpm-lock.yaml'))) return 'pnpm';
196
+ if (lockFiles.some((f: string) => f.endsWith('package-lock.json'))) return 'npm';
197
+
198
+ // Default to npm instead of unknown for Node packages
199
+ return 'npm';
200
+ }
201
+
202
+ detectConfigs(
203
+ packagePath: string,
204
+ fileTree: FileTree,
205
+ manifestContent: NodePackageJson,
206
+ ): PackageLayer['configFiles'] {
207
+ const configs: PackageLayer['configFiles'] = {};
208
+
209
+ // Define config patterns for Node/JavaScript projects
210
+ const configPatterns: Record<string, string[]> = {
211
+ knip: [
212
+ 'knip.json',
213
+ 'knip.jsonc',
214
+ '.knip.json',
215
+ 'knip.config.js',
216
+ 'knip.config.ts',
217
+ 'knip.config.mjs',
218
+ ],
219
+ eslint: [
220
+ '.eslintrc',
221
+ '.eslintrc.js',
222
+ '.eslintrc.json',
223
+ '.eslintrc.yml',
224
+ '.eslintrc.yaml',
225
+ 'eslint.config.js',
226
+ 'eslint.config.mjs',
227
+ '.eslintrc.cjs',
228
+ ],
229
+ prettier: [
230
+ '.prettierrc',
231
+ '.prettierrc.js',
232
+ '.prettierrc.json',
233
+ '.prettierrc.yml',
234
+ '.prettierrc.yaml',
235
+ 'prettier.config.js',
236
+ '.prettierrc.toml',
237
+ ],
238
+ typescript: ['tsconfig.json', 'tsconfig.base.json'],
239
+ jest: [
240
+ 'jest.config.js',
241
+ 'jest.config.ts',
242
+ 'jest.config.mjs',
243
+ 'jest.config.cjs',
244
+ 'jest.config.json',
245
+ ],
246
+ vitest: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs'],
247
+ webpack: ['webpack.config.js', 'webpack.config.ts', 'webpack.config.mjs'],
248
+ vite: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'],
249
+ rollup: ['rollup.config.js', 'rollup.config.ts', 'rollup.config.mjs'],
250
+ babel: ['.babelrc', '.babelrc.js', '.babelrc.json', 'babel.config.js', 'babel.config.json'],
251
+ dockerfile: ['Dockerfile', 'dockerfile', 'Dockerfile.dev', 'Dockerfile.prod'],
252
+ gitignore: ['.gitignore'],
253
+ editorconfig: ['.editorconfig'],
254
+ };
255
+
256
+ // Normalize package path
257
+ const normalizedPath =
258
+ packagePath === '.' || packagePath === ''
259
+ ? ''
260
+ : packagePath.endsWith('/')
261
+ ? packagePath
262
+ : packagePath + '/';
263
+
264
+ // Check files in tree
265
+ if (fileTree.allFiles) {
266
+ for (const [tool, patterns] of Object.entries(configPatterns)) {
267
+ for (const pattern of patterns) {
268
+ const fullPath = normalizedPath + pattern;
269
+ const fileExists = fileTree.allFiles.some(
270
+ f => f.path === fullPath || f.relativePath === fullPath,
271
+ );
272
+
273
+ if (fileExists) {
274
+ configs[tool] = {
275
+ path: fullPath,
276
+ exists: true,
277
+ type: this.getConfigFileType(pattern),
278
+ };
279
+ break; // Found config for this tool
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // Check for inline configs in package.json
286
+ if (manifestContent) {
287
+ if (manifestContent.eslintConfig) {
288
+ configs.eslint = {
289
+ path: normalizedPath + 'package.json',
290
+ exists: true,
291
+ type: 'json',
292
+ isInline: true,
293
+ inlineField: 'eslintConfig',
294
+ };
295
+ }
296
+
297
+ if (manifestContent.prettier) {
298
+ configs.prettier = {
299
+ path: normalizedPath + 'package.json',
300
+ exists: true,
301
+ type: 'json',
302
+ isInline: true,
303
+ inlineField: 'prettier',
304
+ };
305
+ }
306
+
307
+ if (manifestContent.jest) {
308
+ configs.jest = {
309
+ path: normalizedPath + 'package.json',
310
+ exists: true,
311
+ type: 'json',
312
+ isInline: true,
313
+ inlineField: 'jest',
314
+ };
315
+ }
316
+
317
+ if (manifestContent.babel) {
318
+ configs.babel = {
319
+ path: normalizedPath + 'package.json',
320
+ exists: true,
321
+ type: 'json',
322
+ isInline: true,
323
+ inlineField: 'babel',
324
+ };
325
+ }
326
+ }
327
+
328
+ return configs;
329
+ }
330
+
331
+ private getConfigFileType(filename: string): ConfigFile['type'] {
332
+ if (filename.endsWith('.json') || filename.endsWith('.jsonc')) return 'json';
333
+ if (filename.endsWith('.yml') || filename.endsWith('.yaml')) return 'yaml';
334
+ if (filename.endsWith('.toml')) return 'toml';
335
+ if (filename.endsWith('.js') || filename.endsWith('.mjs') || filename.endsWith('.cjs'))
336
+ return 'js';
337
+ if (filename.endsWith('.ts') || filename.endsWith('.mts') || filename.endsWith('.cts'))
338
+ return 'ts';
339
+ if (filename.endsWith('.ini')) return 'ini';
340
+ return 'custom';
341
+ }
342
+
343
+ detectDocsFolder(
344
+ packagePath: string,
345
+ fileTree: FileTree,
346
+ _manifestContent: NodePackageJson,
347
+ ): string | undefined {
348
+ // Look for common documentation folder names
349
+ const docsFolderNames = ['docs', 'documentation', 'doc'];
350
+
351
+ // Normalize package path
352
+ let packagePrefix = packagePath === '.' || packagePath === '' ? '' : packagePath;
353
+ if (packagePrefix && !packagePrefix.endsWith('/')) {
354
+ packagePrefix += '/';
355
+ }
356
+
357
+ if (!fileTree.allFiles) return undefined;
358
+
359
+ // Look for root-level docs folders first (preferred)
360
+ for (const folderName of docsFolderNames) {
361
+ const expectedPath = packagePrefix + folderName;
362
+ const hasFiles = fileTree.allFiles.some(file =>
363
+ file.path?.toLowerCase().startsWith(expectedPath.toLowerCase() + '/'),
364
+ );
365
+
366
+ if (hasFiles) {
367
+ // Find the actual folder name with correct case
368
+ const actualPath = fileTree.allFiles.find(file =>
369
+ file.path?.toLowerCase().startsWith(expectedPath.toLowerCase() + '/'),
370
+ )?.path;
371
+
372
+ if (actualPath) {
373
+ const actualFolderPath = actualPath.substring(
374
+ 0,
375
+ actualPath.indexOf('/', packagePrefix.length),
376
+ );
377
+ return actualFolderPath.substring(packagePrefix.length);
378
+ }
379
+ return folderName; // Fallback to lowercase version
380
+ }
381
+ }
382
+
383
+ // If no explicit docs folder found, look for directories with high concentration of docs
384
+ const potentialFolders = new Map<string, { docFiles: number; totalFiles: number }>();
385
+
386
+ for (const file of fileTree.allFiles) {
387
+ if (!file.path) continue;
388
+
389
+ // Only consider files within this package
390
+ if (packagePrefix !== '' && !file.path.startsWith(packagePrefix)) {
391
+ continue;
392
+ }
393
+
394
+ const relativePath =
395
+ packagePrefix === '' ? file.path : file.path.substring(packagePrefix.length);
396
+ const pathParts = relativePath.split('/');
397
+
398
+ // Only look at direct subdirectories (depth 1)
399
+ if (pathParts.length === 2) {
400
+ const dirName = pathParts[0];
401
+ const fileName = pathParts[1];
402
+
403
+ if (!potentialFolders.has(dirName)) {
404
+ potentialFolders.set(dirName, { docFiles: 0, totalFiles: 0 });
405
+ }
406
+
407
+ const stats = potentialFolders.get(dirName)!;
408
+ stats.totalFiles++;
409
+
410
+ if (this.isDocumentationFile(fileName)) {
411
+ stats.docFiles++;
412
+ }
413
+ }
414
+ }
415
+
416
+ // Find the best candidate folder (highest percentage of docs, minimum 2 doc files)
417
+ let bestFolder: string | undefined;
418
+ let bestScore = 0.5; // Must be at least 50% docs
419
+
420
+ for (const [folderName, stats] of Array.from(potentialFolders.entries())) {
421
+ if (stats.docFiles >= 2) {
422
+ const score = stats.docFiles / stats.totalFiles;
423
+ if (score > bestScore) {
424
+ bestScore = score;
425
+ bestFolder = folderName;
426
+ }
427
+ }
428
+ }
429
+
430
+ return bestFolder;
431
+ }
432
+
433
+ private isDocumentationFile(fileName: string): boolean {
434
+ const lowerName = fileName.toLowerCase();
435
+
436
+ // Documentation file extensions
437
+ const docExtensions = ['.md', '.mdx', '.rst', '.txt', '.adoc', '.asciidoc'];
438
+ const hasDocExtension = docExtensions.some(ext => lowerName.endsWith(ext));
439
+
440
+ if (hasDocExtension) return true;
441
+
442
+ // Documentation file name patterns
443
+ const docPatterns = [
444
+ /^readme/i,
445
+ /^changelog/i,
446
+ /^changes/i,
447
+ /^history/i,
448
+ /^license/i,
449
+ /^copying/i,
450
+ /^install/i,
451
+ /^usage/i,
452
+ /^guide/i,
453
+ /^tutorial/i,
454
+ /^manual/i,
455
+ /^faq/i,
456
+ /^api/i,
457
+ /^reference/i,
458
+ /^spec/i,
459
+ /^specification/i,
460
+ ];
461
+
462
+ return docPatterns.some(pattern => pattern.test(fileName));
463
+ }
464
+ }
465
+
466
+ // Python pyproject.toml parser - simplified version
467
+ class PythonPackageParser implements PackageManifestParser<PyProjectToml> {
468
+ manifestFileName = 'pyproject.toml';
469
+ packageType = 'python' as const;
470
+
471
+ canParse(filename: string): boolean {
472
+ return (
473
+ filename.endsWith('pyproject.toml') ||
474
+ filename.endsWith('setup.py') ||
475
+ filename.endsWith('requirements.txt')
476
+ );
477
+ }
478
+
479
+ parseContent(rawContent: string): PyProjectToml | null {
480
+ try {
481
+ return TOML.load(rawContent) as PyProjectToml;
482
+ } catch {
483
+ return null;
484
+ }
485
+ }
486
+
487
+ extractPackageData(content: PyProjectToml, path: string): PackageLayer['packageData'] | null {
488
+ // Extract package directory path from manifest path
489
+ let packagePath: string;
490
+ const manifestName = path.split('/').pop() || '';
491
+ if (path === manifestName) {
492
+ // Just the filename, package is at root
493
+ packagePath = ''; // Empty string for root
494
+ } else {
495
+ // Remove the manifest filename from path
496
+ packagePath = path.substring(0, path.lastIndexOf('/'));
497
+ }
498
+
499
+ // Handle pyproject.toml structure
500
+ const poetryData = content.tool?.poetry;
501
+ const projectData = content.project;
502
+
503
+ const name = poetryData?.name || projectData?.name || 'unnamed';
504
+ const version = poetryData?.version || projectData?.version;
505
+
506
+ // Extract dependencies
507
+ const dependencies: Record<string, string> = {};
508
+ const devDependencies: Record<string, string> = {};
509
+
510
+ // Poetry format
511
+ if (poetryData?.dependencies) {
512
+ Object.entries(poetryData.dependencies).forEach(([key, value]) => {
513
+ if (key !== 'python') {
514
+ dependencies[key] = typeof value === 'string' ? value : JSON.stringify(value);
515
+ }
516
+ });
517
+ }
518
+
519
+ if (poetryData?.['dev-dependencies']) {
520
+ Object.entries(poetryData['dev-dependencies']).forEach(([key, value]) => {
521
+ devDependencies[key] = typeof value === 'string' ? value : JSON.stringify(value);
522
+ });
523
+ }
524
+
525
+ // PEP 621 format
526
+ if (projectData?.dependencies && Array.isArray(projectData.dependencies)) {
527
+ try {
528
+ // Use pip-requirements-js to properly parse PEP 508 dependency specifications
529
+ const depString = projectData.dependencies.join('\n');
530
+ const requirements = parsePipRequirementsFile(depString);
531
+
532
+ requirements.forEach((req: Requirement) => {
533
+ if (req.type === 'ProjectName' && req.name) {
534
+ let version = '*';
535
+ if (req.versionSpec && req.versionSpec.length > 0) {
536
+ // Use the first version spec for simplicity
537
+ const firstSpec = req.versionSpec[0];
538
+ version = firstSpec.version || '*';
539
+ }
540
+ dependencies[req.name] = version;
541
+ }
542
+ });
543
+ } catch (error) {
544
+ console.warn(
545
+ 'Failed to parse PEP 621 dependencies with pip-requirements-js, falling back to simple parsing:',
546
+ error,
547
+ );
548
+ // Fallback to simple parsing if pip-requirements-js fails
549
+ projectData.dependencies.forEach((dep: string) => {
550
+ const cleanDep = dep.trim();
551
+ const spaceIndex = cleanDep.indexOf(' ');
552
+ if (spaceIndex > 0) {
553
+ dependencies[cleanDep.substring(0, spaceIndex)] = '*';
554
+ } else {
555
+ dependencies[cleanDep] = '*';
556
+ }
557
+ });
558
+ }
559
+ }
560
+
561
+ const packageManager = this.detectPythonPackageManager(content);
562
+
563
+ return {
564
+ name,
565
+ version,
566
+ path: packagePath,
567
+ packageManager,
568
+ dependencies,
569
+ devDependencies,
570
+ peerDependencies: {},
571
+ isMonorepoRoot: false,
572
+ isWorkspace: false,
573
+ parentPackage: undefined,
574
+ availableCommands: [],
575
+ };
576
+ }
577
+
578
+ detectWorkspaces(_content: PyProjectToml): string[] | null {
579
+ // Python doesn't have built-in workspace support like npm
580
+ return null;
581
+ }
582
+
583
+ detectPackageManager(content: PyProjectToml, _lockFiles: string[]): string {
584
+ return this.detectPythonPackageManager(content);
585
+ }
586
+
587
+ private detectPythonPackageManager(
588
+ content: PyProjectToml,
589
+ ): 'pip' | 'poetry' | 'pipenv' | 'unknown' {
590
+ // Check for Poetry
591
+ if (content.tool?.poetry) return 'poetry';
592
+
593
+ // Default to pip
594
+ return 'pip';
595
+ }
596
+
597
+ detectDocsFolder(
598
+ packagePath: string,
599
+ fileTree: FileTree,
600
+ _manifestContent: PyProjectToml,
601
+ ): string | undefined {
602
+ // Reuse the same logic as NodePackageParser
603
+ const nodeParser = new NodePackageParser();
604
+ return nodeParser.detectDocsFolder(packagePath, fileTree, {} as NodePackageJson);
605
+ }
606
+ }
607
+
608
+ // Rust Cargo.toml parser - simplified version
609
+ class CargoPackageParser implements PackageManifestParser<CargoToml> {
610
+ manifestFileName = 'Cargo.toml';
611
+ packageType = 'cargo' as const;
612
+
613
+ canParse(filename: string): boolean {
614
+ return filename.endsWith('Cargo.toml');
615
+ }
616
+
617
+ parseContent(rawContent: string): CargoToml | null {
618
+ try {
619
+ return TOML.load(rawContent) as CargoToml;
620
+ } catch {
621
+ return null;
622
+ }
623
+ }
624
+
625
+ extractPackageData(content: CargoToml, path: string): PackageLayer['packageData'] | null {
626
+ const packageData = content.package;
627
+ if (!packageData) return null;
628
+
629
+ // Extract package directory path from manifest path
630
+ let packagePath: string;
631
+ if (path === 'Cargo.toml') {
632
+ packagePath = ''; // Root directory - empty string for root
633
+ } else if (path.endsWith('/Cargo.toml')) {
634
+ packagePath = path.slice(0, -11); // Remove '/Cargo.toml'
635
+ } else {
636
+ // Shouldn't happen, but handle gracefully
637
+ packagePath = path;
638
+ }
639
+
640
+ // Extract dependencies
641
+ const dependencies: Record<string, string> = {};
642
+ const devDependencies: Record<string, string> = {};
643
+
644
+ if (content.dependencies) {
645
+ Object.entries(content.dependencies).forEach(([key, value]) => {
646
+ dependencies[key] = typeof value === 'string' ? value : JSON.stringify(value);
647
+ });
648
+ }
649
+
650
+ if (content['dev-dependencies']) {
651
+ Object.entries(content['dev-dependencies']).forEach(([key, value]) => {
652
+ devDependencies[key] = typeof value === 'string' ? value : JSON.stringify(value);
653
+ });
654
+ }
655
+
656
+ return {
657
+ name: packageData.name || 'unnamed',
658
+ version: packageData.version,
659
+ path: packagePath,
660
+ packageManager: 'cargo' as const,
661
+ dependencies,
662
+ devDependencies,
663
+ peerDependencies: {},
664
+ isMonorepoRoot: !!content.workspace,
665
+ isWorkspace: false,
666
+ parentPackage: undefined,
667
+ availableCommands: [],
668
+ };
669
+ }
670
+
671
+ detectWorkspaces(content: CargoToml): string[] | null {
672
+ return content.workspace?.members || null;
673
+ }
674
+
675
+ detectPackageManager(): string {
676
+ return 'cargo';
677
+ }
678
+
679
+ detectDocsFolder(
680
+ packagePath: string,
681
+ fileTree: FileTree,
682
+ _manifestContent: CargoToml,
683
+ ): string | undefined {
684
+ // Reuse the same logic as NodePackageParser
685
+ const nodeParser = new NodePackageParser();
686
+ return nodeParser.detectDocsFolder(packagePath, fileTree, {} as NodePackageJson);
687
+ }
688
+ }
689
+
690
+ export class PackageLayerModule {
691
+ private parsers: PackageManifestParser<PackageManifest>[] = [
692
+ new NodePackageParser(),
693
+ new PythonPackageParser(),
694
+ new CargoPackageParser(),
695
+ ];
696
+
697
+ /**
698
+ * Extract package information from the file tree
699
+ * @param fileTree - The file tree to analyze
700
+ * @param fileReader - Optional function to read file contents. If not provided, manifest detection only.
701
+ */
702
+ async discoverPackages(
703
+ fileTree: FileTree,
704
+ fileReader?: (path: string) => Promise<string>,
705
+ ): Promise<PackageLayer[]> {
706
+ const packages: PackageLayer[] = [];
707
+
708
+ if (!fileTree.allFiles) return packages;
709
+
710
+ // Find all package manifest files
711
+ const manifestFiles = fileTree.allFiles.filter(file => {
712
+ if (!file.path) return false;
713
+ return this.parsers.some(parser => parser.canParse(file.path));
714
+ });
715
+
716
+ // Get all files for lock file detection
717
+ const allFilePaths = fileTree.allFiles.map(f => f.path).filter(Boolean) as string[];
718
+
719
+ for (const manifestFile of manifestFiles) {
720
+ if (!manifestFile.path) continue;
721
+
722
+ // Find appropriate parser
723
+ const parser = this.parsers.find(p => p.canParse(manifestFile.path));
724
+ if (!parser) continue;
725
+
726
+ // Get manifest content
727
+ let content: PackageManifest | null = null;
728
+
729
+ if (fileReader) {
730
+ try {
731
+ const fileContent = await fileReader(manifestFile.path);
732
+ if (fileContent) {
733
+ // Use the parser's parseContent method for type-safe parsing
734
+ content = parser.parseContent(fileContent);
735
+ }
736
+ } catch (error) {
737
+ console.warn(`Could not read or parse ${manifestFile.path}:`, error);
738
+ continue;
739
+ }
740
+ } else {
741
+ // If no file reader provided, create minimal package info from path
742
+ const packageName = manifestFile.path.split('/').slice(-2, -1)[0] || 'unnamed';
743
+ content = this.createMinimalManifest(parser.packageType, packageName);
744
+ }
745
+
746
+ if (!content) continue;
747
+
748
+ // Extract package data
749
+ const packageData = parser.extractPackageData(content, manifestFile.path);
750
+ if (!packageData) {
751
+ console.warn(
752
+ `[PackageLayerModule] extractPackageData returned null for ${manifestFile.path}`,
753
+ { content, parser: parser.packageType },
754
+ );
755
+ continue;
756
+ }
757
+
758
+ // Detect package manager
759
+ const dirPath = manifestFile.path.substring(0, manifestFile.path.lastIndexOf('/'));
760
+ const lockFilesInDir = allFilePaths.filter(f => f.startsWith(dirPath) && this.isLockFile(f));
761
+ packageData.packageManager = parser.detectPackageManager(
762
+ content,
763
+ lockFilesInDir,
764
+ ) as PackageLayer['packageData']['packageManager'];
765
+
766
+ // Update commands with correct package manager for Node packages
767
+ if (parser.packageType === 'node' && packageData.availableCommands) {
768
+ // Only replace if we detected a valid package manager
769
+ if (
770
+ packageData.packageManager &&
771
+ packageData.packageManager !== 'unknown' &&
772
+ packageData.packageManager !== 'npm'
773
+ ) {
774
+ packageData.availableCommands = packageData.availableCommands.map(cmd => {
775
+ // Update both script and standard commands
776
+ if (cmd.command.includes('npm ')) {
777
+ let pmCommand = cmd.command;
778
+
779
+ // Handle different package manager syntaxes
780
+ if (packageData.packageManager === 'yarn') {
781
+ pmCommand = pmCommand
782
+ .replace('npm run', 'yarn')
783
+ .replace('npm install', 'yarn install')
784
+ .replace('npm ci', 'yarn install --frozen-lockfile')
785
+ .replace('npm update', 'yarn upgrade');
786
+ } else if (packageData.packageManager === 'pnpm') {
787
+ pmCommand = pmCommand.replace('npm', 'pnpm');
788
+ }
789
+
790
+ return { ...cmd, command: pmCommand };
791
+ }
792
+ return cmd;
793
+ });
794
+ }
795
+ }
796
+
797
+ // Create file set for the manifest
798
+ const fileSet: FileSet = {
799
+ id: `package-manifest-${packageData.path}`,
800
+ name: parser.manifestFileName,
801
+ patterns: [
802
+ {
803
+ type: 'exact',
804
+ pattern: manifestFile.path,
805
+ description: `${parser.packageType} package manifest`,
806
+ },
807
+ ],
808
+ matchedFiles: [manifestFile.path],
809
+ fileCount: 1,
810
+ };
811
+
812
+ // Detect configuration files if the parser supports it
813
+ let configFiles: PackageLayer['configFiles'] | undefined;
814
+ if (parser.detectConfigs) {
815
+ configFiles = parser.detectConfigs(packageData.path, fileTree, content);
816
+ }
817
+
818
+ // Detect documentation folder if the parser supports it
819
+ let docsFolder: string | undefined;
820
+ if (parser.detectDocsFolder) {
821
+ docsFolder = parser.detectDocsFolder(packageData.path, fileTree, content);
822
+ }
823
+
824
+ // Create package layer
825
+ const layer: PackageLayer = {
826
+ id: `package-${parser.packageType}-${packageData.path.replace(/[^a-zA-Z0-9-]/g, '-')}`,
827
+ name: packageData.name,
828
+ type: parser.packageType,
829
+ enabled: true,
830
+ derivedFrom: {
831
+ fileSets: [fileSet],
832
+ derivationType: 'content',
833
+ description: `${parser.packageType} package defined in ${parser.manifestFileName}`,
834
+ contentExtraction: {
835
+ method: 'parse',
836
+ parser: {
837
+ format: parser.manifestFileName.endsWith('.json') ? 'json' : 'toml',
838
+ paths:
839
+ parser.packageType === 'node'
840
+ ? ['']
841
+ : parser.packageType === 'python'
842
+ ? ['tool.poetry', 'project']
843
+ : ['package'],
844
+ },
845
+ },
846
+ },
847
+ packageData,
848
+ configFiles,
849
+ docsFolder,
850
+ pillar: 'foundationHealth',
851
+ };
852
+
853
+ packages.push(layer);
854
+ }
855
+
856
+ // Determine workspace relationships
857
+ this.resolveWorkspaceRelationships(packages);
858
+
859
+ return packages;
860
+ }
861
+
862
+ /**
863
+ * Create package layers from workspace boundaries (backwards compatibility)
864
+ */
865
+ createPackageLayersFromBoundaries(boundaries: WorkspaceBoundary[]): PackageLayer[] {
866
+ return boundaries.map(boundary => {
867
+ const fileSet: FileSet = {
868
+ id: `package-manifest-${boundary.rootPath || 'root'}`,
869
+ name: 'package.json',
870
+ patterns: [
871
+ {
872
+ type: 'exact',
873
+ pattern: boundary.packageJsonPath,
874
+ description: 'Node.js package manifest',
875
+ },
876
+ ],
877
+ matchedFiles: [boundary.packageJsonPath],
878
+ fileCount: 1,
879
+ };
880
+
881
+ const packageData: PackageLayer['packageData'] = {
882
+ name: boundary.packageData?.name || boundary.name,
883
+ version: boundary.packageData?.version,
884
+ path: boundary.rootPath || '.',
885
+ packageManager: 'unknown',
886
+ dependencies: {},
887
+ devDependencies: {},
888
+ peerDependencies: {},
889
+ isMonorepoRoot: !!boundary.packageData?.workspaces,
890
+ isWorkspace: !boundary.isRoot && boundaries.length > 1,
891
+ parentPackage: boundary.isRoot ? undefined : 'root',
892
+ };
893
+
894
+ const layer: PackageLayer = {
895
+ id: `package-node-${boundary.id}`,
896
+ name: packageData.name,
897
+ type: 'node',
898
+ enabled: true,
899
+ derivedFrom: {
900
+ fileSets: [fileSet],
901
+ derivationType: 'content',
902
+ description: 'Node.js package defined in package.json',
903
+ },
904
+ packageData,
905
+ pillar: 'foundationHealth',
906
+ };
907
+
908
+ return layer;
909
+ });
910
+ }
911
+
912
+ private isLockFile(path: string): boolean {
913
+ const lockFiles = [
914
+ 'package-lock.json',
915
+ 'yarn.lock',
916
+ 'pnpm-lock.yaml',
917
+ 'poetry.lock',
918
+ 'Pipfile.lock',
919
+ 'Cargo.lock',
920
+ ];
921
+
922
+ return lockFiles.some(lock => path.endsWith(lock));
923
+ }
924
+
925
+ private resolveWorkspaceRelationships(packages: PackageLayer[]): void {
926
+ // Find root packages (typically at the repository root)
927
+ const _rootPackages = packages.filter(
928
+ p => p.packageData.path === '.' || p.packageData.path === '',
929
+ );
930
+
931
+ // Mark workspace packages
932
+ packages.forEach(pkg => {
933
+ if (pkg.packageData.path !== '.' && pkg.packageData.path !== '') {
934
+ pkg.packageData.isWorkspace = true;
935
+
936
+ // Find parent package
937
+ const parentPath = pkg.packageData.path.includes('/')
938
+ ? pkg.packageData.path.substring(0, pkg.packageData.path.lastIndexOf('/'))
939
+ : '.';
940
+
941
+ const parent = packages.find(p => p.packageData.path === parentPath);
942
+ if (parent) {
943
+ pkg.packageData.parentPackage = parent.packageData.name;
944
+ }
945
+ }
946
+ });
947
+ }
948
+
949
+ /**
950
+ * Create a minimal manifest for when file content isn't available
951
+ */
952
+ private createMinimalManifest(packageType: string, packageName: string): PackageManifest {
953
+ switch (packageType) {
954
+ case 'node':
955
+ return {
956
+ name: packageName,
957
+ version: '0.0.0',
958
+ dependencies: {},
959
+ devDependencies: {},
960
+ };
961
+ case 'python':
962
+ return {
963
+ project: {
964
+ name: packageName,
965
+ version: '0.0.0',
966
+ },
967
+ };
968
+ case 'cargo':
969
+ return {
970
+ package: {
971
+ name: packageName,
972
+ version: '0.0.0',
973
+ },
974
+ };
975
+ default:
976
+ return {};
977
+ }
978
+ }
979
+ }