-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathaudit.js
More file actions
1069 lines (951 loc) · 42.9 KB
/
Copy pathaudit.js
File metadata and controls
1069 lines (951 loc) · 42.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* initializes the audit module for browsers and node tests
* @param {Object} root global object receiving the browser module
* @param {Function} factory function that creates the audit api
* @returns {void} no return value
*/
(function initializeAuditModule(root, factory) {
const auditModule = factory();
if (typeof module === "object" && module.exports) {
module.exports = auditModule;
}
root.GitHubAudit = auditModule;
})(
typeof globalThis !== "undefined" ? globalThis : window,
/**
* creates the deterministic github audit api
* @returns {Object} public scoring and transformation functions
*/
function createAuditModule() {
const DAY_IN_MILLISECONDS = 86400000;
const GENERIC_NAME_PATTERN = /^(test|testing|project|repo|repository|demo|sample|hello[-_]?world)([-_]?\d*)?$/i;
const CLUTTER_NAME_PATTERN = /(tutorial|practice|course|homework|assignment|lab[-_]?\d*|test[-_]?\d*)/i;
const VAGUE_DESCRIPTION_PATTERN = /^(a |an )?(python|java|javascript|typescript|react|web|school|class)?\s*(app|application|project|website|program|repo|repository|tool)$/i;
/**
* limits a numeric score to the inclusive range from zero to one hundred
* @param {number} score score to limit
* @returns {number} rounded score between zero and one hundred
*/
function clampScore(score) {
return Math.max(0, Math.min(100, Math.round(score)));
}
/**
* calculates the average of a list of numeric values
* @param {Array<number>} values values to average
* @returns {number} rounded average or zero for an empty list
*/
function average(values) {
if (values.length === 0) return 0;
return Math.round(values.reduce(sumNumbers, 0) / values.length);
}
/**
* adds two numbers for array reduction
* @param {number} total running total
* @param {number} value value to add
* @returns {number} updated total
*/
function sumNumbers(total, value) {
return total + value;
}
/**
* creates a structured audit finding
* @param {string} category scoring category associated with the finding
* @param {string} severity priority level for the finding
* @param {string} reason explanation of the detected condition
* @param {string} action suggested action for addressing the condition
* @param {boolean} factual whether the finding is a factual check
* @param {string} groupReason reason to use when several repositories are grouped under one recommendation
* @returns {Object} structured audit finding
*/
function createFinding(category, severity, reason, action, factual, groupReason) {
const finding = { category, severity, reason, action, factual };
// Reasons that quote a repository's own measurements cannot describe a group.
// Such findings supply a plural-safe reason that stays true of every member.
if (groupReason) finding.groupReason = groupReason;
return finding;
}
/**
* scores the clarity and completeness of a repository description
* @param {string|null} description github repository description
* @param {boolean} archived whether the repository has been retired
* @returns {{score: number, findings: Array<Object>}} description score and findings
*/
function scoreDescription(description, archived = false) {
const value = (description || "").trim();
const findings = [];
let score = 100;
if (!value) {
// An archived repository is still listed on the profile, so the missing
// description still costs presentation. What changes is priority: this is
// work its owner deliberately retired, and it should not outrank the same
// gap on active projects. The separate action text also keeps the two from
// merging into one recommendation that would report a single severity.
findings.push(archived
? createFinding("Descriptions", "low", "Description is missing.", "Add a one-line description so visitors can tell what this archived project was.", true)
: createFinding("Descriptions", "high", "Description is missing.", "Add one sentence stating what the project does, its audience, and a distinguishing technology or outcome.", true));
return { score: 0, findings };
}
if (/^(test|testing|todo|tbd|wip|sample|demo)$/i.test(value)) {
score -= 55;
findings.push(createFinding("Descriptions", "high", "Description is placeholder text.", "Replace it with the project's purpose and key capability.", true));
} else if (VAGUE_DESCRIPTION_PATTERN.test(value)) {
score -= 35;
findings.push(createFinding("Descriptions", "high", "Description is too generic to distinguish the project.", "Add the problem solved, key behavior, and relevant stack instead of only naming the project type.", false));
}
if (value.length < 30) {
score -= 25;
findings.push(createFinding("Descriptions", "medium", `Description is only ${value.length} characters.`, "Add concrete context so the purpose is clear without opening the repository.", true, "Descriptions are shorter than 30 characters."));
}
if (value.length > 160) {
score -= 15;
findings.push(createFinding("Descriptions", "low", `Description is ${value.length} characters and difficult to scan.`, "Condense it to one focused sentence of 160 characters or fewer.", true));
}
if (/^\s*\((wip|broken|deprecated|archived)\)/i.test(value)) {
score -= 15;
findings.push(createFinding("Descriptions", "medium", "Description begins with a temporary status label.", "Use GitHub archive settings or topics for status and reserve the description for project purpose.", true));
}
if (/^[a-z]/.test(value)) {
score -= 5;
findings.push(createFinding("Descriptions", "low", "Description begins with a lowercase letter.", "Start the description with a capital letter for a more polished presentation.", true));
}
return { score: clampScore(score), findings };
}
/**
* scores repository name clarity and consistency
* @param {string} name github repository name
* @returns {{score: number, findings: Array<Object>}} name score and findings
*/
function scoreName(name) {
const findings = [];
let score = 100;
if (name.length > 50) {
score -= 20;
findings.push(createFinding("Repository presentation", "medium", "Repository name exceeds 50 characters.", "Shorten the name so it is easier to scan, remember, and type.", true));
}
if (/_/.test(name)) {
score -= 10;
findings.push(createFinding("Repository presentation", "low", "Repository name uses underscores.", "Consider lowercase kebab-case for consistent readability.", true));
}
if (/[A-Z]/.test(name)) {
score -= 5;
findings.push(createFinding("Repository presentation", "low", "Repository name contains uppercase letters.", "Consider lowercase kebab-case for consistency across the portfolio.", true));
}
if (GENERIC_NAME_PATTERN.test(name)) {
score -= 40;
findings.push(createFinding("Repository presentation", "high", "Repository name is too generic to communicate its purpose.", "Choose a short, distinctive name related to what the project does.", false));
}
if (CLUTTER_NAME_PATTERN.test(name)) {
score -= 15;
findings.push(createFinding("Portfolio focus", "low", "Name suggests a tutorial, class, or test repository.", "If this is no longer representative work, consider archiving it or keeping it unpinned.", false));
}
return { score: clampScore(score), findings };
}
/**
* scores repository readme quality using available metadata
* @param {{present: boolean|null, size: number|null}} readme readme metadata
* @param {boolean} archived whether the repository has been retired
* @returns {{score: number, findings: Array<Object>}} readme score and findings
*/
function scoreReadme(readme, archived = false) {
const findings = [];
if (!readme || readme.present === null) {
findings.push(createFinding("README quality", "info", "README status could not be verified.", "Configure the serverless GitHub integration to include README checks.", true));
return { score: 60, findings };
}
if (!readme.present) {
// Lower priority on retired work, for the reasons given in scoreDescription.
findings.push(archived
? createFinding("README quality", "low", "Repository has no root README.", "Add a short README so visitors can tell what this archived project did.", true)
: createFinding("README quality", "high", "Repository has no root README.", "Add a README explaining the problem, setup, usage, and important implementation decisions.", true));
return { score: 10, findings };
}
if (readme.size !== null && readme.size < 500) {
findings.push(createFinding("README quality", "medium", `README is only ${readme.size} bytes.`, "Expand it with purpose, setup, usage, and a screenshot or example where useful.", true, "READMEs are shorter than 500 bytes."));
return { score: 55, findings };
}
// Older deployments only expose byte size, so preserve their established score.
if (!readme.sections) return { score: 100, findings };
let score = 35;
const sections = readme.sections;
const coreSections = [sections.overview, sections.installation, sections.usage];
score += coreSections.filter(Boolean).length * 15;
score += sections.examples ? 10 : 0;
score += sections.contributing ? 5 : 0;
score += readme.hasCodeBlock ? 5 : 0;
score += readme.hasImage ? 5 : 0;
score += readme.headingCount >= 3 ? 5 : 0;
const missingCore = [
["overview", sections.overview],
["installation or setup", sections.installation],
["usage", sections.usage],
].filter((entry) => !entry[1]).map((entry) => entry[0]);
if (missingCore.length > 0) {
findings.push(createFinding(
"README quality",
missingCore.length >= 2 ? "medium" : "low",
`README is missing clear ${missingCore.join(", ")} guidance.`,
"Add clearly labeled sections so visitors can understand, install, and use the project quickly.",
true
));
}
if (!sections.examples && !readme.hasImage) {
findings.push(createFinding("README quality", "low", "README has no detected example, demo, screenshot, or image.", "Show the project in action with a concise example, screenshot, or demo section.", true));
}
return { score: clampScore(score), findings };
}
/**
* scores repository discoverability metadata
* @param {Object} repository normalized repository data
* @returns {{score: number, findings: Array<Object>}} discoverability score and findings
*/
function scoreDiscoverability(repository) {
const findings = [];
let score = 100;
if (repository.topics.length === 0) {
score -= 40;
findings.push(createFinding("Discoverability", "medium", "Repository has no topics.", "Add three to five specific topics covering the project domain, stack, and use case.", true));
}
if (!repository.license) {
score -= 25;
findings.push(createFinding("Discoverability", "medium", "Repository has no detected license.", "Add an appropriate license if you intend others to use or contribute to the project.", true));
}
if (!repository.homepage && !repository.archived && /^(HTML|CSS|JavaScript|TypeScript|Vue|Svelte)$/i.test(repository.language || "")) {
score -= 15;
findings.push(createFinding("Discoverability", "low", "Web project has no homepage or demo URL.", "Add a live demo URL when the project is deployable.", false));
}
return { score: clampScore(score), findings };
}
/**
* scores repository maintenance signals
* @param {Object} repository normalized repository data
* @param {Date} now current date used for deterministic age calculation
* @returns {{score: number, findings: Array<Object>}} maintenance score and findings
*/
function scoreMaintenance(repository, now) {
const findings = [];
if (repository.archived) {
return { score: 85, findings };
}
const updatedAt = new Date(repository.pushedAt || repository.updatedAt);
const ageInDays = Math.floor((now - updatedAt) / DAY_IN_MILLISECONDS);
if (!Number.isFinite(ageInDays)) {
findings.push(createFinding("Project maintenance", "info", "Maintenance date could not be determined.", "Check that the repository exposes a valid update timestamp.", true));
return { score: 60, findings };
}
if (ageInDays > 1095) {
findings.push(createFinding("Project maintenance", "medium", `Repository has not been pushed to in ${Math.floor(ageInDays / 365)} years.`, "Update it, clearly mark it complete, or archive it if it is no longer maintained.", true, "Repositories have not been pushed to in more than three years."));
return { score: 35, findings };
}
if (ageInDays > 730) {
findings.push(createFinding("Project maintenance", "low", "Repository has not been pushed to in more than two years.", "Review whether it still represents the work you want visitors to see.", true));
return { score: 65, findings };
}
if (ageInDays > 365) {
return { score: 85, findings };
}
return { score: 100, findings };
}
/**
* normalizes github repository and supplemental metadata for scoring and rendering
* @param {Object} repository github rest repository response
* @param {Object|null} supplemental supplemental readme and pin metadata
* @returns {Object} normalized repository data
*/
function transformRepository(repository, supplemental) {
const readme = supplemental?.readmes?.[repository.name] || { present: null, size: null };
const pinnedRepositories = supplemental?.pinnedRepositories || [];
const pinnedPosition = pinnedRepositories.indexOf(repository.name);
return {
raw: repository,
name: repository.name,
fullName: repository.full_name,
description: repository.description,
url: repository.html_url,
homepage: repository.homepage || null,
language: repository.language || null,
topics: Array.isArray(repository.topics) ? repository.topics : [],
license: repository.license?.spdx_id || null,
stars: repository.stargazers_count || 0,
forks: repository.forks_count || 0,
openIssues: repository.open_issues_count || 0,
archived: Boolean(repository.archived),
fork: repository.fork === true ? true : repository.fork === false ? false : null,
private: Boolean(repository.private),
visibility: repository.visibility || (repository.private ? "private" : "public"),
createdAt: repository.created_at,
updatedAt: repository.updated_at,
pushedAt: repository.pushed_at,
pinned: supplemental === null ? null : pinnedPosition >= 0,
pinnedPosition: pinnedPosition >= 0 ? pinnedPosition : null,
readme,
selected: true,
};
}
function formatReadmeStatus(readme) {
if (!readme || readme.present === null) return "unverified";
if (!readme.present) return "missing";
if (readme.size !== null && readme.size < 500) return "short";
if (readme.sections) {
const coreCount = [readme.sections.overview, readme.sections.installation, readme.sections.usage].filter(Boolean).length;
if (coreCount < 2) return "needs_structure";
if (coreCount === 3 && (readme.sections.examples || readme.hasImage)) return "comprehensive";
}
return "present";
}
function createReport(username, repositories, contributedRepositories = []) {
return {
username,
public_repositories: repositories.length,
pinned_repositories: repositories
.filter((repository) => repository.pinned === true)
.sort((repositoryA, repositoryB) =>
(repositoryA.pinnedPosition ?? Number.MAX_SAFE_INTEGER)
- (repositoryB.pinnedPosition ?? Number.MAX_SAFE_INTEGER)
)
.map((repository) => repository.name),
contributed_repositories: contributedRepositories,
repositories: repositories.map((repository) => ({
name: repository.name,
description: repository.description || null,
url: repository.url,
pinned: repository.pinned === true,
created_at: repository.createdAt,
updated_at: repository.updatedAt,
pushed_at: repository.pushedAt,
primary_language: repository.language || null,
license: repository.license || null,
topics: repository.topics,
stars: repository.stars,
forks: repository.forks,
open_issues: repository.openIssues,
readme_status: formatReadmeStatus(repository.readme),
archived: repository.archived,
forked: repository.fork,
})),
};
}
/**
* Portfolio candidacy labels.
*
* Candidacy answers a different question from the repository score. The score
* measures how well a repository presents itself. Candidacy measures whether it
* is a good thing to put in front of a visitor first, which depends on evidence
* the score deliberately averages away: whether the work is original, whether it
* has been retired, and whether the metadata behind the score was verifiable.
*/
const CANDIDATE_TITLES = {
strong: "Strong candidate",
polish: "Worth polishing",
deemphasize: "De-emphasize",
};
/** Shown alongside a label when some evidence could not be verified. */
const INCOMPLETE_EVIDENCE_NOTE = "Some metadata unavailable";
/**
* counts the findings of a repository audit at one severity
* @param {Array<Object>} findings repository findings
* @param {string} severity severity to count
* @returns {number} number of matching findings
*/
function countFindingsBySeverity(findings, severity) {
return findings.filter((finding) => finding.severity === severity).length;
}
/**
* determines whether an audit could not establish a repository's update history
* @param {Array<Object>} findings repository findings
* @returns {boolean} true when the maintenance date was unusable
*/
function hasUnknownMaintenance(findings) {
return findings.some((finding) => finding.category === "Project maintenance" && finding.severity === "info");
}
/**
* collects the candidacy evidence a repository audit already supports
*
* Every field is derived from data the audit already holds. Nothing here reads
* source code, commit ownership, upstream divergence, or contribution share, and
* pin state is deliberately excluded: pinning is the outcome of a candidacy
* decision rather than evidence for one.
*
* @param {Object} audit repository audit
* @returns {Object} candidacy evidence
*/
function collectCandidateEvidence(audit) {
const repository = audit.repository;
const readmeState = formatReadmeStatus(repository.readme);
const originality = repository.fork === false
? "original"
: repository.fork === true ? "fork" : "unknown";
const maintenanceUnknown = hasUnknownMaintenance(audit.findings);
const maintenance = audit.categoryScores.maintenance;
const unknowns = [];
if (originality === "unknown") unknowns.push("fork status");
if (readmeState === "unverified") unknowns.push("README status");
if (maintenanceUnknown) unknowns.push("update history");
return {
originality,
archived: repository.archived,
private: repository.private,
readmeState,
description: audit.categoryScores.descriptions,
maintenance,
maintenanceUnknown,
// Three years of unexplained silence, as distinct from an explicit archive.
abandoned: !repository.archived && !maintenanceUnknown && maintenance <= 35,
stale: !repository.archived && !maintenanceUnknown && maintenance === 65,
topics: repository.topics.length,
license: repository.license,
homepage: Boolean(repository.homepage),
highFindings: countFindingsBySeverity(audit.findings, "high"),
mediumFindings: countFindingsBySeverity(audit.findings, "medium"),
score: audit.score,
unknowns,
};
}
/**
* lists the independent weaknesses that count against prominent placement
*
* Unavailable evidence never appears here. An unverified README, an unreported
* fork status, and an unusable update timestamp are unknown, not weak, so they
* can never push a repository toward De-emphasize.
*
* @param {Object} evidence candidacy evidence
* @returns {Array<string>} weakness identifiers
*/
function listCandidateWeaknesses(evidence) {
const weaknesses = [];
if (evidence.readmeState === "missing") weaknesses.push("readme");
if (evidence.description < 70) weaknesses.push("description");
if (evidence.topics === 0 && !evidence.license) weaknesses.push("discoverability");
if (evidence.abandoned) weaknesses.push("abandoned");
if (evidence.archived) weaknesses.push("archived");
if (evidence.originality === "fork") weaknesses.push("fork");
return weaknesses;
}
/**
* determines whether the evidence supports an affirmative Strong candidate claim
*
* Strong candidate is a positive assertion about a specific repository, so every
* gate must be satisfied by evidence that was actually verified. Absent evidence
* cannot satisfy a gate, which is why an unreported fork status fails the first
* one. The score appears only as a backstop: gates one through seven already
* imply a score in the low eighties, so the threshold never decides a case alone.
*
* @param {Object} evidence candidacy evidence
* @returns {boolean} true when every Strong candidate gate is satisfied
*/
function isStrongCandidate(evidence) {
return evidence.originality === "original"
&& !evidence.archived
&& (evidence.readmeState === "present" || evidence.readmeState === "comprehensive")
&& evidence.description >= 70
&& evidence.topics >= 1
&& evidence.maintenance >= 85
&& evidence.highFindings === 0
&& evidence.mediumFindings <= 1
&& evidence.score >= 75;
}
/**
* determines whether the evidence argues against prominent placement
*
* No score term appears here. A repository is de-emphasized because of what is
* known about it, not because of where its presentation score lands.
*
* @param {Object} evidence candidacy evidence
* @param {Array<string>} weaknesses weakness identifiers
* @returns {boolean} true when the repository should be de-emphasized
*/
function isDeemphasizedCandidate(evidence, weaknesses) {
if (evidence.highFindings >= 2) return true;
// Neither a README nor a usable description leaves a visitor nothing to read.
if (weaknesses.includes("readme") && weaknesses.includes("description")) return true;
// Archiving is an explicit statement that work is finished; silence is not.
if (weaknesses.includes("abandoned")) return true;
return weaknesses.length >= 2;
}
/**
* joins clauses into readable prose
* @param {Array<string>} clauses clauses to join
* @returns {string} joined clauses
*/
function joinCandidateClauses(clauses) {
if (clauses.length <= 1) return clauses.join("");
if (clauses.length === 2) return `${clauses[0]} and ${clauses[1]}`;
return `${clauses.slice(0, -1).join(", ")}, and ${clauses[clauses.length - 1]}`;
}
/**
* capitalizes the first letter of a clause that begins a sentence
* @param {string} text sentence text
* @returns {string} capitalized text
*/
function capitalizeClause(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
/**
* describes the presentation strengths the evidence actually supports
* @param {Object} evidence candidacy evidence
* @returns {Array<string>} strength clauses
*/
function describeCandidateStrengths(evidence) {
const strengths = [];
if (evidence.readmeState === "comprehensive") strengths.push("a thorough README");
else if (evidence.readmeState === "present") strengths.push("a verified README");
if (evidence.description === 100) strengths.push("a clear description");
else if (evidence.description >= 70) strengths.push("a usable description");
if (evidence.topics > 0 && evidence.license) strengths.push("topics and a license for discoverability");
else if (evidence.topics > 0) strengths.push("topics for discoverability");
else if (evidence.license) strengths.push("a license");
if (evidence.homepage) strengths.push("a linked demo");
if (!evidence.archived && !evidence.maintenanceUnknown) {
if (evidence.maintenance >= 100) strengths.push("activity within the last year");
else if (evidence.maintenance >= 85) strengths.push("reasonably current activity");
}
return strengths;
}
/**
* describes the fixable presentation gaps the evidence actually supports
*
* An unverified README produces no gap clause, so an explanation never states
* that a README is absent when the tool could not check for one.
*
* @param {Object} evidence candidacy evidence
* @returns {Array<string>} gap clauses
*/
function describeCandidateGaps(evidence) {
const gaps = [];
if (evidence.readmeState === "missing") gaps.push("adding a README");
else if (evidence.readmeState === "short") gaps.push("expanding the short README");
else if (evidence.readmeState === "needs_structure") gaps.push("giving the README clear overview, setup, and usage sections");
if (evidence.description === 0) gaps.push("adding a description");
else if (evidence.description < 70) gaps.push("making the description more specific");
if (evidence.topics === 0) gaps.push("adding topics");
if (!evidence.license) gaps.push("adding a license");
if (evidence.stale) gaps.push("a more recent update");
return gaps;
}
/**
* describes why the evidence argues against prominent placement
* @param {Object} evidence candidacy evidence
* @returns {Array<string>} reason clauses
*/
function describeCandidateWeaknesses(evidence) {
const reasons = [];
if (evidence.archived) reasons.push("it is archived");
if (evidence.abandoned) reasons.push("it has not been pushed to in more than three years");
if (evidence.readmeState === "missing") reasons.push("it has no README");
if (evidence.description === 0) reasons.push("it has no description");
else if (evidence.description < 70) reasons.push("its description does not distinguish the project");
if (evidence.topics === 0 && !evidence.license) reasons.push("it has neither topics nor a license");
if (reasons.length === 0 && evidence.highFindings >= 2) {
reasons.push("several high-priority presentation findings remain open");
}
return reasons;
}
/**
* states what is and is not known about who authored the repository
*
* GitHub's own fork flag is the only authorship signal available. The tool does
* not inspect commits, so it never claims that a fork contains no original work,
* and it never treats an unreported fork status as confirmed original work.
*
* @param {Object} evidence candidacy evidence
* @returns {string|null} authorship sentence, or null for confirmed original work
*/
function describeCandidateOriginality(evidence) {
if (evidence.originality === "fork") {
return "GitHub identifies this repository as a fork, and GitProfileLens cannot determine how much of the implementation belongs to the profile owner.";
}
if (evidence.originality === "unknown") {
return "GitHub did not report fork status, so GitProfileLens cannot record this as confirmed original work.";
}
return null;
}
/**
* opens an explanation with what is known about authorship and presentation
*
* When authorship is not settled, that sentence leads, because it is the fact
* that decides the classification. Confirmed original work opens with its own
* strengths instead of an assertion about what it is not.
*
* @param {Object} evidence candidacy evidence
* @param {Array<string>} strengths strength clauses
* @returns {Array<string>} opening sentences
*/
function openCandidateExplanation(evidence, strengths) {
const originality = describeCandidateOriginality(evidence);
if (originality) {
return [
originality,
strengths.length === 0 ? "It has limited supporting metadata." : `It presents ${joinCandidateClauses(strengths)}.`,
];
}
return [strengths.length === 0
? "Original repository with limited supporting metadata."
: `Original repository with ${joinCandidateClauses(strengths)}.`];
}
/**
* names the evidence the audit could not verify
* @param {Object} evidence candidacy evidence
* @returns {Array<string>} qualifying sentences
*/
function qualifyCandidateEvidence(evidence) {
if (evidence.unknowns.length === 0) return [];
return [`GitProfileLens could not verify ${joinCandidateClauses(evidence.unknowns)} for this repository.`];
}
/**
* writes the explanation for a classification from the evidence behind it
* @param {string} label candidate label
* @param {Object} evidence candidacy evidence
* @returns {string} explanation
*/
function explainCandidate(label, evidence) {
const strengths = describeCandidateStrengths(evidence);
const sentences = openCandidateExplanation(evidence, strengths);
if (label === "deemphasize") {
const reasons = describeCandidateWeaknesses(evidence);
if (reasons.length > 0) {
sentences.push(`It is a weak choice for prominent portfolio placement because ${joinCandidateClauses(reasons)}.`);
}
return sentences.concat(qualifyCandidateEvidence(evidence)).join(" ");
}
if (label === "polish") {
if (evidence.archived) {
sentences.push("It is archived, which makes it a weaker choice for prominent portfolio placement.");
}
const gaps = describeCandidateGaps(evidence);
if (gaps.length > 0) {
sentences.push(`${capitalizeClause(joinCandidateClauses(gaps))} would improve portfolio presentation.`);
}
return sentences.concat(qualifyCandidateEvidence(evidence)).join(" ");
}
if (evidence.private) {
sentences.push("Privacy does not count against the work; this is a strong candidate if you intend to publish or showcase the project.");
}
return sentences.join(" ");
}
/**
* classifies a repository as a portfolio candidate, separately from its score
*
* The score answers "how well does this repository present itself". This answers
* "is this a good repository to feature prominently", which is a different
* question: a polished fork, a well-kept archive, and a rough but original
* project can each score in a way their candidacy does not follow.
*
* @param {Object} audit repository audit carrying repository, score, category scores, and findings
* @returns {Object} label, title, explanation, evidence qualifier, and the evidence used
*/
function classifyPortfolioCandidate(audit) {
const evidence = collectCandidateEvidence(audit);
const weaknesses = listCandidateWeaknesses(evidence);
const label = isStrongCandidate(evidence)
? "strong"
: isDeemphasizedCandidate(evidence, weaknesses) ? "deemphasize" : "polish";
return {
label,
title: CANDIDATE_TITLES[label],
explanation: explainCandidate(label, evidence),
qualifier: evidence.unknowns.length > 0 ? INCOMPLETE_EVIDENCE_NOTE : null,
weaknesses,
evidence,
};
}
/**
* calculates a repository presentation and discoverability audit
* @param {Object} repository normalized repository data
* @param {Date} now current date used for maintenance scoring
* @returns {Object} complete repository audit
*/
function scoreRepository(repository, now = new Date()) {
const name = scoreName(repository.name);
const description = scoreDescription(repository.description, repository.archived);
const readme = scoreReadme(repository.readme, repository.archived);
const discoverability = scoreDiscoverability(repository);
const maintenance = scoreMaintenance(repository, now);
const findings = [
...name.findings,
...description.findings,
...readme.findings,
...discoverability.findings,
...maintenance.findings,
];
const score = clampScore(
name.score * 0.15 +
description.score * 0.25 +
readme.score * 0.25 +
discoverability.score * 0.2 +
maintenance.score * 0.15
);
const audit = {
repository,
score,
categoryScores: {
presentation: name.score,
descriptions: description.score,
readme: readme.score,
discoverability: discoverability.score,
maintenance: maintenance.score,
},
findings,
};
// Candidacy reads the finished audit and adds a field. It never feeds back
// into score, categoryScores, or findings, which stay exactly as computed.
audit.candidate = classifyPortfolioCandidate(audit);
return audit;
}
/**
* scores how clearly a portfolio emphasizes a coherent set of projects
* @param {Array<Object>} repositories normalized repositories
* @returns {number} portfolio focus score
*/
function scorePortfolioFocus(repositories) {
if (repositories.length === 0) return 0;
const active = repositories.filter(isActiveRepository);
const languageCounts = new Map();
for (const repository of active) {
if (!repository.language) continue;
languageCounts.set(repository.language, (languageCounts.get(repository.language) || 0) + 1);
}
const largestLanguageGroup = Math.max(0, ...languageCounts.values());
const concentration = active.length > 0 ? largestLanguageGroup / active.length : 0;
const archivedOrForked = repositories.length - active.length;
const curationBonus = Math.min(15, archivedOrForked * 2);
return clampScore(55 + concentration * 30 + curationBonus);
}
/**
* determines whether a repository is active portfolio work
* @param {Object} repository normalized repository data
* @returns {boolean} true when the repository is neither archived nor forked
*/
function isActiveRepository(repository) {
return !repository.archived && !repository.fork;
}
/**
* calculates portfolio category scores and an overall presentation score
* @param {Array<Object>} audits repository audits
* @returns {Object} overall and category portfolio scores
*/
function scoreProfile(audits) {
if (audits.length === 0) {
return {
overall: 0,
categories: { presentation: 0, descriptions: 0, readme: 0, discoverability: 0, maintenance: 0, focus: 0 },
};
}
const repositories = audits.map(getAuditedRepository);
const categories = {
presentation: average(audits.map(getPresentationScore)),
descriptions: average(audits.map(getDescriptionScore)),
readme: average(audits.map(getReadmeScore)),
discoverability: average(audits.map(getDiscoverabilityScore)),
maintenance: average(audits.map(getMaintenanceScore)),
focus: scorePortfolioFocus(repositories),
};
const overall = clampScore(
categories.presentation * 0.15 +
categories.descriptions * 0.2 +
categories.readme * 0.2 +
categories.discoverability * 0.2 +
categories.maintenance * 0.15 +
categories.focus * 0.1
);
return { overall, categories };
}
/**
* gets the repository from a repository audit
* @param {Object} audit repository audit
* @returns {Object} normalized repository
*/
function getAuditedRepository(audit) { return audit.repository; }
/**
* gets the presentation score from a repository audit
* @param {Object} audit repository audit
* @returns {number} presentation score
*/
function getPresentationScore(audit) { return audit.categoryScores.presentation; }
/**
* gets the description score from a repository audit
* @param {Object} audit repository audit
* @returns {number} description score
*/
function getDescriptionScore(audit) { return audit.categoryScores.descriptions; }
/**
* gets the readme score from a repository audit
* @param {Object} audit repository audit
* @returns {number} readme score
*/
function getReadmeScore(audit) { return audit.categoryScores.readme; }
/**
* gets the discoverability score from a repository audit
* @param {Object} audit repository audit
* @returns {number} discoverability score
*/
function getDiscoverabilityScore(audit) { return audit.categoryScores.discoverability; }
/**
* gets the maintenance score from a repository audit
* @param {Object} audit repository audit
* @returns {number} maintenance score
*/
function getMaintenanceScore(audit) { return audit.categoryScores.maintenance; }
/**
* generates ranked portfolio-level recommendations from repository audits
* @param {Array<Object>} audits repository audits
* @returns {Array<Object>} highest-impact recommendations
*/
function generateRecommendations(audits) {
const groups = new Map();
for (const audit of audits) {
for (const finding of audit.findings) {
if (finding.severity === "info") continue;
const key = `${finding.category}|${finding.action}`;
const group = groups.get(key) || { ...finding, repositories: [] };
group.repositories.push(audit.repository.name);
groups.set(key, group);
}
}
const recommendations = [...groups.values()].map(summarizeRecommendation);
addPinningRecommendations(recommendations, audits);
recommendations.sort(compareRecommendations);
return recommendations.slice(0, 5);
}
/**
* finalizes a grouped recommendation so its reason describes every repository it names
*
* A group keeps the first matching finding, whose reason may quote that one
* repository's measurements. The card renders the reason directly above the
* affected repository list, so once a group covers more than one repository the
* plural-safe reason is used instead.
*
* @param {Object} group grouped finding with its affected repositories
* @returns {Object} portfolio recommendation
*/
function summarizeRecommendation(group) {
const { groupReason, ...recommendation } = group;
if (!groupReason || recommendation.repositories.length < 2) return recommendation;
return { ...recommendation, reason: groupReason };
}
/**
* adds portfolio curation advice when pinned metadata is available
* @param {Array<Object>} recommendations recommendations receiving pin advice
* @param {Array<Object>} audits repository audits
* @returns {void} no return value
*/
function addPinningRecommendations(recommendations, audits) {
const pinnedAudits = audits.filter(isPinnedAudit);
const weakPinnedAudits = pinnedAudits.filter(isWeakAudit);
const strongUnpinnedAudits = audits
.filter(isStrongUnpinnedAudit)
.sort(compareAuditScoresDescending)
.slice(0, 3);
if (weakPinnedAudits.length > 0) {
recommendations.push({
category: "Portfolio focus",
severity: "high",
reason: "One or more pinned repositories have weak presentation scores.",
action: "Improve or unpin these repositories so pinned work represents the strongest public projects.",
factual: false,
repositories: weakPinnedAudits.map(getAuditRepositoryName),
});
}
if (pinnedAudits.length < 6 && strongUnpinnedAudits.length > 0) {
recommendations.push({
category: "Portfolio focus",
severity: "medium",
reason: "Strong repository candidates are not currently pinned.",
action: "Consider pinning these projects if they represent the work you want visitors to notice first.",
factual: false,
repositories: strongUnpinnedAudits.map(getAuditRepositoryName),
});
}
}
/**
* determines whether an audit belongs to a pinned repository
* @param {Object} audit repository audit
* @returns {boolean} true when pinned
*/
function isPinnedAudit(audit) { return audit.repository.pinned === true; }
/**
* determines whether a repository audit has a weak score
* @param {Object} audit repository audit
* @returns {boolean} true when the score is below sixty
*/
function isWeakAudit(audit) { return audit.score < 60; }
/**
* determines whether an audit is strong, unpinned, and has verified pin data
*
* The suggestion names "the work you want visitors to notice first", so three
* kinds of repository are excluded even when they score well:
*
* - archived, because retired work is not what a profile leads with;
* - private, because the public audience this score describes cannot see it;
* - forked, because a fork inherits its description, README, topics, and license
* from upstream, so the score qualifying it measures another author's
* presentation rather than this account's.
*
* @param {Object} audit repository audit
* @returns {boolean} true when the repository is a pin candidate
*/
function isStrongUnpinnedAudit(audit) {
return audit.repository.pinned === false
&& audit.score >= 85
&& !audit.repository.archived