github-portfolio-analyzer 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,788 @@
1
+ import { utcNowISOString } from '../utils/time.js';
2
+
3
+ const STATE_ORDER = ['active', 'stale', 'abandoned', 'archived', 'idea', 'reference-only'];
4
+ const EFFORT_ORDER = ['xs', 's', 'm', 'l', 'xl'];
5
+ const BAND_STRENGTH = {
6
+ park: 0,
7
+ later: 1,
8
+ next: 2,
9
+ now: 3
10
+ };
11
+ const PIN_WITHOUT_BAND_BOOST = 100;
12
+
13
+ export function computeCompletionLevel(item, repositorySignals = null) {
14
+ if (resolveItemType(item) === 'idea') {
15
+ return 0;
16
+ }
17
+
18
+ const signals = resolveRepositorySignals(item, repositorySignals);
19
+
20
+ if (!signals.hasReadme) {
21
+ return 0;
22
+ }
23
+
24
+ let level = 1;
25
+
26
+ if (signals.hasPackageJson || signals.nonJavascriptLargeRepo) {
27
+ level = 2;
28
+ } else {
29
+ return level;
30
+ }
31
+
32
+ if (signals.hasCi) {
33
+ level = 3;
34
+ } else {
35
+ return level;
36
+ }
37
+
38
+ if (signals.hasTests) {
39
+ level = 4;
40
+ } else {
41
+ return level;
42
+ }
43
+
44
+ if (signals.hasReadme && signals.hasCi && signals.hasTests && Number(item.score ?? 0) >= 70) {
45
+ level = 5;
46
+ }
47
+
48
+ return level;
49
+ }
50
+
51
+ export function completionLabelFor(level) {
52
+ if (level === 0) {
53
+ return 'Concept only';
54
+ }
55
+
56
+ if (level === 1) {
57
+ return 'Documented';
58
+ }
59
+
60
+ if (level === 2) {
61
+ return 'Structured baseline';
62
+ }
63
+
64
+ if (level === 3) {
65
+ return 'Automated workflow';
66
+ }
67
+
68
+ if (level === 4) {
69
+ return 'Tested workflow';
70
+ }
71
+
72
+ return 'Production-ready candidate';
73
+ }
74
+
75
+ export function computeEffortEstimate(item, completionLevel, repositorySignals = null) {
76
+ const baseline = normalizeEffort(item?.effort) ?? 'm';
77
+ const source = item?.taxonomyMeta?.sources?.effort;
78
+
79
+ let estimate = baseline;
80
+
81
+ if (source === 'default') {
82
+ try {
83
+ const rawSizeKb = repositorySignals?.sizeKb ?? item?.sizeKb;
84
+ const sizeKb = Number(rawSizeKb);
85
+ if (!Number.isFinite(sizeKb)) {
86
+ throw new Error('Missing sizeKb for effort inference');
87
+ }
88
+ estimate = inferEffortEstimate(sizeKb, completionLevel);
89
+ } catch {
90
+ return 'm';
91
+ }
92
+ }
93
+
94
+ return normalizeEffort(estimate) ?? 'm';
95
+ }
96
+
97
+ export function computePriorityBand(input) {
98
+ const score = Number(input.score ?? 0);
99
+ const state = String(input.state ?? '').toLowerCase();
100
+ const completionLevel = Number(input.completionLevel ?? 0);
101
+ const effortEstimate = normalizeEffort(input.effortEstimate) ?? 'm';
102
+
103
+ let priorityScore = score;
104
+ const reasons = [`Base score: ${score}`];
105
+
106
+ if (state === 'active') {
107
+ priorityScore += 10;
108
+ reasons.push('State boost: active (+10)');
109
+ } else if (state === 'stale') {
110
+ priorityScore += 5;
111
+ reasons.push('State boost: stale (+5)');
112
+ } else if (state === 'abandoned' || state === 'archived') {
113
+ priorityScore -= 20;
114
+ reasons.push('State penalty: abandoned/archived (-20)');
115
+ }
116
+
117
+ if (completionLevel >= 1 && completionLevel <= 3) {
118
+ priorityScore += 10;
119
+ reasons.push('Quick-win zone boost: completion level 1-3 (+10)');
120
+ }
121
+
122
+ if (effortEstimate === 'l' || effortEstimate === 'xl') {
123
+ priorityScore -= 10;
124
+ reasons.push('Large effort penalty: l/xl (-10)');
125
+ }
126
+
127
+ const priorityBand = priorityBandFromScore(priorityScore);
128
+ reasons.push(`Priority band: ${priorityBand} (score ${priorityScore})`);
129
+
130
+ return {
131
+ priorityBand,
132
+ priorityScore,
133
+ priorityWhy: reasons
134
+ };
135
+ }
136
+
137
+ export function buildReportModel(portfolioData, inventoryData = null, options = {}) {
138
+ const portfolioItems = Array.isArray(portfolioData?.items) ? portfolioData.items : [];
139
+ const inventoryItems = Array.isArray(inventoryData?.items) ? inventoryData.items : [];
140
+ const policyOverlay = normalizePolicyOverlay(options.policyOverlay);
141
+
142
+ const inventoryLookup = buildInventoryLookup(inventoryItems);
143
+
144
+ const reportItems = portfolioItems.map((item) => {
145
+ const slug = String(item.slug ?? '').trim();
146
+ const inventorySignals = inventoryLookup.get(slug) ?? null;
147
+
148
+ const completionLevel = computeCompletionLevel(item, inventorySignals);
149
+ const effortEstimate = computeEffortEstimate(item, completionLevel, inventorySignals);
150
+ const { priorityBand, priorityScore, priorityWhy } = computePriorityBand({
151
+ score: item.score,
152
+ state: item.state,
153
+ completionLevel,
154
+ effortEstimate
155
+ });
156
+
157
+ const {
158
+ priorityBand: finalBand,
159
+ finalPriorityScore,
160
+ priorityTag,
161
+ priorityOverrides,
162
+ policyReasons
163
+ } = applyPolicyOverlayToItem(
164
+ {
165
+ ...item,
166
+ slug,
167
+ type: resolveItemType(item),
168
+ title: resolveTitle(item),
169
+ tags: collectItemTags(item)
170
+ },
171
+ {
172
+ basePriorityScore: priorityScore,
173
+ basePriorityBand: priorityBand
174
+ },
175
+ policyOverlay
176
+ );
177
+
178
+ const priorityWhyWithPolicy = [...priorityWhy, ...policyReasons];
179
+
180
+ return {
181
+ slug,
182
+ type: resolveItemType(item),
183
+ title: resolveTitle(item),
184
+ score: Number(item.score ?? 0),
185
+ state: String(item.state ?? 'idea'),
186
+ effort: normalizeEffort(item.effort) ?? 'm',
187
+ value: String(item.value ?? 'medium'),
188
+ completionLevel,
189
+ completionLabel: completionLabelFor(completionLevel),
190
+ effortEstimate,
191
+ basePriorityScore: priorityScore,
192
+ finalPriorityScore,
193
+ priorityBand: finalBand,
194
+ ...(priorityTag ? { priorityTag } : {}),
195
+ priorityOverrides,
196
+ priorityScore: finalPriorityScore,
197
+ priorityWhy: priorityWhyWithPolicy,
198
+ nextAction: String(item.nextAction ?? '').trim(),
199
+ // presentation fields — passed directly from portfolio item
200
+ ...(item.language != null ? { language: item.language } : {}),
201
+ ...(Array.isArray(item.topics) && item.topics.length > 0 ? { topics: item.topics } : {}),
202
+ ...(item.htmlUrl != null ? { htmlUrl: item.htmlUrl } : {}),
203
+ ...(item.homepage != null ? { homepage: item.homepage } : {})
204
+ };
205
+ });
206
+
207
+ const sortedByScore = [...reportItems].sort((left, right) => {
208
+ if (right.score !== left.score) {
209
+ return right.score - left.score;
210
+ }
211
+
212
+ return left.slug.localeCompare(right.slug);
213
+ });
214
+
215
+ const sortedByPriority = [...reportItems].sort((left, right) => {
216
+ if (right.finalPriorityScore !== left.finalPriorityScore) {
217
+ return right.finalPriorityScore - left.finalPriorityScore;
218
+ }
219
+
220
+ if (right.score !== left.score) {
221
+ return right.score - left.score;
222
+ }
223
+
224
+ return left.slug.localeCompare(right.slug);
225
+ });
226
+
227
+ const byState = buildByStateSummary(reportItems);
228
+ const byBand = {
229
+ now: topItemsInBand(sortedByPriority, 'now', 5),
230
+ next: topItemsInBand(sortedByPriority, 'next', 5),
231
+ later: topItemsInBand(sortedByPriority, 'later', 5),
232
+ park: topItemsInBand(sortedByPriority, 'park', 5)
233
+ };
234
+
235
+ const matrix = buildCompletionByEffortMatrix(reportItems);
236
+
237
+ return {
238
+ meta: {
239
+ generatedAt:
240
+ typeof options.generatedAt === 'string' && options.generatedAt.trim().length > 0
241
+ ? options.generatedAt
242
+ : utcNowISOString(),
243
+ asOfDate: portfolioData?.meta?.asOfDate ?? null,
244
+ owner: inventoryData?.meta?.owner ?? null,
245
+ counts: {
246
+ total: reportItems.length,
247
+ repos: reportItems.filter((item) => item.type === 'repo').length,
248
+ ideas: reportItems.filter((item) => item.type === 'idea').length
249
+ }
250
+ },
251
+ summary: {
252
+ byState,
253
+ top10ByScore: sortedByScore.slice(0, 10).map(toSummaryItem),
254
+ now: byBand.now.map(toSummaryItem),
255
+ next: byBand.next.map(toSummaryItem),
256
+ later: byBand.later.map(toSummaryItem),
257
+ park: byBand.park.map(toSummaryItem)
258
+ },
259
+ matrix: {
260
+ completionByEffort: matrix
261
+ },
262
+ items: sortedByPriority.map(({ priorityScore: _priorityScore, ...item }) => item)
263
+ };
264
+ }
265
+
266
+ export function stateOrder() {
267
+ return [...STATE_ORDER];
268
+ }
269
+
270
+ export function effortOrder() {
271
+ return [...EFFORT_ORDER];
272
+ }
273
+
274
+ function buildInventoryLookup(inventoryItems) {
275
+ const lookup = new Map();
276
+
277
+ for (const item of inventoryItems) {
278
+ const slug = String(item.slug ?? '').trim();
279
+ if (!slug) {
280
+ continue;
281
+ }
282
+
283
+ lookup.set(slug, item);
284
+ }
285
+
286
+ return lookup;
287
+ }
288
+
289
+ function buildByStateSummary(reportItems) {
290
+ const summary = {
291
+ active: 0,
292
+ stale: 0,
293
+ abandoned: 0,
294
+ archived: 0,
295
+ idea: 0,
296
+ 'reference-only': 0
297
+ };
298
+
299
+ for (const item of reportItems) {
300
+ if (Object.hasOwn(summary, item.state)) {
301
+ summary[item.state] += 1;
302
+ }
303
+ }
304
+
305
+ return summary;
306
+ }
307
+
308
+ function buildCompletionByEffortMatrix(reportItems) {
309
+ const matrix = {
310
+ CL0: createEffortRow(),
311
+ CL1: createEffortRow(),
312
+ CL2: createEffortRow(),
313
+ CL3: createEffortRow(),
314
+ CL4: createEffortRow(),
315
+ CL5: createEffortRow()
316
+ };
317
+
318
+ for (const item of reportItems) {
319
+ const levelKey = `CL${item.completionLevel}`;
320
+ if (!Object.hasOwn(matrix, levelKey)) {
321
+ continue;
322
+ }
323
+
324
+ const effort = normalizeEffort(item.effortEstimate);
325
+ if (!effort) {
326
+ continue;
327
+ }
328
+
329
+ matrix[levelKey][effort] += 1;
330
+ }
331
+
332
+ return matrix;
333
+ }
334
+
335
+ function createEffortRow() {
336
+ return {
337
+ xs: 0,
338
+ s: 0,
339
+ m: 0,
340
+ l: 0,
341
+ xl: 0
342
+ };
343
+ }
344
+
345
+ function topItemsInBand(items, band, count) {
346
+ return items.filter((item) => item.priorityBand === band).slice(0, count);
347
+ }
348
+
349
+ function toSummaryItem(item) {
350
+ return {
351
+ slug: item.slug,
352
+ type: item.type,
353
+ score: item.score,
354
+ state: item.state,
355
+ effort: item.effort,
356
+ effortEstimate: normalizeEffort(item.effortEstimate) ?? 'm',
357
+ value: item.value,
358
+ completionLevel: item.completionLevel,
359
+ basePriorityScore: item.basePriorityScore,
360
+ finalPriorityScore: item.finalPriorityScore,
361
+ priorityBand: item.priorityBand,
362
+ ...(item.priorityTag ? { priorityTag: item.priorityTag } : {}),
363
+ priorityOverrides: item.priorityOverrides,
364
+ priorityWhy: item.priorityWhy,
365
+ nextAction: item.nextAction
366
+ };
367
+ }
368
+
369
+ function applyPolicyOverlayToItem(item, basePriority, policyOverlay) {
370
+ const matchedRules = policyOverlay.rules.filter((rule) => ruleMatches(rule.match, item));
371
+ const pinEntry = policyOverlay.pinBySlug.get(item.slug) ?? null;
372
+
373
+ let finalPriorityScore = basePriority.basePriorityScore;
374
+ let strongestForcedBand = null;
375
+ let priorityTag = null;
376
+ const priorityOverrides = [];
377
+ const policyReasons = [];
378
+
379
+ for (const rule of matchedRules) {
380
+ finalPriorityScore += rule.effects.boost;
381
+
382
+ if (!priorityTag && rule.effects.tag) {
383
+ priorityTag = rule.effects.tag;
384
+ }
385
+
386
+ if (rule.effects.forceBand && isStrongerBand(rule.effects.forceBand, strongestForcedBand)) {
387
+ strongestForcedBand = rule.effects.forceBand;
388
+ }
389
+
390
+ priorityOverrides.push({
391
+ ruleId: rule.id,
392
+ boost: rule.effects.boost,
393
+ ...(rule.effects.forceBand ? { forceBand: rule.effects.forceBand } : {}),
394
+ reason: rule.reason
395
+ });
396
+
397
+ const effectSummary = [];
398
+ if (rule.effects.boost !== 0) {
399
+ effectSummary.push(`boost ${formatSignedNumber(rule.effects.boost)}`);
400
+ }
401
+ if (rule.effects.forceBand) {
402
+ effectSummary.push(`forceBand ${rule.effects.forceBand}`);
403
+ }
404
+ if (rule.effects.tag) {
405
+ effectSummary.push(`tag ${rule.effects.tag}`);
406
+ }
407
+ if (effectSummary.length > 0) {
408
+ policyReasons.push(`Policy ${rule.id}: ${effectSummary.join(', ')}`);
409
+ }
410
+ if (rule.reason) {
411
+ policyReasons.push(`Policy ${rule.id} reason: ${rule.reason}`);
412
+ }
413
+ }
414
+
415
+ if (pinEntry) {
416
+ if (pinEntry.band) {
417
+ priorityOverrides.push({
418
+ ruleId: `pin:${pinEntry.slug}`,
419
+ boost: 0,
420
+ forceBand: pinEntry.band,
421
+ reason: 'Pinned band from policy'
422
+ });
423
+ if (pinEntry.tag) {
424
+ priorityTag = pinEntry.tag;
425
+ }
426
+ policyReasons.push(`Pinned to ${pinEntry.band} by policy`);
427
+ } else {
428
+ finalPriorityScore += PIN_WITHOUT_BAND_BOOST;
429
+ priorityOverrides.push({
430
+ ruleId: `pin:${pinEntry.slug}`,
431
+ boost: PIN_WITHOUT_BAND_BOOST,
432
+ reason: 'Pinned boost from policy'
433
+ });
434
+ if (pinEntry.tag) {
435
+ priorityTag = pinEntry.tag;
436
+ }
437
+ policyReasons.push(`Pinned boost applied (${formatSignedNumber(PIN_WITHOUT_BAND_BOOST)})`);
438
+ }
439
+ }
440
+
441
+ const priorityBand = resolveFinalBand({
442
+ pinBand: pinEntry?.band ?? null,
443
+ forcedBand: strongestForcedBand,
444
+ finalPriorityScore
445
+ });
446
+
447
+ if (priorityTag) {
448
+ policyReasons.push(`Priority tag: ${priorityTag}`);
449
+ }
450
+
451
+ return {
452
+ finalPriorityScore,
453
+ priorityBand,
454
+ priorityTag,
455
+ priorityOverrides,
456
+ policyReasons
457
+ };
458
+ }
459
+
460
+ function resolveFinalBand(input) {
461
+ if (input.pinBand) {
462
+ return input.pinBand;
463
+ }
464
+
465
+ if (input.forcedBand) {
466
+ return input.forcedBand;
467
+ }
468
+
469
+ return priorityBandFromScore(input.finalPriorityScore);
470
+ }
471
+
472
+ function normalizePolicyOverlay(policyOverlay) {
473
+ const source = policyOverlay ?? {};
474
+ const rules = Array.isArray(source.rules) ? source.rules : [];
475
+ const pin = Array.isArray(source.pin) ? source.pin : [];
476
+
477
+ const normalizedRules = rules
478
+ .map((rule, index) => normalizePolicyRule(rule, index))
479
+ .sort((left, right) => left.id.localeCompare(right.id));
480
+
481
+ const normalizedPins = pin
482
+ .map((entry, index) => normalizePolicyPin(entry, index))
483
+ .sort((left, right) => left.slug.localeCompare(right.slug));
484
+
485
+ return {
486
+ rules: normalizedRules,
487
+ pinBySlug: consolidatePins(normalizedPins)
488
+ };
489
+ }
490
+
491
+ function normalizePolicyRule(rule, index) {
492
+ const id = String(rule?.id ?? '').trim();
493
+ if (!id) {
494
+ throw new Error(`Invalid policy rule at index ${index}: missing id`);
495
+ }
496
+
497
+ const effects = rule?.effects ?? {};
498
+ const boostRaw = Number(effects.boost ?? 0);
499
+ if (!Number.isFinite(boostRaw)) {
500
+ throw new Error(`Invalid policy rule ${id}: effects.boost must be a number`);
501
+ }
502
+
503
+ const forceBand = normalizeBand(effects.forceBand);
504
+ const tag = normalizeOptionalString(effects.tag);
505
+ const reason = normalizeOptionalString(rule?.reason) ?? '';
506
+
507
+ return {
508
+ id,
509
+ match: normalizePolicyMatch(rule?.match),
510
+ effects: {
511
+ boost: boostRaw,
512
+ ...(forceBand ? { forceBand } : {}),
513
+ ...(tag ? { tag } : {})
514
+ },
515
+ reason
516
+ };
517
+ }
518
+
519
+ function normalizePolicyMatch(match) {
520
+ const source = match ?? {};
521
+
522
+ return {
523
+ slugContains: normalizeStringArray(source.slugContains),
524
+ fullNameContains: normalizeStringArray(source.fullNameContains),
525
+ titleContains: normalizeStringArray(source.titleContains),
526
+ tagsAny: normalizeStringArray(source.tagsAny),
527
+ type: normalizeStringArray(source.type),
528
+ state: normalizeStringArray(source.state),
529
+ category: normalizeStringArray(source.category),
530
+ strategy: normalizeStringArray(source.strategy)
531
+ };
532
+ }
533
+
534
+ function normalizePolicyPin(entry, index) {
535
+ const slug = String(entry?.slug ?? '').trim();
536
+ if (!slug) {
537
+ throw new Error(`Invalid policy pin at index ${index}: missing slug`);
538
+ }
539
+
540
+ const band = normalizeBand(entry?.band);
541
+ const tag = normalizeOptionalString(entry?.tag);
542
+
543
+ return {
544
+ slug,
545
+ ...(band ? { band } : {}),
546
+ ...(tag ? { tag } : {})
547
+ };
548
+ }
549
+
550
+ function consolidatePins(pins) {
551
+ const result = new Map();
552
+
553
+ for (const pin of pins) {
554
+ const existing = result.get(pin.slug);
555
+ if (!existing) {
556
+ result.set(pin.slug, pin);
557
+ continue;
558
+ }
559
+
560
+ const strongestBand =
561
+ isStrongerBand(pin.band ?? null, existing.band ?? null) ? pin.band ?? null : existing.band ?? null;
562
+ const tag = existing.tag ?? pin.tag;
563
+
564
+ result.set(pin.slug, {
565
+ slug: pin.slug,
566
+ ...(strongestBand ? { band: strongestBand } : {}),
567
+ ...(tag ? { tag } : {})
568
+ });
569
+ }
570
+
571
+ return result;
572
+ }
573
+
574
+ function ruleMatches(match, item) {
575
+ if (!containsAny(item.slug, match.slugContains)) {
576
+ return false;
577
+ }
578
+
579
+ if (!containsAny(item.fullName ?? '', match.fullNameContains)) {
580
+ return false;
581
+ }
582
+
583
+ if (!containsAny(item.title, match.titleContains)) {
584
+ return false;
585
+ }
586
+
587
+ if (!matchesAny(item.tags, match.tagsAny)) {
588
+ return false;
589
+ }
590
+
591
+ if (!matchesAny([item.type], match.type)) {
592
+ return false;
593
+ }
594
+
595
+ if (!matchesAny([item.state], match.state)) {
596
+ return false;
597
+ }
598
+
599
+ if (!matchesAny([item.category], match.category)) {
600
+ return false;
601
+ }
602
+
603
+ if (!matchesAny([item.strategy], match.strategy)) {
604
+ return false;
605
+ }
606
+
607
+ return true;
608
+ }
609
+
610
+ function containsAny(value, needles) {
611
+ if (needles.length === 0) {
612
+ return true;
613
+ }
614
+
615
+ const haystack = String(value ?? '').toLowerCase();
616
+ return needles.some((needle) => haystack.includes(needle));
617
+ }
618
+
619
+ function matchesAny(values, allowed) {
620
+ if (allowed.length === 0) {
621
+ return true;
622
+ }
623
+
624
+ const normalized = values
625
+ .map((value) => String(value ?? '').trim().toLowerCase())
626
+ .filter((value) => value.length > 0);
627
+
628
+ return normalized.some((value) => allowed.includes(value));
629
+ }
630
+
631
+ function collectItemTags(item) {
632
+ const allTags = [];
633
+
634
+ if (Array.isArray(item?.tags)) {
635
+ allTags.push(...item.tags);
636
+ }
637
+
638
+ if (Array.isArray(item?.topics)) {
639
+ allTags.push(...item.topics);
640
+ }
641
+
642
+ const unique = new Set();
643
+ for (const tag of allTags) {
644
+ const normalized = String(tag ?? '').trim().toLowerCase();
645
+ if (normalized.length > 0) {
646
+ unique.add(normalized);
647
+ }
648
+ }
649
+
650
+ return Array.from(unique).sort((left, right) => left.localeCompare(right));
651
+ }
652
+
653
+ function normalizeStringArray(value) {
654
+ if (!Array.isArray(value)) {
655
+ return [];
656
+ }
657
+
658
+ return value
659
+ .map((entry) => String(entry ?? '').trim().toLowerCase())
660
+ .filter((entry) => entry.length > 0);
661
+ }
662
+
663
+ function normalizeOptionalString(value) {
664
+ const text = String(value ?? '').trim();
665
+ return text.length > 0 ? text : null;
666
+ }
667
+
668
+ function normalizeBand(value) {
669
+ const normalized = String(value ?? '').trim().toLowerCase();
670
+ return Object.hasOwn(BAND_STRENGTH, normalized) ? normalized : null;
671
+ }
672
+
673
+ function isStrongerBand(leftBand, rightBand) {
674
+ const left = normalizeBand(leftBand);
675
+ const right = normalizeBand(rightBand);
676
+
677
+ if (!left) {
678
+ return false;
679
+ }
680
+
681
+ if (!right) {
682
+ return true;
683
+ }
684
+
685
+ return BAND_STRENGTH[left] > BAND_STRENGTH[right];
686
+ }
687
+
688
+ function formatSignedNumber(value) {
689
+ const number = Number(value);
690
+ if (!Number.isFinite(number)) {
691
+ return '+0';
692
+ }
693
+
694
+ if (number >= 0) {
695
+ return `+${number}`;
696
+ }
697
+
698
+ return String(number);
699
+ }
700
+
701
+ function resolveRepositorySignals(item, repositorySignals) {
702
+ const source = repositorySignals ?? item ?? {};
703
+ const structural = source.structuralHealth ?? {};
704
+ const language = String(source.language ?? item?.language ?? '').trim().toLowerCase();
705
+ const sizeKb = Number(source.sizeKb ?? item?.sizeKb ?? 0);
706
+
707
+ return {
708
+ hasReadme: Boolean(structural.hasReadme),
709
+ hasPackageJson: Boolean(structural.hasPackageJson),
710
+ hasCi: Boolean(structural.hasCi),
711
+ hasTests: Boolean(structural.hasTests),
712
+ nonJavascriptLargeRepo: isNonJavascript(language) && sizeKb >= 500,
713
+ sizeKb: Number.isFinite(sizeKb) ? sizeKb : 0
714
+ };
715
+ }
716
+
717
+ function isNonJavascript(language) {
718
+ if (!language) {
719
+ return false;
720
+ }
721
+
722
+ return !['javascript', 'typescript'].includes(language);
723
+ }
724
+
725
+ function resolveItemType(item) {
726
+ if (item?.type === 'repo' || item?.type === 'idea') {
727
+ return item.type;
728
+ }
729
+
730
+ if (typeof item?.status === 'string') {
731
+ return 'idea';
732
+ }
733
+
734
+ return 'repo';
735
+ }
736
+
737
+ function resolveTitle(item) {
738
+ return String(item.title ?? item.fullName ?? item.name ?? item.slug ?? 'untitled').trim();
739
+ }
740
+
741
+ function normalizeEffort(value) {
742
+ const normalized = String(value ?? '').trim().toLowerCase();
743
+ if (['xs', 's', 'm', 'l', 'xl'].includes(normalized)) {
744
+ return normalized;
745
+ }
746
+
747
+ return null;
748
+ }
749
+
750
+ function inferEffortEstimate(sizeKb, completionLevel) {
751
+ if (!Number.isFinite(sizeKb) || sizeKb < 0) {
752
+ throw new Error('Invalid sizeKb for effort inference');
753
+ }
754
+
755
+ if (sizeKb < 100 && completionLevel <= 2) {
756
+ return 'xs';
757
+ }
758
+
759
+ if (sizeKb < 500 && completionLevel <= 3) {
760
+ return 's';
761
+ }
762
+
763
+ if (sizeKb < 5000) {
764
+ return 'm';
765
+ }
766
+
767
+ if (sizeKb < 20000) {
768
+ return 'l';
769
+ }
770
+
771
+ return 'xl';
772
+ }
773
+
774
+ function priorityBandFromScore(priorityScore) {
775
+ if (priorityScore >= 80) {
776
+ return 'now';
777
+ }
778
+
779
+ if (priorityScore >= 65) {
780
+ return 'next';
781
+ }
782
+
783
+ if (priorityScore >= 45) {
784
+ return 'later';
785
+ }
786
+
787
+ return 'park';
788
+ }