@lakindu_perera/toren 1.0.7 → 1.0.8

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.
@@ -0,0 +1,515 @@
1
+ /**
2
+ * @fileoverview Toren — Important File Detector
3
+ *
4
+ * Identifies the most strategically significant files in a project, based
5
+ * on the already-scanned flat file list and detected project context.
6
+ *
7
+ * Design contract:
8
+ * - Pure function. Uses only the data already produced by scan().
9
+ * - No filesystem I/O. No directory traversal.
10
+ * - Deterministic: same input always produces the same output.
11
+ * - No duplicates: each path appears at most once (highest priority wins).
12
+ * - Sorted: highest priority first; ties broken alphabetically by path.
13
+ * - Never returns null; importantFiles is always an array.
14
+ * - Framework-specific files are only marked important when they exist.
15
+ *
16
+ * Priority anchors:
17
+ * 100 – Project manifest (package.json, pom.xml, Cargo.toml, …)
18
+ * 95 – Primary documentation (README.md)
19
+ * 90 – Primary detected entry point
20
+ * 85 – Framework configuration (next.config.*, vite.config.*, …)
21
+ * 82 – App-level framework files (root layout, root page, …)
22
+ * 80 – Environment documentation (.env.example)
23
+ * 78 – Secondary framework files (middleware, server entry)
24
+ * 75 – TypeScript / language config
25
+ * 72 – Project-specific config (routes, config/application)
26
+ * 70 – Container (Dockerfile)
27
+ * 68 – Container orchestration (docker-compose)
28
+ *
29
+ * @module detectors/important-files-detector
30
+ */
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types (JSDoc — no TypeScript dependency required)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * @typedef {Object} ImportantFile
38
+ * @property {string} path - POSIX relative path from the project root
39
+ * @property {string} type - Semantic role: 'manifest' | 'documentation' |
40
+ * 'entry-point' | 'configuration' | 'environment' |
41
+ * 'container' | 'utility'
42
+ * @property {string} reason - Human-readable explanation of why this file matters
43
+ * @property {number} priority - Sort weight; higher = more important
44
+ */
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Static candidate tables — files important for every project type
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Files that are generically important regardless of project type.
52
+ * Each entry is included only if the file actually exists in flatFiles.
53
+ *
54
+ * @type {ImportantFile[]}
55
+ */
56
+ const GENERIC_CANDIDATES = [
57
+ // ── Package manager manifests ─────────────────────────────────────────────
58
+ {
59
+ path: 'package.json',
60
+ type: 'manifest',
61
+ reason: 'Defines dependencies, scripts, and project metadata',
62
+ priority: 100,
63
+ },
64
+
65
+ // ── Primary documentation ─────────────────────────────────────────────────
66
+ {
67
+ path: 'README.md',
68
+ type: 'documentation',
69
+ reason: 'Primary project documentation',
70
+ priority: 95,
71
+ },
72
+ {
73
+ path: 'README',
74
+ type: 'documentation',
75
+ reason: 'Primary project documentation',
76
+ priority: 95,
77
+ },
78
+
79
+ // ── Environment documentation ─────────────────────────────────────────────
80
+ {
81
+ path: '.env.example',
82
+ type: 'environment',
83
+ reason: 'Documents required environment variables',
84
+ priority: 80,
85
+ },
86
+ {
87
+ path: '.env.sample',
88
+ type: 'environment',
89
+ reason: 'Documents required environment variables',
90
+ priority: 80,
91
+ },
92
+
93
+ // ── TypeScript ────────────────────────────────────────────────────────────
94
+ {
95
+ path: 'tsconfig.json',
96
+ type: 'configuration',
97
+ reason: 'TypeScript compiler configuration',
98
+ priority: 75,
99
+ },
100
+
101
+ // ── Container ─────────────────────────────────────────────────────────────
102
+ {
103
+ path: 'Dockerfile',
104
+ type: 'container',
105
+ reason: 'Container build configuration',
106
+ priority: 70,
107
+ },
108
+ {
109
+ path: 'docker-compose.yml',
110
+ type: 'container',
111
+ reason: 'Multi-container Docker configuration',
112
+ priority: 68,
113
+ },
114
+ {
115
+ path: 'docker-compose.yaml',
116
+ type: 'container',
117
+ reason: 'Multi-container Docker configuration',
118
+ priority: 68,
119
+ },
120
+ ];
121
+
122
+ /**
123
+ * Non-JS project manifests — same role as package.json for their ecosystems.
124
+ * Checked unconditionally because they might be present in polyglot repos.
125
+ *
126
+ * @type {ImportantFile[]}
127
+ */
128
+ const ECOSYSTEM_MANIFESTS = [
129
+ {
130
+ path: 'pom.xml',
131
+ type: 'manifest',
132
+ reason: 'Maven project descriptor and dependency manifest',
133
+ priority: 100,
134
+ },
135
+ {
136
+ path: 'build.gradle',
137
+ type: 'manifest',
138
+ reason: 'Gradle build script and dependency manifest',
139
+ priority: 100,
140
+ },
141
+ {
142
+ path: 'build.gradle.kts',
143
+ type: 'manifest',
144
+ reason: 'Gradle Kotlin DSL build script',
145
+ priority: 100,
146
+ },
147
+ {
148
+ path: 'Cargo.toml',
149
+ type: 'manifest',
150
+ reason: 'Rust package manifest and dependency configuration',
151
+ priority: 100,
152
+ },
153
+ {
154
+ path: 'composer.json',
155
+ type: 'manifest',
156
+ reason: 'PHP Composer dependency manifest',
157
+ priority: 100,
158
+ },
159
+ {
160
+ path: 'Gemfile',
161
+ type: 'manifest',
162
+ reason: 'Ruby gem dependency manifest',
163
+ priority: 100,
164
+ },
165
+ {
166
+ path: 'go.mod',
167
+ type: 'manifest',
168
+ reason: 'Go module definition and dependency manifest',
169
+ priority: 100,
170
+ },
171
+ {
172
+ path: 'pyproject.toml',
173
+ type: 'manifest',
174
+ reason: 'Python project configuration and dependency manifest',
175
+ priority: 100,
176
+ },
177
+ {
178
+ path: 'requirements.txt',
179
+ type: 'manifest',
180
+ reason: 'Python runtime dependency list',
181
+ priority: 95,
182
+ },
183
+ ];
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Pattern-matching helpers (no filesystem access — operate on flatFiles array)
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Push a candidate entry if the exact path exists in the file set.
191
+ *
192
+ * @param {ImportantFile[]} out
193
+ * @param {Set<string>} fileSet
194
+ * @param {string} filePath
195
+ * @param {string} type
196
+ * @param {string} reason
197
+ * @param {number} priority
198
+ */
199
+ function addExact(out, fileSet, filePath, type, reason, priority) {
200
+ if (fileSet.has(filePath)) {
201
+ out.push({ path: filePath, type, reason, priority });
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Push candidates for all files whose path starts with the given prefix.
207
+ * Used for patterns like 'vite.config.' (matches vite.config.js, .ts, .mjs …).
208
+ *
209
+ * @param {ImportantFile[]} out
210
+ * @param {string[]} flatFiles
211
+ * @param {string} prefix - e.g. 'vite.config.'
212
+ * @param {string} type
213
+ * @param {string} reason
214
+ * @param {number} priority
215
+ */
216
+ function addPrefix(out, flatFiles, prefix, type, reason, priority) {
217
+ for (const f of flatFiles) {
218
+ if (f.startsWith(prefix)) {
219
+ out.push({ path: f, type, reason, priority });
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Push candidates for all files whose path ends with the given suffix.
226
+ * Used for patterns like '*Application.java' (matches any depth).
227
+ *
228
+ * @param {ImportantFile[]} out
229
+ * @param {string[]} flatFiles
230
+ * @param {string} suffix - e.g. 'Application.java'
231
+ * @param {string} type
232
+ * @param {string} reason
233
+ * @param {number} priority
234
+ */
235
+ function addSuffix(out, flatFiles, suffix, type, reason, priority) {
236
+ for (const f of flatFiles) {
237
+ if (f.endsWith(suffix)) {
238
+ out.push({ path: f, type, reason, priority });
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Push candidates for root-level files matching `stem.*`.
245
+ * "Root-level" means the path contains no '/' after the stem.
246
+ * Used for patterns like 'server.*', 'app.*' (root only, not src/server.*).
247
+ *
248
+ * @param {ImportantFile[]} out
249
+ * @param {string[]} flatFiles
250
+ * @param {string} stem - e.g. 'server.' (note trailing dot)
251
+ * @param {string} type
252
+ * @param {string} reason
253
+ * @param {number} priority
254
+ */
255
+ function addRootPrefix(out, flatFiles, stem, type, reason, priority) {
256
+ for (const f of flatFiles) {
257
+ // Must start with stem AND have no directory separator in the remainder
258
+ if (f.startsWith(stem) && !f.slice(stem.length).includes('/')) {
259
+ out.push({ path: f, type, reason, priority });
260
+ }
261
+ }
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Project-specific candidate builder
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * Return framework-specific candidates based on detected project type.
270
+ * Only files that exist in flatFiles are included (checked by callers via
271
+ * addExact/addPrefix/addSuffix/addRootPrefix).
272
+ *
273
+ * @param {string} projectType - From ScanResult.projectType
274
+ * @param {string[]} flatFiles - All scanned file paths
275
+ * @param {Set<string>} fileSet - flatFiles as Set for O(1) lookup
276
+ * @returns {ImportantFile[]}
277
+ */
278
+ function getProjectCandidates(projectType, flatFiles, fileSet) {
279
+ const out = [];
280
+ const pt = projectType.toLowerCase();
281
+
282
+ // ── Framework build configs (apply to many ecosystems) ───────────────────
283
+ addPrefix(out, flatFiles, 'vite.config.', 'configuration', 'Vite build and development configuration', 85);
284
+ addPrefix(out, flatFiles, 'webpack.config.','configuration', 'Webpack bundler configuration', 85);
285
+
286
+ // ── Next.js ───────────────────────────────────────────────────────────────
287
+ if (pt.includes('next')) {
288
+ // Framework config
289
+ for (const f of ['next.config.js', 'next.config.mjs', 'next.config.ts']) {
290
+ addExact(out, fileSet, f, 'configuration', 'Next.js framework configuration', 85);
291
+ }
292
+ // App Router
293
+ for (const ext of ['js', 'jsx', 'ts', 'tsx']) {
294
+ addExact(out, fileSet, `app/layout.${ext}`, 'entry-point', 'Root layout for the Next.js App Router', 82);
295
+ addExact(out, fileSet, `app/page.${ext}`, 'entry-point', 'Root page for the Next.js App Router', 81);
296
+ }
297
+ // Edge middleware
298
+ for (const f of ['middleware.js', 'middleware.ts']) {
299
+ addExact(out, fileSet, f, 'configuration', 'Next.js edge middleware', 78);
300
+ }
301
+ // Pages Router
302
+ addPrefix(out, flatFiles, 'pages/index.', 'entry-point', 'Home page for the Next.js Pages Router', 78);
303
+ }
304
+
305
+ // ── Nuxt.js ───────────────────────────────────────────────────────────────
306
+ else if (pt.includes('nuxt')) {
307
+ for (const f of ['nuxt.config.js', 'nuxt.config.ts', 'nuxt.config.mjs']) {
308
+ addExact(out, fileSet, f, 'configuration', 'Nuxt.js framework configuration', 85);
309
+ }
310
+ for (const ext of ['js', 'vue', 'ts']) {
311
+ addExact(out, fileSet, `app.${ext}`, 'entry-point', 'Nuxt application root', 82);
312
+ addExact(out, fileSet, `pages/index.${ext}`, 'entry-point', 'Nuxt home page (Pages Router)', 78);
313
+ }
314
+ }
315
+
316
+ // ── React (Vite / CRA) ───────────────────────────────────────────────────
317
+ else if (pt.includes('react')) {
318
+ addPrefix(out, flatFiles, 'src/main.', 'entry-point', 'Application entry point', 82);
319
+ addPrefix(out, flatFiles, 'src/App.', 'entry-point', 'Root application component', 78);
320
+ // CRA fallback
321
+ addExact(out, fileSet, 'public/index.html', 'entry-point', 'Application HTML entry point', 76);
322
+ }
323
+
324
+ // ── Vue.js ────────────────────────────────────────────────────────────────
325
+ else if (pt.includes('vue')) {
326
+ for (const f of ['vue.config.js', 'vue.config.ts']) {
327
+ addExact(out, fileSet, f, 'configuration', 'Vue CLI configuration', 85);
328
+ }
329
+ addPrefix(out, flatFiles, 'src/main.', 'entry-point', 'Application entry point', 82);
330
+ addPrefix(out, flatFiles, 'src/App.', 'entry-point', 'Root application component', 78);
331
+ }
332
+
333
+ // ── Angular ───────────────────────────────────────────────────────────────
334
+ else if (pt.includes('angular')) {
335
+ addExact(out, fileSet, 'angular.json', 'configuration', 'Angular workspace configuration', 85);
336
+ addPrefix(out, flatFiles, 'src/main.', 'entry-point', 'Angular bootstrap entry point', 82);
337
+ addExact(out, fileSet, 'src/app/app.module.ts', 'entry-point', 'Angular root module', 80);
338
+ addExact(out, fileSet, 'src/app/app.component.ts','entry-point','Angular root component', 78);
339
+ }
340
+
341
+ // ── Svelte ────────────────────────────────────────────────────────────────
342
+ else if (pt.includes('svelte')) {
343
+ for (const f of ['svelte.config.js', 'svelte.config.ts']) {
344
+ addExact(out, fileSet, f, 'configuration', 'SvelteKit / Svelte configuration', 85);
345
+ }
346
+ addPrefix(out, flatFiles, 'src/routes/+page.', 'entry-point', 'SvelteKit home page route', 82);
347
+ addPrefix(out, flatFiles, 'src/main.', 'entry-point', 'Svelte application entry point', 80);
348
+ addPrefix(out, flatFiles, 'src/App.', 'entry-point', 'Svelte root component', 78);
349
+ }
350
+
351
+ // ── Express / Fastify / Koa (Node.js server frameworks) ──────────────────
352
+ else if (pt.includes('express') || pt.includes('fastify') || pt.includes('koa')) {
353
+ addRootPrefix(out, flatFiles, 'server.', 'entry-point', 'Server application entry point', 82);
354
+ addRootPrefix(out, flatFiles, 'app.', 'entry-point', 'Application entry point', 80);
355
+ addPrefix(out, flatFiles, 'src/server.', 'entry-point', 'Server application entry point', 80);
356
+ addPrefix(out, flatFiles, 'src/app.', 'entry-point', 'Application entry point', 78);
357
+ // Common conventions
358
+ addExact(out, fileSet, 'src/index.js', 'entry-point', 'Application entry point', 76);
359
+ addExact(out, fileSet, 'src/index.ts', 'entry-point', 'Application entry point', 76);
360
+ }
361
+
362
+ // ── Python ────────────────────────────────────────────────────────────────
363
+ if (pt.includes('python')) {
364
+ addExact(out, fileSet, 'main.py', 'entry-point', 'Main Python application entry point', 82);
365
+ addExact(out, fileSet, 'manage.py', 'entry-point', 'Django project management CLI', 82);
366
+ addExact(out, fileSet, 'app.py', 'entry-point', 'Flask / web framework application', 80);
367
+ addExact(out, fileSet, 'wsgi.py', 'configuration', 'WSGI server entry point', 75);
368
+ addExact(out, fileSet, 'asgi.py', 'configuration', 'ASGI server entry point', 75);
369
+ }
370
+
371
+ // ── Java / Spring Boot ────────────────────────────────────────────────────
372
+ if (pt.includes('java') || pt.includes('spring')) {
373
+ addExact(out, fileSet, 'pom.xml', 'manifest', 'Maven project descriptor and dependency manifest', 100);
374
+ addExact(out, fileSet, 'build.gradle', 'manifest', 'Gradle build script and dependency manifest', 100);
375
+ addExact(out, fileSet, 'build.gradle.kts', 'manifest', 'Gradle Kotlin DSL build script', 100);
376
+ addSuffix(out, flatFiles, 'Application.java', 'entry-point', 'Spring Boot application entry point', 82);
377
+ addSuffix(out, flatFiles, 'Application.kt', 'entry-point', 'Spring Boot Kotlin application entry point', 82);
378
+ // Main application properties
379
+ addExact(out, fileSet, 'src/main/resources/application.properties', 'configuration',
380
+ 'Spring Boot application configuration', 78);
381
+ addExact(out, fileSet, 'src/main/resources/application.yml', 'configuration',
382
+ 'Spring Boot application configuration', 78);
383
+ }
384
+
385
+ // ── Go ────────────────────────────────────────────────────────────────────
386
+ if (pt === 'go') {
387
+ addExact(out, fileSet, 'go.mod', 'manifest', 'Go module definition and dependency manifest', 100);
388
+ addExact(out, fileSet, 'go.sum', 'manifest', 'Go module checksums', 92);
389
+ addExact(out, fileSet, 'main.go', 'entry-point','Main Go application entry point', 82);
390
+ addExact(out, fileSet, 'Makefile', 'utility', 'Build and task automation', 72);
391
+ }
392
+
393
+ // ── Rust ──────────────────────────────────────────────────────────────────
394
+ if (pt.includes('rust')) {
395
+ addExact(out, fileSet, 'Cargo.toml', 'manifest', 'Rust package manifest and dependency configuration', 100);
396
+ addExact(out, fileSet, 'Cargo.lock', 'manifest', 'Rust dependency lockfile', 92);
397
+ addExact(out, fileSet, 'src/main.rs', 'entry-point','Binary crate entry point', 82);
398
+ addExact(out, fileSet, 'src/lib.rs', 'entry-point','Library crate root', 82);
399
+ }
400
+
401
+ // ── PHP / Laravel / Composer ──────────────────────────────────────────────
402
+ if (pt.includes('php') || pt.includes('composer')) {
403
+ addExact(out, fileSet, 'composer.json', 'manifest', 'PHP Composer dependency manifest', 100);
404
+ addExact(out, fileSet, 'artisan', 'utility', 'Laravel CLI tool', 82);
405
+ addExact(out, fileSet, 'routes/web.php', 'configuration','Web route definitions', 78);
406
+ addExact(out, fileSet, 'routes/api.php', 'configuration','API route definitions', 78);
407
+ addExact(out, fileSet, 'public/index.php','entry-point', 'HTTP front controller (Laravel / PHP)', 76);
408
+ }
409
+
410
+ // ── Ruby / Rails ──────────────────────────────────────────────────────────
411
+ if (pt.includes('ruby') || pt.includes('rails')) {
412
+ addExact(out, fileSet, 'Gemfile', 'manifest', 'Ruby gem dependency manifest', 100);
413
+ addExact(out, fileSet, 'config/routes.rb', 'configuration','Rails route definitions', 78);
414
+ addExact(out, fileSet, 'config/application.rb', 'configuration','Rails application configuration', 76);
415
+ addExact(out, fileSet, 'config/environment.rb', 'configuration','Rails environment bootstrap', 74);
416
+ addExact(out, fileSet, 'app/controllers/application_controller.rb', 'entry-point',
417
+ 'Rails base application controller', 72);
418
+ }
419
+
420
+ // ── Elixir / Phoenix ─────────────────────────────────────────────────────
421
+ if (pt.includes('elixir') || pt.includes('phoenix')) {
422
+ addExact(out, fileSet, 'mix.exs', 'manifest', 'Elixir Mix build and dependency manifest', 100);
423
+ addExact(out, fileSet, 'mix.lock', 'manifest', 'Elixir Mix dependency lockfile', 92);
424
+ addPrefix(out, flatFiles, 'lib/', 'entry-point', 'Elixir application module', 75);
425
+ }
426
+
427
+ return out;
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Deduplication and sort
432
+ // ---------------------------------------------------------------------------
433
+
434
+ /**
435
+ * Merge a list of raw candidates into a deduplicated, sorted result.
436
+ * When the same path appears with different priorities, the highest wins.
437
+ * Ties in priority are broken by ascending alphabetical path order.
438
+ *
439
+ * @param {ImportantFile[]} rawCandidates
440
+ * @returns {ImportantFile[]}
441
+ */
442
+ function deduplicateAndSort(rawCandidates) {
443
+ /** @type {Map<string, ImportantFile>} */
444
+ const best = new Map();
445
+
446
+ for (const candidate of rawCandidates) {
447
+ const existing = best.get(candidate.path);
448
+ if (!existing || candidate.priority > existing.priority) {
449
+ best.set(candidate.path, candidate);
450
+ }
451
+ }
452
+
453
+ return Array.from(best.values()).sort((a, b) => {
454
+ if (b.priority !== a.priority) return b.priority - a.priority; // higher first
455
+ return a.path.localeCompare(b.path); // alpha on ties
456
+ });
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Public API
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Detect strategically important files in a scanned project.
465
+ *
466
+ * Uses only data already produced by scan() — no new filesystem operations.
467
+ *
468
+ * @param {Object} params
469
+ * @param {string[]} params.flatFiles - All relative POSIX file paths from the scanner
470
+ * @param {string} params.projectType - Detected project type (e.g. 'React', 'Go')
471
+ * @param {string[]} params.entryPoints - Detected entry point paths
472
+ * @param {string[]} params.configs - Detected configuration file paths
473
+ * @returns {{ importantFiles: ImportantFile[] }}
474
+ */
475
+ export function detectImportantFiles({ flatFiles, projectType, entryPoints, configs }) {
476
+ const fileSet = new Set(flatFiles);
477
+
478
+ /** @type {ImportantFile[]} */
479
+ const raw = [];
480
+
481
+ // ── 1. Generic files (always check, any project type) ────────────────────
482
+ for (const candidate of GENERIC_CANDIDATES) {
483
+ if (fileSet.has(candidate.path)) {
484
+ raw.push({ ...candidate });
485
+ }
486
+ }
487
+
488
+ for (const candidate of ECOSYSTEM_MANIFESTS) {
489
+ if (fileSet.has(candidate.path)) {
490
+ raw.push({ ...candidate });
491
+ }
492
+ }
493
+
494
+ // ── 2. Primary entry point ────────────────────────────────────────────────
495
+ if (entryPoints.length > 0) {
496
+ const primary = entryPoints[0];
497
+ if (fileSet.has(primary)) {
498
+ raw.push({
499
+ path: primary,
500
+ type: 'entry-point',
501
+ reason: 'Primary application entry point',
502
+ priority: 90,
503
+ });
504
+ }
505
+ }
506
+
507
+ // ── 3. Project-specific candidates ───────────────────────────────────────
508
+ const projectCandidates = getProjectCandidates(projectType, flatFiles, fileSet);
509
+ for (const c of projectCandidates) {
510
+ raw.push(c);
511
+ }
512
+
513
+ // ── 4. Deduplicate, resolve priority conflicts, and sort ──────────────────
514
+ return { importantFiles: deduplicateAndSort(raw) };
515
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @fileoverview Toren — Package Manager Detector
3
+ *
4
+ * Identifies which package manager a project uses by inspecting the presence
5
+ * of well-known lockfiles in the repository root.
6
+ *
7
+ * Design contract:
8
+ * - Pure function. Accepts only the already-scanned flatFiles array.
9
+ * - No filesystem I/O. No recursive rescan. No external dependencies.
10
+ * - Only root-level files are considered. A lockfile nested under a
11
+ * subdirectory (e.g. examples/app/package-lock.json) does NOT influence
12
+ * the result — it belongs to that sub-project, not the repository root.
13
+ * - Deterministic: same input always produces the same output.
14
+ * - Handles ambiguity explicitly: when multiple different package managers
15
+ * are detected at root, packageManager is set to null and ambiguous is true.
16
+ *
17
+ * Supported lockfiles → package manager:
18
+ * package-lock.json → npm
19
+ * pnpm-lock.yaml → pnpm
20
+ * yarn.lock → yarn
21
+ * bun.lock → bun
22
+ * bun.lockb → bun
23
+ *
24
+ * @module detectors/package-manager-detector
25
+ */
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Lockfile registry
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Maps a root-level lockfile basename to its package manager name.
33
+ * Evaluation is case-sensitive — lockfile names are always lowercase on all
34
+ * supported platforms.
35
+ *
36
+ * @type {Map<string, string>}
37
+ */
38
+ const LOCKFILE_MAP = new Map([
39
+ ['package-lock.json', 'npm'],
40
+ ['pnpm-lock.yaml', 'pnpm'],
41
+ ['yarn.lock', 'yarn'],
42
+ ['bun.lock', 'bun'],
43
+ ['bun.lockb', 'bun'],
44
+ ]);
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Types (JSDoc — no TypeScript dependency required)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * @typedef {Object} PackageManagerResult
52
+ * @property {string|null} packageManager - Detected package manager name, or null
53
+ * when none or multiple are found.
54
+ * @property {string[]} packageManagers - Sorted list of all detected package managers.
55
+ * Empty when none detected.
56
+ * @property {boolean} ambiguous - True when multiple different package managers
57
+ * are detected at root simultaneously.
58
+ */
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Public API
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Detect the package manager used by a project from its root lockfiles.
66
+ *
67
+ * Only root-level entries in `flatFiles` are examined. A POSIX-style path
68
+ * with no '/' character is at the repository root (e.g. "yarn.lock").
69
+ * Paths that contain '/' are nested files (e.g. "packages/app/yarn.lock")
70
+ * and are intentionally ignored.
71
+ *
72
+ * @param {string[]} flatFiles - POSIX-relative file paths from the scanner.
73
+ * Must not be null or undefined.
74
+ * @returns {PackageManagerResult}
75
+ */
76
+ export function detectPackageManager(flatFiles) {
77
+ const detected = new Set();
78
+
79
+ for (const file of flatFiles) {
80
+ // Root-level only: a POSIX relative path with no '/' is at the root.
81
+ // e.g. "package-lock.json" ✓ root
82
+ // "apps/web/yarn.lock" ✗ nested — skip
83
+ if (file.includes('/')) continue;
84
+
85
+ const pm = LOCKFILE_MAP.get(file);
86
+ if (pm !== undefined) {
87
+ detected.add(pm);
88
+ }
89
+ }
90
+
91
+ // Sort for deterministic output — order of detection must not vary between runs.
92
+ const packageManagers = Array.from(detected).sort();
93
+
94
+ if (packageManagers.length === 0) {
95
+ return { packageManager: null, packageManagers: [], ambiguous: false };
96
+ }
97
+
98
+ if (packageManagers.length === 1) {
99
+ return {
100
+ packageManager: packageManagers[0],
101
+ packageManagers: packageManagers,
102
+ ambiguous: false,
103
+ };
104
+ }
105
+
106
+ // Multiple different package managers at root — do not guess.
107
+ return {
108
+ packageManager: null,
109
+ packageManagers: packageManagers,
110
+ ambiguous: true,
111
+ };
112
+ }