@lakindu_perera/toren 1.0.10 → 1.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.
@@ -0,0 +1,1730 @@
1
+ /**
2
+ * @fileoverview Toren — Technology Stack Detector (v1.1.1)
3
+ *
4
+ * Detects the technology stack of a project using pre-collected scan context.
5
+ * Does NOT re-scan the repository or re-read package.json.
6
+ *
7
+ * Design contract:
8
+ * - Consumes only the data already collected by scan() and passed in context.
9
+ * - Deterministic: same input always produces the same ordered output.
10
+ * - Confidence model (centralized in evidence-weights.ts):
11
+ * dependency / devDependency / peerDependency → +0.60 (strong)
12
+ * manifest → +0.60 (strong)
13
+ * config file → +0.25 (moderate supporting)
14
+ * script keyword → +0.15 (weak supporting)
15
+ * file → +0.15 (weak supporting)
16
+ * directory → +0.15 (weak supporting)
17
+ * - Confidence is rounded to 2 decimal places and capped at 1.0.
18
+ * - Technologies below the CONFIDENCE_THRESHOLD (0.60) are suppressed.
19
+ * - When multiple rules match the same technology, evidence is merged and
20
+ * confidence is combined (capped), never producing duplicate entries.
21
+ * - Evidence within a technology is deduplicated and sorted deterministically.
22
+ * - Files under noise directories (examples/, fixtures/, demo/, samples/)
23
+ * are deprioritised for file/directory evidence to reduce false positives
24
+ * from nested example projects.
25
+ * - Output is ordered by: category rank → confidence desc → name asc.
26
+
27
+ *
28
+ * Frontend technologies detected (Step 4):
29
+ * React, Next.js, Vue, Nuxt, Angular, Svelte, SvelteKit, Astro, Remix
30
+ *
31
+ * Styling technologies detected (Step 4):
32
+ * Tailwind CSS, Sass, Less, Styled Components, Emotion, Bootstrap,
33
+ * Material UI, Chakra UI
34
+ *
35
+ * Backend technologies detected (Step 5):
36
+ * Node.js: Express, Fastify, NestJS, Koa, Hapi
37
+ * Python: Django, Flask, FastAPI
38
+ * Java: Spring Boot
39
+ * PHP: Laravel
40
+ * Ruby: Rails
41
+ *
42
+ * Database technologies detected (Step 5):
43
+ * PostgreSQL, MySQL, MariaDB, SQLite, MongoDB, Redis
44
+ *
45
+ * ORM / data-access tools detected (Step 5):
46
+ * Prisma, Drizzle, TypeORM, Sequelize, Mongoose, Knex,
47
+ * SQLAlchemy, Hibernate, Django ORM, Eloquent, Active Record
48
+ *
49
+ * Testing frameworks detected (Step 6):
50
+ * Jest, Vitest, Mocha, Playwright, Cypress, Testing Library,
51
+ * Pytest, JUnit
52
+ *
53
+ * Build tools detected (Step 6):
54
+ * Vite, Webpack, Rollup, esbuild
55
+ *
56
+ * Quality tools detected (Step 6):
57
+ * ESLint, Prettier, Biome, Stylelint
58
+ *
59
+ * Container tools detected (Step 6):
60
+ * Docker, Docker Compose
61
+ *
62
+ * Deployment tools detected (Step 6):
63
+ * Vercel, Netlify, Fly.io, Railway, Render,
64
+ * Serverless Framework, AWS SAM
65
+ *
66
+ * False-positive / safety guards:
67
+ * - Directory names alone never trigger framework detection.
68
+ * - .env / secret files are NEVER read or scanned.
69
+ * - Database URLs / credentials are NEVER used as evidence.
70
+ * - ORM is never guessed from database type; each needs its own evidence.
71
+ * - Plain CSS files are never reported as a technology framework.
72
+ *
73
+ * @module detectors/technology-stack-detector
74
+ */
75
+ import { EVIDENCE_WEIGHTS, evidenceTypeRank, capConfidence, isStrongEvidence, } from './evidence-weights.js';
76
+ // ---------------------------------------------------------------------------
77
+ // Constants
78
+ // ---------------------------------------------------------------------------
79
+ /** Minimum confidence required to include a technology in the output. */
80
+ const CONFIDENCE_THRESHOLD = 0.60;
81
+ /**
82
+ * Confidence contribution per evidence kind.
83
+ * Re-exported from the centralized evidence-weights module.
84
+ * Kept here as a local alias for rule-level convenience.
85
+ */
86
+ const SCORE = EVIDENCE_WEIGHTS;
87
+ /**
88
+ * Canonical ordering for technology categories.
89
+ * Lower index = listed first in the output.
90
+ */
91
+ const CATEGORY_ORDER = [
92
+ 'language',
93
+ 'runtime',
94
+ 'package-manager',
95
+ 'frontend',
96
+ 'backend',
97
+ 'styling',
98
+ 'database',
99
+ 'orm',
100
+ 'testing',
101
+ 'build',
102
+ 'quality',
103
+ 'container',
104
+ 'deployment',
105
+ 'other',
106
+ ];
107
+ // ---------------------------------------------------------------------------
108
+ // Rule registry
109
+ // ---------------------------------------------------------------------------
110
+ /**
111
+ * All detection rules, evaluated in order.
112
+ * Add new rules here — the engine handles deduplication automatically.
113
+ *
114
+ * Keep this list small for v1.1.0 (proof-of-concept set).
115
+ * Future steps will add comprehensive rules per category.
116
+ */
117
+ const TECH_RULES = [
118
+ // ── TypeScript ────────────────────────────────────────────────────────────
119
+ {
120
+ tech: 'TypeScript',
121
+ category: 'language',
122
+ evidenceType: 'devDependency',
123
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['typescript'] ? 'typescript' : null,
124
+ },
125
+ {
126
+ tech: 'TypeScript',
127
+ category: 'language',
128
+ evidenceType: 'dependency',
129
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['typescript'] ? 'typescript' : null,
130
+ },
131
+ {
132
+ tech: 'TypeScript',
133
+ category: 'language',
134
+ evidenceType: 'config',
135
+ match: ({ configs }) => configs.includes('tsconfig.json') ? 'tsconfig.json' : null,
136
+ },
137
+ {
138
+ tech: 'TypeScript',
139
+ category: 'language',
140
+ evidenceType: 'file',
141
+ match: ({ flatFiles }) => flatFiles.some(f => /\.(ts|tsx)$/.test(f) && !f.endsWith('.d.ts'))
142
+ ? '*.ts / *.tsx source files'
143
+ : null,
144
+ },
145
+ // ── JavaScript ───────────────────────────────────────────────────────────
146
+ {
147
+ tech: 'JavaScript',
148
+ category: 'language',
149
+ evidenceType: 'file',
150
+ match: ({ flatFiles }) => flatFiles.some(f => /\.(js|jsx|mjs|cjs)$/.test(f))
151
+ ? '*.js / *.jsx source files'
152
+ : null,
153
+ },
154
+ {
155
+ tech: 'JavaScript',
156
+ category: 'language',
157
+ evidenceType: 'config',
158
+ match: ({ configs }) => configs.includes('jsconfig.json') ? 'jsconfig.json' : null,
159
+ },
160
+ {
161
+ tech: 'JavaScript',
162
+ category: 'language',
163
+ evidenceType: 'manifest',
164
+ match: ({ flatFiles }) => flatFiles.includes('package.json') ? 'package.json' : null,
165
+ },
166
+ // ==========================================================================
167
+ // FRONTEND FRAMEWORKS
168
+ // ==========================================================================
169
+ // ── React ─────────────────────────────────────────────────────────────────
170
+ // False-positive guard: a directory named "react/" alone must NOT trigger this.
171
+ // Evidence must come from package.json deps or explicit .jsx/.tsx files.
172
+ {
173
+ tech: 'React',
174
+ category: 'frontend',
175
+ evidenceType: 'dependency',
176
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['react'] ? 'react' : null,
177
+ },
178
+ {
179
+ tech: 'React',
180
+ category: 'frontend',
181
+ evidenceType: 'devDependency',
182
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['react'] ? 'react' : null,
183
+ },
184
+ {
185
+ // *.jsx / *.tsx files are supporting evidence only (0.15 alone < threshold).
186
+ tech: 'React',
187
+ category: 'frontend',
188
+ evidenceType: 'file',
189
+ match: ({ flatFiles }) => flatFiles.some(f => /\.(jsx|tsx)$/.test(f)) ? '*.jsx / *.tsx files' : null,
190
+ },
191
+ // ── Next.js ───────────────────────────────────────────────────────────────
192
+ // False-positive guards:
193
+ // - A directory named "next/" alone must NOT trigger detection.
194
+ // - README mentions are not used as evidence (no text-scanning rules).
195
+ // - app/ or pages/ directories are only supporting evidence (< threshold alone).
196
+ {
197
+ tech: 'Next.js',
198
+ category: 'frontend',
199
+ evidenceType: 'dependency',
200
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['next'] ? 'next' : null,
201
+ },
202
+ {
203
+ tech: 'Next.js',
204
+ category: 'frontend',
205
+ evidenceType: 'devDependency',
206
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['next'] ? 'next' : null,
207
+ },
208
+ {
209
+ tech: 'Next.js',
210
+ category: 'frontend',
211
+ evidenceType: 'config',
212
+ match: ({ configs }) => {
213
+ const found = configs.find(c => /^next\.config\.[a-zA-Z0-9]+$/.test(c));
214
+ return found ?? null;
215
+ },
216
+ },
217
+ {
218
+ // app/ or pages/ are supporting evidence only — not standalone triggers.
219
+ tech: 'Next.js',
220
+ category: 'frontend',
221
+ evidenceType: 'directory',
222
+ match: ({ flatFiles }) => flatFiles.some(f => f.startsWith('app/') || f.startsWith('pages/'))
223
+ ? 'app/ or pages/ directory'
224
+ : null,
225
+ },
226
+ // ── Vue ───────────────────────────────────────────────────────────────────
227
+ {
228
+ tech: 'Vue',
229
+ category: 'frontend',
230
+ evidenceType: 'dependency',
231
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['vue'] ? 'vue' : null,
232
+ },
233
+ {
234
+ tech: 'Vue',
235
+ category: 'frontend',
236
+ evidenceType: 'devDependency',
237
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['vue'] ? 'vue' : null,
238
+ },
239
+ {
240
+ // *.vue SFC files — supporting evidence only (0.15 alone < threshold).
241
+ tech: 'Vue',
242
+ category: 'frontend',
243
+ evidenceType: 'file',
244
+ match: ({ flatFiles }) => flatFiles.some(f => f.endsWith('.vue')) ? '*.vue single-file components' : null,
245
+ },
246
+ // ── Nuxt ──────────────────────────────────────────────────────────────────
247
+ {
248
+ tech: 'Nuxt',
249
+ category: 'frontend',
250
+ evidenceType: 'dependency',
251
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['nuxt'] ? 'nuxt' : null,
252
+ },
253
+ {
254
+ tech: 'Nuxt',
255
+ category: 'frontend',
256
+ evidenceType: 'devDependency',
257
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['nuxt'] ? 'nuxt' : null,
258
+ },
259
+ {
260
+ tech: 'Nuxt',
261
+ category: 'frontend',
262
+ evidenceType: 'config',
263
+ match: ({ configs }) => {
264
+ const found = configs.find(c => /^nuxt\.config\.[a-zA-Z0-9]+$/.test(c));
265
+ return found ?? null;
266
+ },
267
+ },
268
+ // ── Angular ───────────────────────────────────────────────────────────────
269
+ {
270
+ tech: 'Angular',
271
+ category: 'frontend',
272
+ evidenceType: 'dependency',
273
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@angular/core'] ? '@angular/core' : null,
274
+ },
275
+ {
276
+ tech: 'Angular',
277
+ category: 'frontend',
278
+ evidenceType: 'devDependency',
279
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@angular/core'] ? '@angular/core' : null,
280
+ },
281
+ {
282
+ tech: 'Angular',
283
+ category: 'frontend',
284
+ evidenceType: 'config',
285
+ match: ({ configs }) => configs.includes('angular.json') ? 'angular.json' : null,
286
+ },
287
+ // ── Svelte ────────────────────────────────────────────────────────────────
288
+ {
289
+ tech: 'Svelte',
290
+ category: 'frontend',
291
+ evidenceType: 'dependency',
292
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['svelte'] ? 'svelte' : null,
293
+ },
294
+ {
295
+ tech: 'Svelte',
296
+ category: 'frontend',
297
+ evidenceType: 'devDependency',
298
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['svelte'] ? 'svelte' : null,
299
+ },
300
+ {
301
+ // *.svelte files — supporting evidence only (0.15 alone < threshold).
302
+ tech: 'Svelte',
303
+ category: 'frontend',
304
+ evidenceType: 'file',
305
+ match: ({ flatFiles }) => flatFiles.some(f => f.endsWith('.svelte')) ? '*.svelte component files' : null,
306
+ },
307
+ // ── SvelteKit ─────────────────────────────────────────────────────────────
308
+ // SvelteKit is a distinct meta-framework from bare Svelte — detected separately.
309
+ {
310
+ tech: 'SvelteKit',
311
+ category: 'frontend',
312
+ evidenceType: 'dependency',
313
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@sveltejs/kit'] ? '@sveltejs/kit' : null,
314
+ },
315
+ {
316
+ tech: 'SvelteKit',
317
+ category: 'frontend',
318
+ evidenceType: 'devDependency',
319
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@sveltejs/kit'] ? '@sveltejs/kit' : null,
320
+ },
321
+ {
322
+ tech: 'SvelteKit',
323
+ category: 'frontend',
324
+ evidenceType: 'config',
325
+ match: ({ configs }) => {
326
+ const found = configs.find(c => /^svelte\.config\.[a-zA-Z0-9]+$/.test(c));
327
+ return found ?? null;
328
+ },
329
+ },
330
+ // ── Astro ─────────────────────────────────────────────────────────────────
331
+ {
332
+ tech: 'Astro',
333
+ category: 'frontend',
334
+ evidenceType: 'dependency',
335
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['astro'] ? 'astro' : null,
336
+ },
337
+ {
338
+ tech: 'Astro',
339
+ category: 'frontend',
340
+ evidenceType: 'devDependency',
341
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['astro'] ? 'astro' : null,
342
+ },
343
+ {
344
+ tech: 'Astro',
345
+ category: 'frontend',
346
+ evidenceType: 'config',
347
+ match: ({ configs }) => {
348
+ const found = configs.find(c => /^astro\.config\.[a-zA-Z0-9]+$/.test(c));
349
+ return found ?? null;
350
+ },
351
+ },
352
+ // ── Remix ─────────────────────────────────────────────────────────────────
353
+ // Modern Remix (v2+) uses @remix-run/react + @remix-run/node.
354
+ {
355
+ tech: 'Remix',
356
+ category: 'frontend',
357
+ evidenceType: 'dependency',
358
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@remix-run/react'] ? '@remix-run/react' : null,
359
+ },
360
+ {
361
+ tech: 'Remix',
362
+ category: 'frontend',
363
+ evidenceType: 'devDependency',
364
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@remix-run/react'] ? '@remix-run/react' : null,
365
+ },
366
+ {
367
+ tech: 'Remix',
368
+ category: 'frontend',
369
+ evidenceType: 'dependency',
370
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@remix-run/node'] ? '@remix-run/node' : null,
371
+ },
372
+ {
373
+ tech: 'Remix',
374
+ category: 'frontend',
375
+ evidenceType: 'devDependency',
376
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@remix-run/node'] ? '@remix-run/node' : null,
377
+ },
378
+ // ==========================================================================
379
+ // STYLING
380
+ // ==========================================================================
381
+ // ── Tailwind CSS ──────────────────────────────────────────────────────────
382
+ {
383
+ tech: 'Tailwind CSS',
384
+ category: 'styling',
385
+ evidenceType: 'dependency',
386
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['tailwindcss'] ? 'tailwindcss' : null,
387
+ },
388
+ {
389
+ tech: 'Tailwind CSS',
390
+ category: 'styling',
391
+ evidenceType: 'devDependency',
392
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['tailwindcss'] ? 'tailwindcss' : null,
393
+ },
394
+ {
395
+ tech: 'Tailwind CSS',
396
+ category: 'styling',
397
+ evidenceType: 'config',
398
+ match: ({ configs }) => {
399
+ const found = configs.find(c => /^tailwind\.config\.[a-zA-Z0-9]+$/.test(c));
400
+ return found ?? null;
401
+ },
402
+ },
403
+ // ── Sass ──────────────────────────────────────────────────────────────────
404
+ {
405
+ tech: 'Sass',
406
+ category: 'styling',
407
+ evidenceType: 'dependency',
408
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['sass'] ? 'sass' : null,
409
+ },
410
+ {
411
+ tech: 'Sass',
412
+ category: 'styling',
413
+ evidenceType: 'devDependency',
414
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['sass'] ? 'sass' : null,
415
+ },
416
+ {
417
+ // *.scss files — supporting evidence only (0.15 alone < threshold).
418
+ tech: 'Sass',
419
+ category: 'styling',
420
+ evidenceType: 'file',
421
+ match: ({ flatFiles }) => flatFiles.some(f => f.endsWith('.scss')) ? '*.scss stylesheets' : null,
422
+ },
423
+ // ── Less ──────────────────────────────────────────────────────────────────
424
+ {
425
+ tech: 'Less',
426
+ category: 'styling',
427
+ evidenceType: 'dependency',
428
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['less'] ? 'less' : null,
429
+ },
430
+ {
431
+ tech: 'Less',
432
+ category: 'styling',
433
+ evidenceType: 'devDependency',
434
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['less'] ? 'less' : null,
435
+ },
436
+ {
437
+ tech: 'Less',
438
+ category: 'styling',
439
+ evidenceType: 'file',
440
+ match: ({ flatFiles }) => flatFiles.some(f => f.endsWith('.less')) ? '*.less stylesheets' : null,
441
+ },
442
+ // ── Styled Components ─────────────────────────────────────────────────────
443
+ {
444
+ tech: 'Styled Components',
445
+ category: 'styling',
446
+ evidenceType: 'dependency',
447
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['styled-components'] ? 'styled-components' : null,
448
+ },
449
+ {
450
+ tech: 'Styled Components',
451
+ category: 'styling',
452
+ evidenceType: 'devDependency',
453
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['styled-components'] ? 'styled-components' : null,
454
+ },
455
+ // ── Emotion ───────────────────────────────────────────────────────────────
456
+ {
457
+ tech: 'Emotion',
458
+ category: 'styling',
459
+ evidenceType: 'dependency',
460
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@emotion/react'] ? '@emotion/react' : null,
461
+ },
462
+ {
463
+ tech: 'Emotion',
464
+ category: 'styling',
465
+ evidenceType: 'devDependency',
466
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@emotion/react'] ? '@emotion/react' : null,
467
+ },
468
+ // ── Bootstrap ─────────────────────────────────────────────────────────────
469
+ {
470
+ tech: 'Bootstrap',
471
+ category: 'styling',
472
+ evidenceType: 'dependency',
473
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['bootstrap'] ? 'bootstrap' : null,
474
+ },
475
+ {
476
+ tech: 'Bootstrap',
477
+ category: 'styling',
478
+ evidenceType: 'devDependency',
479
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['bootstrap'] ? 'bootstrap' : null,
480
+ },
481
+ // ── Material UI ───────────────────────────────────────────────────────────
482
+ {
483
+ tech: 'Material UI',
484
+ category: 'styling',
485
+ evidenceType: 'dependency',
486
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@mui/material'] ? '@mui/material' : null,
487
+ },
488
+ {
489
+ tech: 'Material UI',
490
+ category: 'styling',
491
+ evidenceType: 'devDependency',
492
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@mui/material'] ? '@mui/material' : null,
493
+ },
494
+ // ── Chakra UI ─────────────────────────────────────────────────────────────
495
+ {
496
+ tech: 'Chakra UI',
497
+ category: 'styling',
498
+ evidenceType: 'dependency',
499
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@chakra-ui/react'] ? '@chakra-ui/react' : null,
500
+ },
501
+ {
502
+ tech: 'Chakra UI',
503
+ category: 'styling',
504
+ evidenceType: 'devDependency',
505
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@chakra-ui/react'] ? '@chakra-ui/react' : null,
506
+ },
507
+ // ==========================================================================
508
+ // BACKEND FRAMEWORKS
509
+ // ==========================================================================
510
+ //
511
+ // Node.js backends: detected via packageManifest deps.
512
+ // Python/Java/PHP/Ruby: detected via flatFiles manifest/config/file signals,
513
+ // since packageManifest is null for non-Node projects.
514
+ //
515
+ // Safety contract:
516
+ // - .env files are NEVER read or inspected.
517
+ // - No credentials or connection strings are ever accessed.
518
+ // - All evidence is structural (file presence, package names).
519
+ // ── Express ───────────────────────────────────────────────────────────────
520
+ {
521
+ tech: 'Express',
522
+ category: 'backend',
523
+ evidenceType: 'dependency',
524
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['express'] ? 'express' : null,
525
+ },
526
+ {
527
+ tech: 'Express',
528
+ category: 'backend',
529
+ evidenceType: 'devDependency',
530
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['express'] ? 'express' : null,
531
+ },
532
+ // ── Fastify ───────────────────────────────────────────────────────────────
533
+ {
534
+ tech: 'Fastify',
535
+ category: 'backend',
536
+ evidenceType: 'dependency',
537
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['fastify'] ? 'fastify' : null,
538
+ },
539
+ {
540
+ tech: 'Fastify',
541
+ category: 'backend',
542
+ evidenceType: 'devDependency',
543
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['fastify'] ? 'fastify' : null,
544
+ },
545
+ // ── NestJS ────────────────────────────────────────────────────────────────
546
+ {
547
+ tech: 'NestJS',
548
+ category: 'backend',
549
+ evidenceType: 'dependency',
550
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@nestjs/core'] ? '@nestjs/core' : null,
551
+ },
552
+ {
553
+ tech: 'NestJS',
554
+ category: 'backend',
555
+ evidenceType: 'devDependency',
556
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@nestjs/core'] ? '@nestjs/core' : null,
557
+ },
558
+ {
559
+ // nest-cli.json is a root-level NestJS CLI config file.
560
+ tech: 'NestJS',
561
+ category: 'backend',
562
+ evidenceType: 'config',
563
+ match: ({ flatFiles }) => flatFiles.includes('nest-cli.json') ? 'nest-cli.json' : null,
564
+ },
565
+ // ── Koa ───────────────────────────────────────────────────────────────────
566
+ {
567
+ tech: 'Koa',
568
+ category: 'backend',
569
+ evidenceType: 'dependency',
570
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['koa'] ? 'koa' : null,
571
+ },
572
+ {
573
+ tech: 'Koa',
574
+ category: 'backend',
575
+ evidenceType: 'devDependency',
576
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['koa'] ? 'koa' : null,
577
+ },
578
+ // ── Hapi ──────────────────────────────────────────────────────────────────
579
+ {
580
+ tech: 'Hapi',
581
+ category: 'backend',
582
+ evidenceType: 'dependency',
583
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@hapi/hapi'] ? '@hapi/hapi' : null,
584
+ },
585
+ {
586
+ tech: 'Hapi',
587
+ category: 'backend',
588
+ evidenceType: 'devDependency',
589
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@hapi/hapi'] ? '@hapi/hapi' : null,
590
+ },
591
+ // ── Django ────────────────────────────────────────────────────────────────
592
+ // manage.py at root is the canonical Django management script. It is
593
+ // generated by `django-admin startproject` and is unique to Django.
594
+ // Treated as manifest-level evidence (0.60) so Django crosses the threshold
595
+ // on its own. settings.py provides additional supporting evidence.
596
+ {
597
+ tech: 'Django',
598
+ category: 'backend',
599
+ evidenceType: 'manifest',
600
+ match: ({ flatFiles }) => flatFiles.includes('manage.py') ? 'manage.py' : null,
601
+ },
602
+ {
603
+ tech: 'Django',
604
+ category: 'backend',
605
+ evidenceType: 'file',
606
+ match: ({ flatFiles }) => flatFiles.some(f => f === 'settings.py' || f.endsWith('/settings.py'))
607
+ ? 'settings.py'
608
+ : null,
609
+ },
610
+ // ── Flask ─────────────────────────────────────────────────────────────────
611
+ // app.py at root + a Python manifest (requirements.txt / pyproject.toml /
612
+ // Pipfile) is treated as manifest-level evidence (0.60).
613
+ // wsgi.py provides additional supporting evidence.
614
+ {
615
+ tech: 'Flask',
616
+ category: 'backend',
617
+ evidenceType: 'manifest',
618
+ match: ({ flatFiles }) => {
619
+ const hasApp = flatFiles.includes('app.py');
620
+ const hasPyManifest = flatFiles.includes('requirements.txt') ||
621
+ flatFiles.includes('pyproject.toml') ||
622
+ flatFiles.includes('Pipfile');
623
+ return hasApp && hasPyManifest ? 'app.py' : null;
624
+ },
625
+ },
626
+ {
627
+ tech: 'Flask',
628
+ category: 'backend',
629
+ evidenceType: 'file',
630
+ match: ({ flatFiles }) => flatFiles.some(f => f === 'wsgi.py' || f.endsWith('/wsgi.py'))
631
+ ? 'wsgi.py'
632
+ : null,
633
+ },
634
+ // ── FastAPI ───────────────────────────────────────────────────────────────
635
+ // main.py at root + a Python manifest = manifest-level evidence (0.60).
636
+ // routers/ directory is additional supporting evidence.
637
+ {
638
+ tech: 'FastAPI',
639
+ category: 'backend',
640
+ evidenceType: 'manifest',
641
+ match: ({ flatFiles }) => {
642
+ const hasMain = flatFiles.includes('main.py');
643
+ const hasPyManifest = flatFiles.includes('requirements.txt') ||
644
+ flatFiles.includes('pyproject.toml') ||
645
+ flatFiles.includes('Pipfile');
646
+ return hasMain && hasPyManifest ? 'main.py' : null;
647
+ },
648
+ },
649
+ {
650
+ tech: 'FastAPI',
651
+ category: 'backend',
652
+ evidenceType: 'directory',
653
+ match: ({ flatFiles }) => flatFiles.some(f => f.startsWith('routers/') || f.startsWith('app/routers/'))
654
+ ? 'routers/ directory'
655
+ : null,
656
+ },
657
+ // ── Spring Boot ───────────────────────────────────────────────────────────
658
+ // pom.xml and build.gradle are root-level Java build files — structural signals.
659
+ // *Application.java is the canonical Spring Boot entry-point convention.
660
+ // requiresStrongEvidence: false — Spring Boot is legitimately identified by
661
+ // build files alone (Java projects have no package.json equivalent).
662
+ {
663
+ tech: 'Spring Boot',
664
+ category: 'backend',
665
+ evidenceType: 'config',
666
+ requiresStrongEvidence: false,
667
+ match: ({ configs }) => configs.includes('pom.xml') ? 'pom.xml' : null,
668
+ },
669
+ {
670
+ tech: 'Spring Boot',
671
+ category: 'backend',
672
+ evidenceType: 'config',
673
+ requiresStrongEvidence: false,
674
+ match: ({ configs }) => configs.includes('build.gradle') || configs.includes('build.gradle.kts')
675
+ ? (configs.includes('build.gradle') ? 'build.gradle' : 'build.gradle.kts')
676
+ : null,
677
+ },
678
+ {
679
+ tech: 'Spring Boot',
680
+ category: 'backend',
681
+ evidenceType: 'file',
682
+ requiresStrongEvidence: false,
683
+ match: ({ flatFiles }) => flatFiles.some(f => f.endsWith('Application.java'))
684
+ ? 'Application.java'
685
+ : null,
686
+ },
687
+ // ── Laravel ───────────────────────────────────────────────────────────────
688
+ // artisan is present in every Laravel project (0.60 manifest).
689
+ // composer.json provides additional PHP-project confirmation.
690
+ {
691
+ tech: 'Laravel',
692
+ category: 'backend',
693
+ evidenceType: 'manifest',
694
+ match: ({ flatFiles }) => flatFiles.includes('artisan') ? 'artisan' : null,
695
+ },
696
+ {
697
+ tech: 'Laravel',
698
+ category: 'backend',
699
+ evidenceType: 'file',
700
+ match: ({ flatFiles }) => flatFiles.includes('composer.json') ? 'composer.json' : null,
701
+ },
702
+ // ── Rails ─────────────────────────────────────────────────────────────────
703
+ // Gemfile (0.60 manifest) + config/routes.rb (0.15 file) = 0.75.
704
+ {
705
+ tech: 'Rails',
706
+ category: 'backend',
707
+ evidenceType: 'manifest',
708
+ match: ({ flatFiles }) => flatFiles.includes('Gemfile') ? 'Gemfile' : null,
709
+ },
710
+ {
711
+ tech: 'Rails',
712
+ category: 'backend',
713
+ evidenceType: 'file',
714
+ match: ({ flatFiles }) => flatFiles.includes('config/routes.rb') ? 'config/routes.rb' : null,
715
+ },
716
+ // ==========================================================================
717
+ // DATABASES
718
+ // ==========================================================================
719
+ //
720
+ // Evidence: package.json deps OR structural config/schema files.
721
+ // Safety: .env files are NEVER read. DATABASE_URL / connection strings are
722
+ // NEVER used as evidence — only package names and config files.
723
+ // ── PostgreSQL ────────────────────────────────────────────────────────────
724
+ {
725
+ tech: 'PostgreSQL',
726
+ category: 'database',
727
+ evidenceType: 'dependency',
728
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['pg'] ? 'pg' : null,
729
+ },
730
+ {
731
+ tech: 'PostgreSQL',
732
+ category: 'database',
733
+ evidenceType: 'devDependency',
734
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['pg'] ? 'pg' : null,
735
+ },
736
+ {
737
+ tech: 'PostgreSQL',
738
+ category: 'database',
739
+ evidenceType: 'dependency',
740
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['postgres'] ? 'postgres' : null,
741
+ },
742
+ {
743
+ tech: 'PostgreSQL',
744
+ category: 'database',
745
+ evidenceType: 'dependency',
746
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@neondatabase/serverless']
747
+ ? '@neondatabase/serverless'
748
+ : null,
749
+ },
750
+ // ── MySQL ─────────────────────────────────────────────────────────────────
751
+ {
752
+ tech: 'MySQL',
753
+ category: 'database',
754
+ evidenceType: 'dependency',
755
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['mysql'] ? 'mysql' : null,
756
+ },
757
+ {
758
+ tech: 'MySQL',
759
+ category: 'database',
760
+ evidenceType: 'dependency',
761
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['mysql2'] ? 'mysql2' : null,
762
+ },
763
+ {
764
+ tech: 'MySQL',
765
+ category: 'database',
766
+ evidenceType: 'devDependency',
767
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['mysql2'] ? 'mysql2' : null,
768
+ },
769
+ // ── MariaDB ───────────────────────────────────────────────────────────────
770
+ {
771
+ tech: 'MariaDB',
772
+ category: 'database',
773
+ evidenceType: 'dependency',
774
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['mariadb'] ? 'mariadb' : null,
775
+ },
776
+ {
777
+ tech: 'MariaDB',
778
+ category: 'database',
779
+ evidenceType: 'devDependency',
780
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['mariadb'] ? 'mariadb' : null,
781
+ },
782
+ // ── SQLite ────────────────────────────────────────────────────────────────
783
+ {
784
+ tech: 'SQLite',
785
+ category: 'database',
786
+ evidenceType: 'dependency',
787
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['sqlite3'] ? 'sqlite3' : null,
788
+ },
789
+ {
790
+ tech: 'SQLite',
791
+ category: 'database',
792
+ evidenceType: 'devDependency',
793
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['sqlite3'] ? 'sqlite3' : null,
794
+ },
795
+ {
796
+ tech: 'SQLite',
797
+ category: 'database',
798
+ evidenceType: 'dependency',
799
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['better-sqlite3'] ? 'better-sqlite3' : null,
800
+ },
801
+ {
802
+ tech: 'SQLite',
803
+ category: 'database',
804
+ evidenceType: 'devDependency',
805
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['better-sqlite3'] ? 'better-sqlite3' : null,
806
+ },
807
+ {
808
+ tech: 'SQLite',
809
+ category: 'database',
810
+ evidenceType: 'dependency',
811
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@libsql/client'] ? '@libsql/client' : null,
812
+ },
813
+ {
814
+ // *.db / *.sqlite files are supporting structural evidence.
815
+ tech: 'SQLite',
816
+ category: 'database',
817
+ evidenceType: 'file',
818
+ match: ({ flatFiles }) => flatFiles.some(f => /\.(db|sqlite|sqlite3)$/.test(f))
819
+ ? '*.db / *.sqlite file'
820
+ : null,
821
+ },
822
+ // ── MongoDB ───────────────────────────────────────────────────────────────
823
+ {
824
+ tech: 'MongoDB',
825
+ category: 'database',
826
+ evidenceType: 'dependency',
827
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['mongodb'] ? 'mongodb' : null,
828
+ },
829
+ {
830
+ tech: 'MongoDB',
831
+ category: 'database',
832
+ evidenceType: 'devDependency',
833
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['mongodb'] ? 'mongodb' : null,
834
+ },
835
+ // ── Redis ─────────────────────────────────────────────────────────────────
836
+ {
837
+ tech: 'Redis',
838
+ category: 'database',
839
+ evidenceType: 'dependency',
840
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['redis'] ? 'redis' : null,
841
+ },
842
+ {
843
+ tech: 'Redis',
844
+ category: 'database',
845
+ evidenceType: 'devDependency',
846
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['redis'] ? 'redis' : null,
847
+ },
848
+ {
849
+ tech: 'Redis',
850
+ category: 'database',
851
+ evidenceType: 'dependency',
852
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['ioredis'] ? 'ioredis' : null,
853
+ },
854
+ {
855
+ tech: 'Redis',
856
+ category: 'database',
857
+ evidenceType: 'devDependency',
858
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['ioredis'] ? 'ioredis' : null,
859
+ },
860
+ // ==========================================================================
861
+ // ORM / DATA-ACCESS TOOLS
862
+ // ==========================================================================
863
+ //
864
+ // Each ORM has independent evidence — never inferred from the database type.
865
+ // Schema/config files are detected via presence only; content is never read.
866
+ // ── Prisma ────────────────────────────────────────────────────────────────
867
+ {
868
+ tech: 'Prisma',
869
+ category: 'orm',
870
+ evidenceType: 'dependency',
871
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@prisma/client'] ? '@prisma/client' : null,
872
+ },
873
+ {
874
+ tech: 'Prisma',
875
+ category: 'orm',
876
+ evidenceType: 'devDependency',
877
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['prisma'] ? 'prisma' : null,
878
+ },
879
+ {
880
+ tech: 'Prisma',
881
+ category: 'orm',
882
+ evidenceType: 'devDependency',
883
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@prisma/client'] ? '@prisma/client' : null,
884
+ },
885
+ {
886
+ // schema.prisma presence is structural evidence — file is NOT read.
887
+ tech: 'Prisma',
888
+ category: 'orm',
889
+ evidenceType: 'file',
890
+ match: ({ flatFiles }) => flatFiles.some(f => f === 'prisma/schema.prisma' || f.endsWith('/schema.prisma'))
891
+ ? 'prisma/schema.prisma'
892
+ : null,
893
+ },
894
+ // ── Drizzle ───────────────────────────────────────────────────────────────
895
+ {
896
+ tech: 'Drizzle',
897
+ category: 'orm',
898
+ evidenceType: 'dependency',
899
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['drizzle-orm'] ? 'drizzle-orm' : null,
900
+ },
901
+ {
902
+ tech: 'Drizzle',
903
+ category: 'orm',
904
+ evidenceType: 'devDependency',
905
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['drizzle-orm'] ? 'drizzle-orm' : null,
906
+ },
907
+ {
908
+ tech: 'Drizzle',
909
+ category: 'orm',
910
+ evidenceType: 'devDependency',
911
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['drizzle-kit'] ? 'drizzle-kit' : null,
912
+ },
913
+ // ── TypeORM ───────────────────────────────────────────────────────────────
914
+ {
915
+ tech: 'TypeORM',
916
+ category: 'orm',
917
+ evidenceType: 'dependency',
918
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['typeorm'] ? 'typeorm' : null,
919
+ },
920
+ {
921
+ tech: 'TypeORM',
922
+ category: 'orm',
923
+ evidenceType: 'devDependency',
924
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['typeorm'] ? 'typeorm' : null,
925
+ },
926
+ // ── Sequelize ─────────────────────────────────────────────────────────────
927
+ {
928
+ tech: 'Sequelize',
929
+ category: 'orm',
930
+ evidenceType: 'dependency',
931
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['sequelize'] ? 'sequelize' : null,
932
+ },
933
+ {
934
+ tech: 'Sequelize',
935
+ category: 'orm',
936
+ evidenceType: 'devDependency',
937
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['sequelize'] ? 'sequelize' : null,
938
+ },
939
+ // ── Mongoose ──────────────────────────────────────────────────────────────
940
+ {
941
+ tech: 'Mongoose',
942
+ category: 'orm',
943
+ evidenceType: 'dependency',
944
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['mongoose'] ? 'mongoose' : null,
945
+ },
946
+ {
947
+ tech: 'Mongoose',
948
+ category: 'orm',
949
+ evidenceType: 'devDependency',
950
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['mongoose'] ? 'mongoose' : null,
951
+ },
952
+ // ── Knex ──────────────────────────────────────────────────────────────────
953
+ {
954
+ tech: 'Knex',
955
+ category: 'orm',
956
+ evidenceType: 'dependency',
957
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['knex'] ? 'knex' : null,
958
+ },
959
+ {
960
+ tech: 'Knex',
961
+ category: 'orm',
962
+ evidenceType: 'devDependency',
963
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['knex'] ? 'knex' : null,
964
+ },
965
+ // ── SQLAlchemy ────────────────────────────────────────────────────────────
966
+ // alembic.ini + Python manifest (requirements.txt / pyproject.toml / Pipfile)
967
+ // = manifest-level evidence (0.60). alembic.ini alone = config (0.25 < threshold).
968
+ {
969
+ tech: 'SQLAlchemy',
970
+ category: 'orm',
971
+ evidenceType: 'config',
972
+ match: ({ flatFiles }) => flatFiles.includes('alembic.ini') ? 'alembic.ini' : null,
973
+ },
974
+ {
975
+ tech: 'SQLAlchemy',
976
+ category: 'orm',
977
+ evidenceType: 'manifest',
978
+ match: ({ flatFiles }) => {
979
+ const hasAlembic = flatFiles.includes('alembic.ini');
980
+ const hasPython = flatFiles.includes('requirements.txt') ||
981
+ flatFiles.includes('pyproject.toml') ||
982
+ flatFiles.includes('Pipfile');
983
+ return hasAlembic && hasPython ? 'alembic.ini (SQLAlchemy/Alembic)' : null;
984
+ },
985
+ },
986
+ // ── Hibernate ─────────────────────────────────────────────────────────────
987
+ // persistence.xml (JPA standard) or hibernate.cfg.xml (classic config).
988
+ // pom.xml/build.gradle (0.25 each) + persistence.xml (0.15) = 0.40 < threshold.
989
+ // Use persistence.xml as manifest-level (0.60) when pom.xml is also present.
990
+ {
991
+ tech: 'Hibernate',
992
+ category: 'orm',
993
+ evidenceType: 'manifest',
994
+ match: ({ flatFiles, configs }) => {
995
+ const hasBuildFile = configs.includes('pom.xml') ||
996
+ configs.includes('build.gradle') ||
997
+ configs.includes('build.gradle.kts');
998
+ const hasPersistence = flatFiles.some(f => f.endsWith('persistence.xml'));
999
+ return hasBuildFile && hasPersistence ? 'persistence.xml' : null;
1000
+ },
1001
+ },
1002
+ {
1003
+ tech: 'Hibernate',
1004
+ category: 'orm',
1005
+ evidenceType: 'manifest',
1006
+ match: ({ flatFiles, configs }) => {
1007
+ const hasBuildFile = configs.includes('pom.xml') ||
1008
+ configs.includes('build.gradle') ||
1009
+ configs.includes('build.gradle.kts');
1010
+ const hasHibernateCfg = flatFiles.some(f => f.endsWith('hibernate.cfg.xml'));
1011
+ return hasBuildFile && hasHibernateCfg ? 'hibernate.cfg.xml' : null;
1012
+ },
1013
+ },
1014
+ // ── Django ORM ────────────────────────────────────────────────────────────
1015
+ // models.py + manage.py = manifest-level evidence (0.60).
1016
+ {
1017
+ tech: 'Django ORM',
1018
+ category: 'orm',
1019
+ evidenceType: 'manifest',
1020
+ match: ({ flatFiles }) => {
1021
+ const hasManage = flatFiles.includes('manage.py');
1022
+ const hasModels = flatFiles.some(f => f === 'models.py' || f.endsWith('/models.py'));
1023
+ return hasManage && hasModels ? 'models.py' : null;
1024
+ },
1025
+ },
1026
+ // ── Eloquent ──────────────────────────────────────────────────────────────
1027
+ // artisan + app/Models/ directory = manifest-level evidence (0.60).
1028
+ {
1029
+ tech: 'Eloquent',
1030
+ category: 'orm',
1031
+ evidenceType: 'manifest',
1032
+ match: ({ flatFiles }) => {
1033
+ const hasArtisan = flatFiles.includes('artisan');
1034
+ const hasModels = flatFiles.some(f => f.startsWith('app/Models/'));
1035
+ return hasArtisan && hasModels ? 'app/Models/ (Eloquent)' : null;
1036
+ },
1037
+ },
1038
+ // ── Active Record ─────────────────────────────────────────────────────────
1039
+ // Gemfile + db/schema.rb = manifest-level evidence (0.60).
1040
+ {
1041
+ tech: 'Active Record',
1042
+ category: 'orm',
1043
+ evidenceType: 'manifest',
1044
+ match: ({ flatFiles }) => {
1045
+ const hasGemfile = flatFiles.includes('Gemfile');
1046
+ const hasSchema = flatFiles.includes('db/schema.rb');
1047
+ return hasGemfile && hasSchema ? 'db/schema.rb' : null;
1048
+ },
1049
+ },
1050
+ // ==========================================================================
1051
+ // PACKAGE MANAGERS
1052
+ // ==========================================================================
1053
+ // ── npm ───────────────────────────────────────────────────────────────────
1054
+ {
1055
+ tech: 'npm',
1056
+ category: 'package-manager',
1057
+ evidenceType: 'file',
1058
+ match: ({ packageManager, flatFiles }) => packageManager === 'npm' || flatFiles.includes('package-lock.json')
1059
+ ? 'package-lock.json'
1060
+ : null,
1061
+ },
1062
+ // ── pnpm ──────────────────────────────────────────────────────────────────
1063
+ {
1064
+ tech: 'pnpm',
1065
+ category: 'package-manager',
1066
+ evidenceType: 'file',
1067
+ match: ({ packageManager, flatFiles }) => packageManager === 'pnpm' || flatFiles.includes('pnpm-lock.yaml')
1068
+ ? 'pnpm-lock.yaml'
1069
+ : null,
1070
+ },
1071
+ // ── Yarn ──────────────────────────────────────────────────────────────────
1072
+ {
1073
+ tech: 'Yarn',
1074
+ category: 'package-manager',
1075
+ evidenceType: 'file',
1076
+ match: ({ packageManager, flatFiles }) => packageManager === 'yarn' || flatFiles.includes('yarn.lock')
1077
+ ? 'yarn.lock'
1078
+ : null,
1079
+ },
1080
+ // ── Bun ───────────────────────────────────────────────────────────────────
1081
+ {
1082
+ tech: 'Bun',
1083
+ category: 'package-manager',
1084
+ evidenceType: 'file',
1085
+ match: ({ packageManager, flatFiles }) => packageManager === 'bun' ||
1086
+ flatFiles.includes('bun.lock') ||
1087
+ flatFiles.includes('bun.lockb')
1088
+ ? (flatFiles.includes('bun.lockb') ? 'bun.lockb' : 'bun.lock')
1089
+ : null,
1090
+ },
1091
+ // ==========================================================================
1092
+ // TESTING FRAMEWORKS
1093
+ // ==========================================================================
1094
+ // ── Jest ─────────────────────────────────────────────────────────────────────
1095
+ {
1096
+ tech: 'Jest',
1097
+ category: 'testing',
1098
+ evidenceType: 'dependency',
1099
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['jest'] ? 'jest' : null,
1100
+ },
1101
+ {
1102
+ tech: 'Jest',
1103
+ category: 'testing',
1104
+ evidenceType: 'dependency',
1105
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['jest'] ? 'jest' : null,
1106
+ },
1107
+ {
1108
+ tech: 'Jest',
1109
+ category: 'testing',
1110
+ evidenceType: 'manifest',
1111
+ match: ({ configs }) => {
1112
+ const found = configs.find(c => /^jest\.config\.[a-zA-Z0-9]+$/.test(c));
1113
+ return found ?? null;
1114
+ },
1115
+ },
1116
+ {
1117
+ tech: 'Jest',
1118
+ category: 'testing',
1119
+ evidenceType: 'script',
1120
+ match: ({ scripts }) => scripts.some(s => /\bjest\b/.test(s.command)) ? 'jest script' : null,
1121
+ },
1122
+ // ── Vitest ────────────────────────────────────────────────────────────────────
1123
+ {
1124
+ tech: 'Vitest',
1125
+ category: 'testing',
1126
+ evidenceType: 'devDependency',
1127
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['vitest'] ? 'vitest' : null,
1128
+ },
1129
+ {
1130
+ tech: 'Vitest',
1131
+ category: 'testing',
1132
+ evidenceType: 'dependency',
1133
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['vitest'] ? 'vitest' : null,
1134
+ },
1135
+ {
1136
+ tech: 'Vitest',
1137
+ category: 'testing',
1138
+ evidenceType: 'manifest',
1139
+ match: ({ configs }) => {
1140
+ const found = configs.find(c => /^vitest\.config\.[a-zA-Z0-9]+$/.test(c));
1141
+ return found ?? null;
1142
+ },
1143
+ },
1144
+ // ── Mocha ────────────────────────────────────────────────────────────────────
1145
+ {
1146
+ tech: 'Mocha',
1147
+ category: 'testing',
1148
+ evidenceType: 'devDependency',
1149
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['mocha'] ? 'mocha' : null,
1150
+ },
1151
+ {
1152
+ tech: 'Mocha',
1153
+ category: 'testing',
1154
+ evidenceType: 'dependency',
1155
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['mocha'] ? 'mocha' : null,
1156
+ },
1157
+ {
1158
+ tech: 'Mocha',
1159
+ category: 'testing',
1160
+ evidenceType: 'manifest',
1161
+ match: ({ configs }) => configs.includes('.mocharc.yml') || configs.includes('.mocharc.json') ||
1162
+ configs.includes('.mocharc.js') || configs.includes('.mocharc.cjs')
1163
+ ? '.mocharc.*'
1164
+ : null,
1165
+ },
1166
+ // ── Playwright ────────────────────────────────────────────────────────────────
1167
+ {
1168
+ tech: 'Playwright',
1169
+ category: 'testing',
1170
+ evidenceType: 'devDependency',
1171
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@playwright/test'] ? '@playwright/test' : null,
1172
+ },
1173
+ {
1174
+ tech: 'Playwright',
1175
+ category: 'testing',
1176
+ evidenceType: 'dependency',
1177
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@playwright/test'] ? '@playwright/test' : null,
1178
+ },
1179
+ {
1180
+ tech: 'Playwright',
1181
+ category: 'testing',
1182
+ evidenceType: 'manifest',
1183
+ match: ({ configs, flatFiles }) => {
1184
+ const found = configs.find(c => /^playwright\.config\.[a-zA-Z0-9]+$/.test(c)) ??
1185
+ flatFiles.find(f => /^playwright\.config\.[a-zA-Z0-9]+$/.test(f.split('/').pop() ?? ''));
1186
+ return found ? 'playwright.config.*' : null;
1187
+ },
1188
+ },
1189
+ // ── Cypress ────────────────────────────────────────────────────────────────────
1190
+ {
1191
+ tech: 'Cypress',
1192
+ category: 'testing',
1193
+ evidenceType: 'devDependency',
1194
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['cypress'] ? 'cypress' : null,
1195
+ },
1196
+ {
1197
+ tech: 'Cypress',
1198
+ category: 'testing',
1199
+ evidenceType: 'dependency',
1200
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['cypress'] ? 'cypress' : null,
1201
+ },
1202
+ {
1203
+ tech: 'Cypress',
1204
+ category: 'testing',
1205
+ evidenceType: 'manifest',
1206
+ match: ({ configs, flatFiles }) => {
1207
+ const found = configs.find(c => /^cypress\.config\.[a-zA-Z0-9]+$/.test(c)) ??
1208
+ flatFiles.find(f => /^cypress\.config\.[a-zA-Z0-9]+$/.test(f.split('/').pop() ?? ''));
1209
+ return found ? 'cypress.config.*' : null;
1210
+ },
1211
+ },
1212
+ // ── Testing Library ──────────────────────────────────────────────────────────
1213
+ {
1214
+ tech: 'Testing Library',
1215
+ category: 'testing',
1216
+ evidenceType: 'devDependency',
1217
+ match: ({ packageManifest }) => {
1218
+ const devDeps = packageManifest?.devDependencies ?? {};
1219
+ const found = Object.keys(devDeps).find(k => k.startsWith('@testing-library/'));
1220
+ return found ?? null;
1221
+ },
1222
+ },
1223
+ {
1224
+ tech: 'Testing Library',
1225
+ category: 'testing',
1226
+ evidenceType: 'dependency',
1227
+ match: ({ packageManifest }) => {
1228
+ const deps = packageManifest?.dependencies ?? {};
1229
+ const found = Object.keys(deps).find(k => k.startsWith('@testing-library/'));
1230
+ return found ?? null;
1231
+ },
1232
+ },
1233
+ // ── Pytest ────────────────────────────────────────────────────────────────────
1234
+ // pytest.ini or pyproject.toml + a Python project = manifest evidence (0.60)
1235
+ {
1236
+ tech: 'Pytest',
1237
+ category: 'testing',
1238
+ evidenceType: 'manifest',
1239
+ match: ({ flatFiles }) => {
1240
+ const hasPytest = flatFiles.includes('pytest.ini') || flatFiles.includes('setup.cfg');
1241
+ const hasPython = flatFiles.includes('requirements.txt') ||
1242
+ flatFiles.includes('pyproject.toml') ||
1243
+ flatFiles.includes('Pipfile');
1244
+ return hasPytest && hasPython ? 'pytest.ini' : null;
1245
+ },
1246
+ },
1247
+ {
1248
+ tech: 'Pytest',
1249
+ category: 'testing',
1250
+ evidenceType: 'file',
1251
+ match: ({ flatFiles }) => {
1252
+ // conftest.py is a pytest-specific file
1253
+ return flatFiles.some(f => f === 'conftest.py' || f.endsWith('/conftest.py'))
1254
+ ? 'conftest.py'
1255
+ : null;
1256
+ },
1257
+ },
1258
+ // ── JUnit ────────────────────────────────────────────────────────────────────
1259
+ // JUnit is detected via Java build files + test source patterns
1260
+ {
1261
+ tech: 'JUnit',
1262
+ category: 'testing',
1263
+ evidenceType: 'manifest',
1264
+ match: ({ flatFiles, configs }) => {
1265
+ const hasBuild = configs.includes('pom.xml') ||
1266
+ configs.includes('build.gradle') ||
1267
+ configs.includes('build.gradle.kts');
1268
+ const hasTestJava = flatFiles.some(f => f.endsWith('Test.java') || f.endsWith('Tests.java'));
1269
+ return hasBuild && hasTestJava ? 'Test.java' : null;
1270
+ },
1271
+ },
1272
+ // ==========================================================================
1273
+ // BUILD TOOLS
1274
+ // ==========================================================================
1275
+ // ── Vite ─────────────────────────────────────────────────────────────────────
1276
+ {
1277
+ tech: 'Vite',
1278
+ category: 'build',
1279
+ evidenceType: 'devDependency',
1280
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['vite'] ? 'vite' : null,
1281
+ },
1282
+ {
1283
+ tech: 'Vite',
1284
+ category: 'build',
1285
+ evidenceType: 'dependency',
1286
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['vite'] ? 'vite' : null,
1287
+ },
1288
+ {
1289
+ tech: 'Vite',
1290
+ category: 'build',
1291
+ evidenceType: 'manifest',
1292
+ match: ({ configs }) => {
1293
+ const found = configs.find(c => /^vite\.config\.[a-zA-Z0-9]+$/.test(c));
1294
+ return found ?? null;
1295
+ },
1296
+ },
1297
+ // ── Webpack ───────────────────────────────────────────────────────────────────
1298
+ // IMPORTANT: Only detect explicit usage. Do NOT detect webpack just because
1299
+ // a framework may use it internally (e.g. Next.js uses webpack internally).
1300
+ {
1301
+ tech: 'Webpack',
1302
+ category: 'build',
1303
+ evidenceType: 'devDependency',
1304
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['webpack'] ? 'webpack' : null,
1305
+ },
1306
+ {
1307
+ tech: 'Webpack',
1308
+ category: 'build',
1309
+ evidenceType: 'dependency',
1310
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['webpack'] ? 'webpack' : null,
1311
+ },
1312
+ {
1313
+ tech: 'Webpack',
1314
+ category: 'build',
1315
+ evidenceType: 'manifest',
1316
+ match: ({ configs, flatFiles }) => {
1317
+ const found = configs.find(c => /^webpack\.config\.[a-zA-Z0-9]+$/.test(c)) ??
1318
+ flatFiles.find(f => /^webpack\.config\.[a-zA-Z0-9]+$/.test(f.split('/').pop() ?? ''));
1319
+ return found ? 'webpack.config.*' : null;
1320
+ },
1321
+ },
1322
+ // ── Rollup ────────────────────────────────────────────────────────────────────
1323
+ {
1324
+ tech: 'Rollup',
1325
+ category: 'build',
1326
+ evidenceType: 'devDependency',
1327
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['rollup'] ? 'rollup' : null,
1328
+ },
1329
+ {
1330
+ tech: 'Rollup',
1331
+ category: 'build',
1332
+ evidenceType: 'dependency',
1333
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['rollup'] ? 'rollup' : null,
1334
+ },
1335
+ {
1336
+ tech: 'Rollup',
1337
+ category: 'build',
1338
+ evidenceType: 'manifest',
1339
+ match: ({ configs, flatFiles }) => {
1340
+ const found = configs.find(c => /^rollup\.config\.[a-zA-Z0-9]+$/.test(c)) ??
1341
+ flatFiles.find(f => /^rollup\.config\.[a-zA-Z0-9]+$/.test(f.split('/').pop() ?? ''));
1342
+ return found ? 'rollup.config.*' : null;
1343
+ },
1344
+ },
1345
+ // ── esbuild ───────────────────────────────────────────────────────────────────
1346
+ {
1347
+ tech: 'esbuild',
1348
+ category: 'build',
1349
+ evidenceType: 'devDependency',
1350
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['esbuild'] ? 'esbuild' : null,
1351
+ },
1352
+ {
1353
+ tech: 'esbuild',
1354
+ category: 'build',
1355
+ evidenceType: 'dependency',
1356
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['esbuild'] ? 'esbuild' : null,
1357
+ },
1358
+ // ==========================================================================
1359
+ // QUALITY TOOLS
1360
+ // ==========================================================================
1361
+ // ── ESLint ────────────────────────────────────────────────────────────────────
1362
+ {
1363
+ tech: 'ESLint',
1364
+ category: 'quality',
1365
+ evidenceType: 'devDependency',
1366
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['eslint'] ? 'eslint' : null,
1367
+ },
1368
+ {
1369
+ tech: 'ESLint',
1370
+ category: 'quality',
1371
+ evidenceType: 'dependency',
1372
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['eslint'] ? 'eslint' : null,
1373
+ },
1374
+ {
1375
+ tech: 'ESLint',
1376
+ category: 'quality',
1377
+ evidenceType: 'manifest',
1378
+ match: ({ configs, flatFiles }) => {
1379
+ // eslint.config.* (flat config, v9+)
1380
+ const flatConfig = configs.find(c => /^eslint\.config\.[a-zA-Z0-9]+$/.test(c));
1381
+ if (flatConfig)
1382
+ return flatConfig;
1383
+ // .eslintrc* legacy configs
1384
+ const legacyConfig = flatFiles.find(f => {
1385
+ const base = f.split('/').pop() ?? '';
1386
+ return /^\.eslintrc(\.json|\.js|\.cjs|\.yml|\.yaml|)$/.test(base);
1387
+ });
1388
+ return legacyConfig ? '.eslintrc*' : null;
1389
+ },
1390
+ },
1391
+ // ── Prettier ──────────────────────────────────────────────────────────────────
1392
+ {
1393
+ tech: 'Prettier',
1394
+ category: 'quality',
1395
+ evidenceType: 'devDependency',
1396
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['prettier'] ? 'prettier' : null,
1397
+ },
1398
+ {
1399
+ tech: 'Prettier',
1400
+ category: 'quality',
1401
+ evidenceType: 'dependency',
1402
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['prettier'] ? 'prettier' : null,
1403
+ },
1404
+ {
1405
+ tech: 'Prettier',
1406
+ category: 'quality',
1407
+ evidenceType: 'manifest',
1408
+ match: ({ flatFiles }) => {
1409
+ const found = flatFiles.find(f => {
1410
+ const base = f.split('/').pop() ?? '';
1411
+ return /^\.prettierrc(\.json|\.js|\.cjs|\.yml|\.yaml|)$/.test(base);
1412
+ });
1413
+ return found ? '.prettierrc*' : null;
1414
+ },
1415
+ },
1416
+ // ── Biome ────────────────────────────────────────────────────────────────────
1417
+ {
1418
+ tech: 'Biome',
1419
+ category: 'quality',
1420
+ evidenceType: 'devDependency',
1421
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['@biomejs/biome'] ? '@biomejs/biome' : null,
1422
+ },
1423
+ {
1424
+ tech: 'Biome',
1425
+ category: 'quality',
1426
+ evidenceType: 'dependency',
1427
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['@biomejs/biome'] ? '@biomejs/biome' : null,
1428
+ },
1429
+ {
1430
+ tech: 'Biome',
1431
+ category: 'quality',
1432
+ evidenceType: 'manifest',
1433
+ match: ({ configs, flatFiles }) => configs.includes('biome.json') || flatFiles.includes('biome.json')
1434
+ ? 'biome.json'
1435
+ : null,
1436
+ },
1437
+ // ── Stylelint ─────────────────────────────────────────────────────────────────
1438
+ {
1439
+ tech: 'Stylelint',
1440
+ category: 'quality',
1441
+ evidenceType: 'devDependency',
1442
+ match: ({ packageManifest }) => packageManifest?.devDependencies?.['stylelint'] ? 'stylelint' : null,
1443
+ },
1444
+ {
1445
+ tech: 'Stylelint',
1446
+ category: 'quality',
1447
+ evidenceType: 'dependency',
1448
+ match: ({ packageManifest }) => packageManifest?.dependencies?.['stylelint'] ? 'stylelint' : null,
1449
+ },
1450
+ {
1451
+ tech: 'Stylelint',
1452
+ category: 'quality',
1453
+ evidenceType: 'manifest',
1454
+ match: ({ flatFiles }) => {
1455
+ const found = flatFiles.find(f => {
1456
+ const base = f.split('/').pop() ?? '';
1457
+ return /^\.stylelintrc(\.json|\.js|\.cjs|\.yml|\.yaml|)$/.test(base) || base === 'stylelint.config.js' || base === 'stylelint.config.cjs';
1458
+ });
1459
+ return found ? '.stylelintrc*' : null;
1460
+ },
1461
+ },
1462
+ // ==========================================================================
1463
+ // CONTAINER
1464
+ // ==========================================================================
1465
+ // ── Docker ────────────────────────────────────────────────────────────────────
1466
+ {
1467
+ tech: 'Docker',
1468
+ category: 'container',
1469
+ evidenceType: 'manifest',
1470
+ match: ({ flatFiles }) => flatFiles.some(f => f === 'Dockerfile' || /\/Dockerfile$/.test(f) || /^Dockerfile\.[a-zA-Z0-9]+$/.test(f.split('/').pop() ?? ''))
1471
+ ? 'Dockerfile'
1472
+ : null,
1473
+ },
1474
+ // ── Docker Compose ────────────────────────────────────────────────────────────
1475
+ {
1476
+ tech: 'Docker Compose',
1477
+ category: 'container',
1478
+ evidenceType: 'manifest',
1479
+ match: ({ flatFiles }) => flatFiles.some(f => {
1480
+ const base = f.split('/').pop() ?? '';
1481
+ return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'].includes(base);
1482
+ })
1483
+ ? 'docker-compose.yml'
1484
+ : null,
1485
+ },
1486
+ // ==========================================================================
1487
+ // DEPLOYMENT
1488
+ // ==========================================================================
1489
+ // ── Vercel ────────────────────────────────────────────────────────────────────
1490
+ // Only detect from explicit vercel.json — NOT from Next.js alone.
1491
+ {
1492
+ tech: 'Vercel',
1493
+ category: 'deployment',
1494
+ evidenceType: 'manifest',
1495
+ match: ({ configs, flatFiles }) => configs.includes('vercel.json') || flatFiles.includes('vercel.json')
1496
+ ? 'vercel.json'
1497
+ : null,
1498
+ },
1499
+ // ── Netlify ───────────────────────────────────────────────────────────────────
1500
+ {
1501
+ tech: 'Netlify',
1502
+ category: 'deployment',
1503
+ evidenceType: 'manifest',
1504
+ match: ({ flatFiles }) => flatFiles.includes('netlify.toml') ? 'netlify.toml' : null,
1505
+ },
1506
+ // ── Fly.io ────────────────────────────────────────────────────────────────────
1507
+ {
1508
+ tech: 'Fly.io',
1509
+ category: 'deployment',
1510
+ evidenceType: 'manifest',
1511
+ match: ({ flatFiles }) => flatFiles.includes('fly.toml') ? 'fly.toml' : null,
1512
+ },
1513
+ // ── Railway ────────────────────────────────────────────────────────────────────
1514
+ {
1515
+ tech: 'Railway',
1516
+ category: 'deployment',
1517
+ evidenceType: 'manifest',
1518
+ match: ({ flatFiles }) => flatFiles.includes('railway.json') || flatFiles.includes('railway.toml')
1519
+ ? (flatFiles.includes('railway.json') ? 'railway.json' : 'railway.toml')
1520
+ : null,
1521
+ },
1522
+ // ── Render ────────────────────────────────────────────────────────────────────
1523
+ {
1524
+ tech: 'Render',
1525
+ category: 'deployment',
1526
+ evidenceType: 'manifest',
1527
+ match: ({ flatFiles }) => flatFiles.includes('render.yaml') || flatFiles.includes('render.yml')
1528
+ ? (flatFiles.includes('render.yaml') ? 'render.yaml' : 'render.yml')
1529
+ : null,
1530
+ },
1531
+ // ── Serverless Framework ──────────────────────────────────────────────────────
1532
+ {
1533
+ tech: 'Serverless Framework',
1534
+ category: 'deployment',
1535
+ evidenceType: 'manifest',
1536
+ match: ({ flatFiles }) => flatFiles.includes('serverless.yml') || flatFiles.includes('serverless.yaml') ||
1537
+ flatFiles.includes('serverless.json') || flatFiles.includes('serverless.ts')
1538
+ ? 'serverless.yml'
1539
+ : null,
1540
+ },
1541
+ // ── AWS SAM ───────────────────────────────────────────────────────────────────
1542
+ {
1543
+ tech: 'AWS SAM',
1544
+ category: 'deployment',
1545
+ evidenceType: 'manifest',
1546
+ match: ({ flatFiles }) => flatFiles.includes('template.yaml') || flatFiles.includes('template.yml')
1547
+ ? (flatFiles.includes('template.yaml') ? 'template.yaml' : 'template.yml')
1548
+ : null,
1549
+ },
1550
+ ];
1551
+ // ---------------------------------------------------------------------------
1552
+ // Engine helpers
1553
+ // ---------------------------------------------------------------------------
1554
+ /** Normalise a technology name to a stable deduplication key. */
1555
+ function techKey(name) {
1556
+ return name.toLowerCase().replace(/[\s./]/g, '-');
1557
+ }
1558
+ /**
1559
+ * Compute confidence from raw score.
1560
+ * Delegates to capConfidence() for consistent rounding and capping.
1561
+ * Kept as a named wrapper for clarity in the engine.
1562
+ */
1563
+ function toConfidence(rawScore) {
1564
+ return capConfidence(rawScore);
1565
+ }
1566
+ /** Category sort rank (lower = first). */
1567
+ function categoryRank(cat) {
1568
+ const idx = CATEGORY_ORDER.indexOf(cat);
1569
+ return idx === -1 ? CATEGORY_ORDER.length : idx;
1570
+ }
1571
+ /**
1572
+ * Noise directories whose files should not produce standalone file/directory
1573
+ * evidence. Package manifest signals (dependencies, configs, manifests) are
1574
+ * still respected globally.
1575
+ *
1576
+ * These paths are treated as low-priority because they commonly contain
1577
+ * nested example or fixture projects that do not represent the root stack.
1578
+ */
1579
+ const NOISE_DIR_PREFIXES = [
1580
+ 'example/',
1581
+ 'examples/',
1582
+ 'fixture/',
1583
+ 'fixtures/',
1584
+ 'demo/',
1585
+ 'demos/',
1586
+ 'sample/',
1587
+ 'samples/',
1588
+ ];
1589
+ /**
1590
+ * Returns true if a file path lives under a noise directory.
1591
+ * Only applies to file and directory evidence — package.json signals are
1592
+ * always root-level and unaffected.
1593
+ */
1594
+ function isNoisePath(filePath) {
1595
+ return NOISE_DIR_PREFIXES.some(prefix => filePath.startsWith(prefix));
1596
+ }
1597
+ /**
1598
+ * Return a filtered copy of flatFiles that excludes noise-directory paths.
1599
+ * Used to produce a "root-signal-only" view for file/directory evidence.
1600
+ */
1601
+ function rootOnlyFiles(flatFiles) {
1602
+ return flatFiles.filter(f => !isNoisePath(f));
1603
+ }
1604
+ /**
1605
+ * Sort evidence entries deterministically:
1606
+ * 1. By evidence type rank (dependency first, directory last).
1607
+ * 2. Within the same type, alphabetically by value.
1608
+ */
1609
+ function sortEvidence(evidence) {
1610
+ return [...evidence].sort((a, b) => {
1611
+ const typeDiff = evidenceTypeRank(a.type) - evidenceTypeRank(b.type);
1612
+ if (typeDiff !== 0)
1613
+ return typeDiff;
1614
+ return a.value.localeCompare(b.value);
1615
+ });
1616
+ }
1617
+ // ---------------------------------------------------------------------------
1618
+ // Public API
1619
+ // ---------------------------------------------------------------------------
1620
+ /**
1621
+ * Detect the technology stack from already-collected scan context.
1622
+ *
1623
+ * @param context - Pre-collected scan data. No disk I/O is performed.
1624
+ * @returns TechnologyStack with deduplicated, ordered, threshold-filtered
1625
+ * technologies.
1626
+ */
1627
+ export function detectTechnologyStack(context) {
1628
+ // Build a root-signal-only file list for use in file/directory rules.
1629
+ // This prevents nested example/fixture projects from dominating the output.
1630
+ const rootFiles = rootOnlyFiles(context.flatFiles);
1631
+ // Augmented context with root-only files available for rules that want it.
1632
+ // Rules that deal with package manifest signals use context directly (already root-level).
1633
+ const ctxWithRootFiles = {
1634
+ ...context,
1635
+ // flatFiles remains the full list — manifest/config rules need all files.
1636
+ // rootFiles is used internally by the engine for file/directory signals.
1637
+ };
1638
+ // Map from dedup-key → mutable accumulator
1639
+ const accumulators = new Map();
1640
+ for (const rule of TECH_RULES) {
1641
+ // For file, directory, and manifest evidence types, use the root-only file
1642
+ // list to avoid noise from nested example/fixture directories.
1643
+ // Package manifest signals from package.json (dependency/devDependency/
1644
+ // peerDependency) are already root-level and unaffected.
1645
+ const effectiveContext = (rule.evidenceType === 'file' ||
1646
+ rule.evidenceType === 'directory' ||
1647
+ rule.evidenceType === 'manifest')
1648
+ ? { ...context, flatFiles: rootFiles }
1649
+ : context;
1650
+ const matchedValue = rule.match(effectiveContext);
1651
+ if (matchedValue == null)
1652
+ continue; // rule did not fire
1653
+ const key = techKey(rule.tech);
1654
+ const evidence = {
1655
+ type: rule.evidenceType,
1656
+ value: matchedValue,
1657
+ };
1658
+ const scoreContribution = SCORE[rule.evidenceType];
1659
+ const isStrong = isStrongEvidence(rule.evidenceType);
1660
+ const existing = accumulators.get(key);
1661
+ if (existing) {
1662
+ // Merge: accumulate score and evidence (avoid duplicate evidence entries)
1663
+ existing.rawScore += scoreContribution;
1664
+ if (isStrong) {
1665
+ existing.hasStrongEvidence = true;
1666
+ }
1667
+ const alreadyHas = existing.evidence.some(e => e.type === evidence.type && e.value === evidence.value);
1668
+ if (!alreadyHas) {
1669
+ existing.evidence.push(evidence);
1670
+ }
1671
+ }
1672
+ else {
1673
+ accumulators.set(key, {
1674
+ name: rule.tech,
1675
+ category: rule.category,
1676
+ rawScore: scoreContribution,
1677
+ evidence: [evidence],
1678
+ hasStrongEvidence: isStrong,
1679
+ });
1680
+ }
1681
+ }
1682
+ // Suppress ctxWithRootFiles unused-variable warning.
1683
+ void ctxWithRootFiles;
1684
+ // Build Technology objects, applying confidence threshold and minimum evidence rules.
1685
+ const technologies = [];
1686
+ for (const acc of accumulators.values()) {
1687
+ const confidence = toConfidence(acc.rawScore);
1688
+ // Primary guard: confidence must meet the detection threshold.
1689
+ if (confidence < CONFIDENCE_THRESHOLD)
1690
+ continue;
1691
+ // Secondary guard: technology must have at least one strong evidence
1692
+ // signal (dependency, devDependency, peerDependency, or manifest) unless
1693
+ // explicitly opted out via requiresStrongEvidence: false.
1694
+ // This prevents pathological accumulation of many weak signals creating
1695
+ // false detections.
1696
+ //
1697
+ // NOTE: rules with requiresStrongEvidence: false are those that ARE
1698
+ // legitimately triggered by their manifest-level config files alone.
1699
+ // The check is stored in the accumulator's hasStrongEvidence flag.
1700
+ if (!acc.hasStrongEvidence) {
1701
+ // Check if ANY rule for this tech opted out of the strong-evidence requirement.
1702
+ const anyRuleAllowsWeakOnly = TECH_RULES.some(r => techKey(r.tech) === techKey(acc.name) && r.requiresStrongEvidence === false);
1703
+ if (!anyRuleAllowsWeakOnly) {
1704
+ continue; // suppress: no strong evidence found
1705
+ }
1706
+ }
1707
+ technologies.push({
1708
+ name: acc.name,
1709
+ category: acc.category,
1710
+ confidence,
1711
+ // Sort evidence deterministically: by type rank then alphabetically.
1712
+ evidence: sortEvidence(acc.evidence),
1713
+ });
1714
+ }
1715
+ // Deterministic ordering:
1716
+ // 1. Category rank (ascending)
1717
+ // 2. Confidence (descending)
1718
+ // 3. Name (ascending, alphabetical tie-breaker)
1719
+ technologies.sort((a, b) => {
1720
+ const catDiff = categoryRank(a.category) - categoryRank(b.category);
1721
+ if (catDiff !== 0)
1722
+ return catDiff;
1723
+ const confDiff = b.confidence - a.confidence;
1724
+ if (confDiff !== 0)
1725
+ return confDiff;
1726
+ return a.name.localeCompare(b.name);
1727
+ });
1728
+ return { technologies };
1729
+ }
1730
+ //# sourceMappingURL=technology-stack-detector.js.map