-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmodels.py
More file actions
1572 lines (1321 loc) · 63.9 KB
/
Copy pathmodels.py
File metadata and controls
1572 lines (1321 loc) · 63.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
"""Pydantic models for the scanner JSON report.
The report has two distinct layers:
- ``data`` — raw facts collected from public sources (GitHub API). No
judgement, no scoring; values are reported as observed.
- ``metrics`` — standardized scores (integers 1..100) computed from ``data``
by a versioned, transparent methodology (see ``metrics.py``
and docs/metrics.md).
Schema and metrics methodology are versioned independently:
``Report.schema_version`` covers the JSON structure; ``Metrics.metrics_version``
covers the scoring formulas. Any breaking change must bump the respective
version — downstream scoring/certification depends on a stable schema, and
trust depends on a transparent methodology.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
SCHEMA_VERSION = "0.34.0"
# ---------------------------------------------------------------------------
# Data layer: raw observed facts
# ---------------------------------------------------------------------------
class RepoRef(BaseModel):
"""Identity of the scanned repository."""
url: str
host: str = "github.com"
owner: str
name: str
@property
def full_name(self) -> str:
return f"{self.owner}/{self.name}"
class OrgInfo(BaseModel):
"""Public profile of a GitHub organization."""
login: str
name: Optional[str] = None
description: Optional[str] = None
blog: Optional[str] = None
location: Optional[str] = None
email: Optional[str] = None
twitter_username: Optional[str] = None
is_verified: bool = Field(
default=False, description="GitHub verified-domain badge on the organization"
)
public_repos: int = 0
followers: int = 0
created_at: Optional[datetime] = None
avatar_url: Optional[str] = None
class OwnerProfile(BaseModel):
"""Public profile of the account owning a repository (user or organization)."""
login: str
type: str = Field(description='"User" or "Organization"')
name: Optional[str] = None
company: Optional[str] = None
blog: Optional[str] = None
location: Optional[str] = None
followers: int = 0
public_repos: int = 0
created_at: Optional[datetime] = None
account_age_days: Optional[int] = None
is_verified: Optional[bool] = Field(
default=None,
description="GitHub verified-domain badge; only organizations can be verified (None for users)",
)
avatar_url: Optional[str] = None
class RepoInfo(BaseModel):
"""Basic repository metadata."""
owner_type: Optional[str] = Field(
default=None, description='"User" or "Organization"'
)
description: Optional[str] = None
homepage: Optional[str] = None
has_wiki: Optional[bool] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
pushed_at: Optional[datetime] = None
default_branch: Optional[str] = None
is_fork: bool = False
is_archived: bool = False
is_disabled: bool = False
size_kb: Optional[int] = None
primary_language: Optional[str] = None
languages: dict[str, int] = Field(
default_factory=dict,
description="Language name -> bytes of code, from the GitHub languages API",
)
significant_languages: list[str] = Field(
default_factory=list,
description="Languages holding >=10% of total bytes, largest first "
"(see languages.py) — the languages the repository is written in, "
"excluding residue like CI scripts and generated markup",
)
topics: list[str] = Field(default_factory=list)
license_spdx: Optional[str] = Field(
default=None, description="SPDX identifier of the detected license, if any"
)
license_spdx_raw: Optional[str] = Field(
default=None,
description="Unfiltered spdx_id from GitHub, including the NOASSERTION "
"sentinel that marks an unrecognized license file",
)
class StarDay(BaseModel):
"""Stars added on a single calendar day (UTC)."""
date: str = Field(description="Day the stars were added, ISO ``YYYY-MM-DD`` (UTC)")
count: int = Field(ge=1, description="Number of stars added that day")
class StarHistory(BaseModel):
"""Per-day star-addition history, for the stars-over-time chart.
Collected newest-first from GitHub's GraphQL ``stargazers`` connection and
bucketed by UTC day. Bounded: at most a fixed number of pages is fetched
(see ``collect.STAR_HISTORY_MAX_PAGES``), so for very popular repositories
only the most recent window is captured. ``complete`` says whether the
whole history was reached; when it is False the earliest ``days`` bucket is
a partial window boundary, and a cumulative curve must be anchored at
``total_stars`` (working backwards) rather than at zero.
"""
total_stars: int = Field(ge=0, description="Repository's current star count")
collected: int = Field(
ge=0, description="Star events actually fetched (< total_stars when truncated)"
)
complete: bool = Field(
description="True when pagination reached the end of the stargazers connection "
"(not merely when collected matches total_stars — GitHub's counter can exceed "
"the entries the connection lists)"
)
days: list[StarDay] = Field(
default_factory=list, description="Daily star additions, ascending by date"
)
# GitHub restricted the stargazers connection to a repository's own admins
# and collaborators in July 2026, so a history that was collected can never
# be collected again and is carried forward into later scans instead (see
# ``collect.scan_repository``). A carried-forward history is frozen at the
# day it was captured, and every consumer — chart, growth assessment,
# reader — has to be able to tell how old it is.
collected_at: Optional[str] = Field(
default=None,
description="UTC day the history was captured, ISO ``YYYY-MM-DD``. Older than the "
"report's own generated_at when the history was carried forward from an earlier scan",
)
class ForkDay(BaseModel):
"""Forks created on a single calendar day (UTC)."""
date: str = Field(description="Day the forks were created, ISO ``YYYY-MM-DD`` (UTC)")
count: int = Field(ge=1, description="Number of forks created that day")
class ForkHistory(BaseModel):
"""Per-day fork-creation history, for the forks-over-time chart.
Collected newest-first from GitHub's GraphQL ``forks`` connection (each fork
node's ``createdAt`` is when the fork — and so the fork event — was created)
and bucketed by UTC day. Bounded exactly like ``StarHistory`` (see
``collect.FORK_HISTORY_MAX_PAGES``); ``complete`` says whether the whole
history was reached, and when False a cumulative curve must be anchored at
``total_forks`` working backwards rather than at zero.
"""
total_forks: int = Field(ge=0, description="Repository's current fork count")
collected: int = Field(
ge=0, description="Fork events actually fetched (< total_forks when truncated)"
)
complete: bool = Field(
description="True when pagination reached the end of the forks connection "
"(not merely when collected matches total_forks — forkCount can exceed "
"the forks the connection lists)"
)
days: list[ForkDay] = Field(
default_factory=list, description="Daily fork additions, ascending by date"
)
class Popularity(BaseModel):
stars: int = 0
forks: int = 0
watchers: int = Field(default=0, description="Subscribers (users watching for notifications)")
open_issues_and_prs: int = Field(
default=0, description="GitHub's combined open issues + open PRs counter"
)
star_history: Optional[StarHistory] = Field(
default=None,
description="Per-day star additions for the stars-over-time chart; None when "
"unavailable (no token, or the GraphQL fetch failed)",
)
fork_history: Optional[ForkHistory] = Field(
default=None,
description="Per-day fork additions for the forks-over-time chart; None when "
"unavailable (no token, or the GraphQL fetch failed)",
)
class ContributorOrganization(BaseModel):
"""A public GitHub organization membership declared on a contributor profile."""
login: str
name: Optional[str] = None
location: Optional[str] = None
class ContributorProfile(BaseModel):
"""Optional public profile enrichment for a displayed top contributor."""
name: Optional[str] = None
location: Optional[str] = None
company: Optional[str] = None
organizations: list[ContributorOrganization] = Field(default_factory=list)
class Contributor(BaseModel):
login: str
commits: int
type: Optional[str] = None
avatar_url: Optional[str] = None
profile: Optional[ContributorProfile] = None
class CommitRecord(BaseModel):
"""One commit from the default branch, as written by its author.
The message is kept split the way GitHub returns it: ``headline`` is the
first line (the subject), ``body`` everything after it. A long body is
shortened from the middle rather than the end (see
``collect._truncate_body``), because git trailers — the ``Co-authored-by``
and ``Generated with`` lines that identify bots and coding agents — sit at
the very end of a message.
"""
oid: str = Field(description="Full commit SHA")
committed_at: datetime = Field(description="Committer date")
headline: str = Field(description="First line of the commit message")
body: Optional[str] = Field(
default=None,
description="Message after the first line; None when there is none. When "
"truncated, head and tail are kept with an elision marker between them",
)
body_truncated: bool = Field(
default=False, description="True when the middle of body was elided"
)
author_login: Optional[str] = Field(
default=None, description="GitHub login of the author; None for unlinked authors"
)
author_name: Optional[str] = Field(
default=None, description="Author name from the git commit itself"
)
is_bot: bool = Field(
default=False,
description="Authoring account is an automation account (a GitHub App: "
"Dependabot, Renovate, a CI bot), not a person",
)
is_coding_agent: bool = Field(
default=False,
description="An LLM coding agent wrote the change — either committing "
"under its own account or credited in a Co-authored-by trailer. "
"Independent of is_bot: a human commit produced with an agent has "
"is_bot=false and is_coding_agent=true. See bots.py for the detection "
"keys and their known incompleteness",
)
class ReleaseRecord(BaseModel):
"""One release, with its version tag classified by semantic-version level.
``kind`` reads the tag alone rather than diffing consecutive versions, so a
gap in the fetched window cannot mislabel a release: ``X.0.0`` is major,
``X.Y.0`` minor, anything else with a three-part version is a patch, a
``-rc``/``-beta`` suffix makes it a prerelease, and a tag that is not
semver at all is ``other``.
"""
tag: str
published_at: Optional[datetime] = Field(
default=None, description="Publication date; None when the tag carries no date"
)
kind: Literal["major", "minor", "patch", "prerelease", "other"] = Field(
description="Semantic-version level of the tag"
)
class Activity(BaseModel):
"""Commit / release activity facts."""
recent_commits: list[CommitRecord] = Field(
default_factory=list,
description="Up to 100 newest commits on the default branch, newest first. "
"Empty when unavailable (no token, empty repository, or the GraphQL "
"snapshot fell back to REST)",
)
commits_last_year: Optional[int] = Field(
default=None, description="Total commits in the last 52 weeks (all contributors)"
)
active_weeks_last_year: Optional[int] = Field(
default=None, description="Number of weeks with at least one commit in the last 52 weeks"
)
days_since_last_push: Optional[int] = None
releases_count: Optional[int] = Field(
default=None, description="Number of releases fetched (capped at 100)"
)
releases_from_tags: bool = Field(
default=False,
description=(
"True when release facts were derived from semver git tags because the "
"repo publishes no GitHub Releases"
),
)
releases: list[ReleaseRecord] = Field(
default_factory=list,
description="Up to 100 most recent releases, newest first, for the release "
"timeline. Carried by the same snapshot the aggregates above are derived "
"from, so it costs no extra request",
)
latest_release_tag: Optional[str] = None
latest_release_at: Optional[datetime] = None
days_since_latest_release: Optional[int] = None
mean_days_between_releases: Optional[float] = Field(
default=None, description="Mean gap between the most recent releases (up to 10)"
)
class TrackedItem(BaseModel):
"""One open issue or pull request, with whatever last happened on it.
Sampled oldest-first: the question these answer is not what the tracker is
busy with, it is what has been sitting in it unanswered — and that is at
the far end of the queue, not the near one.
"""
number: int
created_at: datetime
last_comment_at: Optional[datetime] = Field(
default=None, description="Most recent comment; None when nobody has replied at all"
)
last_comment_author: Optional[str] = Field(
default=None,
description="Login of the most recent commenter — read to tell a maintainer "
"reply from the author talking to themselves; None when there is no comment",
)
class RecentPullRequests(BaseModel):
"""What the project did with the pull requests it decided recently.
``IssueMetrics.merged_prs`` already carries the all-time acceptance rate,
and that figure is close to immovable: a project with nine thousand merged
pull requests cannot change it inside a year no matter how it behaves now.
These fields answer the same question over a window a maintainer can still
move, which is the question anyone deciding whether to open a pull request
is actually asking.
``newcomer_*`` narrows it further, to contributors with no prior merged
pull request in this repository. A project can be quick and generous with
its regulars and never merge anything from outside that circle; the two
rates come apart often enough that only the second one tells a first-time
contributor what to expect.
Derived from a fixed-size sample of the most recently updated decided pull
requests (see ``snapshot.MAX_DECIDED_PR_SAMPLE``). ``sample_exhausted``
marks the case where the sample ran out before the window did: the counts
are then lower bounds and only ``*_30d`` ratios remain meaningful.
"""
window_days: int = Field(
default=30, description="Length of the long window every *_30d field covers"
)
sample_size: int = Field(
default=0, description="Decided pull requests read, across every window"
)
sample_exhausted: bool = Field(
default=False,
description="The sample ended inside the window, so counts are lower bounds",
)
decided_7d: Optional[int] = None
merged_7d: Optional[int] = None
decided_30d: Optional[int] = None
merged_30d: Optional[int] = None
authors_30d: Optional[int] = Field(
default=None, description="Distinct human authors of decided pull requests in the window"
)
authors_probed_30d: Optional[int] = Field(
default=None,
description="How many of those authors' histories were actually checked; below "
"authors_30d when the probe cap was hit, and every newcomer_* figure then "
"describes the probed subset only",
)
newcomer_authors_30d: Optional[int] = None
newcomer_decided_30d: Optional[int] = None
newcomer_merged_30d: Optional[int] = None
bot_prs_excluded_30d: int = Field(
default=0,
description="Automation-authored pull requests dropped before anything above "
"was counted; a Dependabot queue merging itself is not contribution flow",
)
class ContributionFlow(BaseModel):
"""Whether work arriving from outside is still being acted on.
Separate from ``Activity``, which measures what maintainers *emit*. This
measures what they *answer*, and the difference is the whole point: a
finished library emits nothing and owes nothing, while an abandoned one
emits nothing while requests pile up against it.
Carried by the same GraphQL snapshot as everything else, so it costs no
additional request. ``collected`` is False on the REST fallback path and
on unauthenticated scans, where every field below stays empty and nothing
derived from them may be scored.
"""
collected: bool = Field(
default=False, description="The GraphQL snapshot supplied these fields"
)
last_merged_pr_at: Optional[datetime] = Field(
default=None,
description="Merge date of the most recently merged pull request seen in the "
"sample; None when the repository has never merged one",
)
oldest_open_prs: list[TrackedItem] = Field(
default_factory=list, description="Up to 20 longest-open pull requests, oldest first"
)
oldest_open_issues: list[TrackedItem] = Field(
default_factory=list, description="Up to 20 longest-open issues, oldest first"
)
ci_last_run_at: Optional[datetime] = Field(
default=None,
description="When CI last reported on the default branch head; None when the "
"repository runs no checks",
)
ci_last_conclusion: Optional[str] = Field(
default=None,
description="Conclusion of that run (SUCCESS, FAILURE, …), verbatim from GitHub",
)
recent_prs: RecentPullRequests = Field(
default_factory=RecentPullRequests,
description="Windowed pull-request outcomes; empty when collected is False",
)
class IssueMetrics(BaseModel):
open_issues: Optional[int] = None
closed_issues: Optional[int] = None
closed_ratio: Optional[float] = Field(
default=None, description="closed / (open + closed), None when the repo has no issues"
)
open_prs: Optional[int] = None
merged_prs: Optional[int] = None
closed_unmerged_prs: Optional[int] = None
class Maintainership(BaseModel):
"""Who maintains the project, and how concentrated that work is.
Every figure here counts people only. Automation accounts are removed
before anything is derived — a bus factor computed over a list where
Renovate and Dependabot rank second and third (measured: prettier, vuejs/core)
describes the release robots, not the maintainers. ``bot_contributors``
keeps the removal visible rather than silent.
"""
contributors_sampled: Optional[int] = Field(
default=None,
description="Human contributors counted (capped at 100 by the API page size)",
)
bot_contributors: Optional[int] = Field(
default=None,
description="Automation accounts excluded from every figure in this "
"section. Undercounts: bots running as ordinary user accounts "
"(kubernetes' k8s-ci-robot) are indistinguishable from people here",
)
top_contributors: list[Contributor] = Field(default_factory=list)
bus_factor: Optional[int] = Field(
default=None,
description="Smallest number of contributors whose commits cover >=50% of sampled commits",
)
top_contributor_share: Optional[float] = Field(
default=None, description="Share of sampled commits by the single top contributor (0..1)"
)
issues: IssueMetrics = Field(default_factory=IssueMetrics)
class ReadmeBadges(BaseModel):
"""Status badges the README displays.
Descriptive only, and deliberately unscored — see ``scanner.readme`` for
why the picture of a fact must not be scored alongside the fact. The one
field with a job beyond description is ``has_inspect_badge``, which is how
badge *adoption* is distinguished from badge *publication*.
"""
collected: bool = Field(
default=False, description="README markup was read; False leaves every count at zero"
)
total: int = Field(default=0, description="Distinct badge images, anywhere in the README")
header: int = Field(
default=0, description="Of those, the ones above the first section heading"
)
hosts: list[str] = Field(
default_factory=list, description="Badge services used, sorted and deduplicated"
)
has_inspect_badge: bool = False
class CommunityHealth(BaseModel):
"""From GitHub's community profile endpoint."""
health_percentage: Optional[int] = None
has_readme: bool = False
has_license: bool = False
has_contributing: bool = False
has_code_of_conduct: bool = False
has_issue_template: bool = False
has_pull_request_template: bool = False
has_description: bool = False
readme_badges: ReadmeBadges = Field(default_factory=ReadmeBadges)
class QualitySignals(BaseModel):
"""Heuristics from the repository file tree."""
has_ci: bool = Field(default=False, description="GitHub Actions workflows present")
ci_workflows: list[str] = Field(default_factory=list)
has_tests: bool = Field(default=False, description="Test directories or test files detected")
has_docs_dir: bool = False
has_linter_config: bool = False
linter_configs: list[str] = Field(default_factory=list)
has_editorconfig: bool = False
has_precommit_config: bool = False
class ScorecardCheck(BaseModel):
"""One OpenSSF Scorecard check result.
``score`` is 0..10, or ``None`` when Scorecard returned ``-1`` — meaning
*inconclusive* (it could not determine the answer, e.g. Branch-Protection
without an admin token). Inconclusive checks carry no information and are
excluded from scoring (weights renormalized), never counted as zero.
"""
name: str = Field(description="Scorecard check name, e.g. 'Token-Permissions'")
score: Optional[int] = Field(
default=None, ge=0, le=10, description="0..10; None when Scorecard reported -1 (inconclusive)"
)
reason: Optional[str] = None
documentation_url: Optional[str] = None
class Scorecard(BaseModel):
"""OpenSSF Scorecard result for the repository, produced by the open-source
``scorecard`` CLI (https://github.com/ossf/scorecard).
Scorecard is a neutral, versioned security-scoring standard — its checks
are tool-agnostic (any accepted SAST/dependency-update/etc. tool earns
credit), which is exactly why we lean on it instead of detecting specific
vendor config files. ``aggregate_score`` is Scorecard's own 0..10 headline
number; ``checks`` are the per-check breakdown."""
aggregate_score: Optional[float] = Field(
default=None, description="Scorecard's headline score, 0..10 (None if it could not compute)"
)
checks: list[ScorecardCheck] = Field(default_factory=list)
scorecard_version: Optional[str] = None
ran_at: Optional[datetime] = None
commit: Optional[str] = Field(default=None, description="Repo commit Scorecard evaluated")
class SecuritySignals(BaseModel):
has_security_policy: bool = Field(default=False, description="SECURITY.md present")
has_dependabot_config: bool = False
has_codeql_workflow: bool = False
lockfiles: list[str] = Field(
default_factory=list, description="Dependency lockfiles found (supply-chain pinning signal)"
)
scorecard: Optional[Scorecard] = Field(
default=None,
description="OpenSSF Scorecard result, when the scorecard CLI was available and ran",
)
class AIReadinessSignals(BaseModel):
"""Heuristics for how well the repo is equipped to be developed and
maintained with AI coding agents. Collected from the file tree (paths and
blob sizes) — see docs/metrics.md, "AI Readiness". Presence-based and
coarse: signals that the infrastructure exists, not how good it is.
This is an *independent, additive* signal — the AI Readiness category
carries weight 0.0 in the overall score, so it is surfaced as its own badge
and never drags a solid pre-AI-era project's health score down.
"""
agent_instruction_files: list[str] = Field(
default_factory=list,
description="Agent guidance files found (CLAUDE.md, AGENTS.md, .cursor/rules, "
".github/copilot-instructions.md, GEMINI.md, .windsurfrules, ...)",
)
agent_instruction_max_bytes: Optional[int] = Field(
default=None, description="Size of the largest agent instruction file (stub detection)"
)
has_llms_txt: bool = Field(
default=False,
description="llms.txt / llms-full.txt machine-readable docs entrypoint, in the "
"repository tree or served by the project's website",
)
llms_txt_url: Optional[str] = Field(
default=None,
description="Where the project's website serves llms.txt, when it was found "
"there rather than in the repository tree",
)
bootstrap_files: list[str] = Field(
default_factory=list,
description="One-command bootstrap / task runners (Makefile, Taskfile, justfile, ...)",
)
toolchain_manifests: list[str] = Field(
default_factory=list,
description="Manifests whose toolchain defines the build/test command itself "
"(Cargo.toml -> `cargo test`, go.mod -> `go test`, ...). A weaker bootstrap "
"signal than a task runner, but a real one: these ecosystems need no Makefile",
)
typecheck_configs: list[str] = Field(
default_factory=list,
description="Static type-check configs found (mypy.ini, pyrightconfig.json, tsconfig.json, py.typed, ...)",
)
has_devcontainer: bool = False
has_dockerfile: bool = False
has_nix: bool = Field(default=False, description="Nix flake / shell.nix / default.nix present")
api_schema_files: list[str] = Field(
default_factory=list,
description="Machine-readable interface schemas (OpenAPI/Swagger, GraphQL, protobuf, AsyncAPI)",
)
has_mcp_signal: bool = Field(
default=False, description="Model Context Protocol server signal (dependency or mcp config)"
)
example_dirs: list[str] = Field(
default_factory=list, description="Directories of runnable examples/recipes, and notebooks"
)
source_files_sampled: int = Field(
default=0, description="Non-vendored source files considered for the file-size signal"
)
oversized_source_files: int = Field(
default=0, description="Source files above the agent-legibility size threshold"
)
largest_source_bytes: Optional[int] = None
class Dependency(BaseModel):
"""One dependency declared directly in a manifest file, parsed from its
own text — not resolved against a registry. Reported exactly as declared;
no freshness or vulnerability checks are performed (see the "Not yet
integrated" note in docs/ecosystems.md)."""
ecosystem: str = Field(
description="pypi | npm | packagist | crates | go | maven | rubygems | nuget | hex"
)
name: str
version_constraint: Optional[str] = Field(
default=None, description="As declared in the manifest, verbatim (e.g. \"^3.1.50\")"
)
manifest: str = Field(description="Manifest file path this dependency was declared in")
class ResolvedDependency(BaseModel):
"""One package in the resolved dependency set (direct + transitive),
as reported by GitHub's dependency-graph SBOM export."""
ecosystem: str = Field(
description="pypi | npm | packagist | crates | go | maven | rubygems | nuget | hex"
)
name: str
version: Optional[str] = Field(
default=None, description="Resolved version when the graph knows it (lockfile-backed)"
)
direct: bool = Field(
description="Matches a declared direct runtime dependency; False = indirect/transitive "
"(or a direct dev/test dependency, which the declared list excludes)"
)
class AllDependencies(BaseModel):
"""The full resolved dependency set — direct plus indirect/transitive —
from GitHub's dependency-graph SBOM export.
Collection is strictly best-effort and time-boxed: when it fails or the
budget runs out, ``collected`` is False, ``error`` says why (mirrored in
the report warnings), and the scan continues unaffected. The declared
direct list (``DependencySignals.dependencies``) is collected
independently and is never impacted."""
collected: bool = Field(
default=False, description="The resolved graph was retrieved successfully"
)
source: Optional[str] = Field(
default=None, description='Where the graph came from ("github-sbom"); None if not collected'
)
error: Optional[str] = Field(
default=None, description="Why the graph could not be collected (also a report warning)"
)
total_count: Optional[int] = Field(
default=None, description="Resolved packages in the graph (always complete, even when the list is truncated)"
)
direct_count: Optional[int] = None
indirect_count: Optional[int] = None
truncated: bool = Field(
default=False,
description="The embedded package list was capped to keep reports bounded; counts remain complete",
)
packages: list[ResolvedDependency] = Field(
default_factory=list, description="Resolved packages, direct entries first"
)
class AdvisoryFinding(BaseModel):
"""One resolved dependency carrying known advisories.
``severity`` is the worst severity across the package's advisories, from
the advisory database's own label; "unknown" where the record carries none
(common for PYSEC entries) rather than a guess."""
ecosystem: str
name: str
version: Optional[str] = None
direct: bool = Field(
description="Matches a declared direct runtime dependency; False = indirect, "
"or a direct dev/test dependency, which the declared list excludes"
)
severity: str = Field(description="critical | high | moderate | low | unknown")
cvss_score: Optional[float] = Field(
default=None,
description="Highest CVSS base score across this package's advisories, computed "
"from the published vector; null where no advisory carries one",
)
oldest_advisory_days: Optional[int] = Field(
default=None,
description="Days since the earliest of this package's advisories was published — "
"how long a fix has been available and unapplied",
)
advisory_count: int = Field(description="Distinct advisories affecting this version")
advisory_ids: list[str] = Field(
default_factory=list, description="Advisory identifiers (OSV/GHSA/PYSEC), capped at 10"
)
fixed_version: Optional[str] = Field(
default=None, description="Highest version an advisory records as fixed, when stated"
)
class MaliciousDependency(BaseModel):
"""One resolved dependency OSV reports as a malicious package.
Sourced from the OpenSSF ``ossf/malicious-packages`` corpus, which OSV.dev
serves under ``MAL-`` identifiers, so it arrives on the same batch query as
ordinary advisories at no extra cost.
This is not a vulnerability finding and carries none of a vulnerability's
fields: malware has no CVSS vector and no severity band. The remedy is
removal, or moving off the compromised name — never an upgrade to a fixed
release of the same artifact, because there is none."""
ecosystem: str
name: str
version: Optional[str] = Field(
default=None, description="Version resolved in the dependency graph"
)
direct: bool = Field(
description="Matches a declared direct runtime dependency; False = indirect. "
"Both are scored alike — an install-time payload runs at any depth."
)
advisory_ids: list[str] = Field(
default_factory=list, description="Malicious-package report identifiers, capped at 10"
)
first_reported_at: Optional[datetime] = Field(
default=None,
description="Earliest publication date across the reports, when the record states one",
)
still_published: Optional[bool] = Field(
default=None,
description="Whether the registry still serves this exact version. False means the "
"registry removed it, so the reported artifact can no longer be installed and the "
"finding is reported without being scored. None means the check did not run or the "
"ecosystem is not covered — treated as still published, the conservative direction.",
)
class DependencyAdvisories(BaseModel):
"""Known advisories affecting the resolved dependency set, from OSV.dev.
Best-effort like the dependency graph it reads from: on any failure
``collected`` is False and ``error`` says why, and the advisory metric is
excluded from scoring rather than counted as zero.
An entry here means the version recorded in the dependency graph falls in
an advisory's affected range. It is not a reachability or exploitability
finding, and the graph includes development and test pins that GitHub's
export does not distinguish from runtime dependencies."""
collected: bool = Field(default=False, description="The advisory lookup completed")
source: Optional[str] = Field(default=None, description='Advisory source ("osv")')
scope: Optional[str] = Field(
default=None,
description='What was assessed: "published_package" (the runtime closure of the '
"published package, from deps.dev — what installing it pulls in) or "
'"repository_graph" (the repository dependency graph, which also contains '
"development and test pins)",
)
assessed_package: Optional[str] = Field(
default=None,
description='The published package assessed, as "ecosystem:name@version"; '
"null in repository_graph scope",
)
error: Optional[str] = Field(
default=None, description="Why advisories could not be collected (also a report warning)"
)
assessed_count: int = Field(
default=0, description="Resolved packages actually queried (version and ecosystem known)"
)
unassessed_count: int = Field(
default=0, description="Resolved packages skipped — no version, or unsupported ecosystem"
)
affected_count: int = Field(default=0, description="Assessed packages carrying advisories")
direct_affected_count: int = Field(
default=0, description="Affected packages that are declared direct runtime dependencies"
)
advisory_count: int = Field(
default=0, description="Total advisories across affected packages"
)
by_severity: dict[str, int] = Field(
default_factory=dict, description="Affected package counts keyed by worst severity"
)
truncated: bool = Field(
default=False, description="The embedded findings list was capped; counts remain complete"
)
findings: list[AdvisoryFinding] = Field(
default_factory=list, description="Affected packages, most severe first"
)
malicious_count: int = Field(
default=0, description="Assessed packages reported as malicious packages"
)
malicious: list[MaliciousDependency] = Field(
default_factory=list,
description="Dependencies reported as malicious packages, direct entries first. "
"Excluded from findings and from every advisory count above — malware is "
"reported and scored as its own finding, not as an advisory",
)
class DependencySignals(BaseModel):
manifests: list[str] = Field(
default_factory=list, description="Dependency manifest files found in the tree"
)
ecosystems: list[str] = Field(
default_factory=list, description="Package ecosystems inferred from manifests"
)
dependencies: list[Dependency] = Field(
default_factory=list,
description="Direct runtime dependencies declared in supported manifests "
"(pyproject.toml, setup.cfg, package.json, composer.json, Cargo.toml, "
"go.mod, pom.xml, Gemfile, *.csproj, mix.exs); dev/test dependency "
"groups and platform pseudo-packages are excluded",
)
all_dependencies: AllDependencies = Field(
default_factory=AllDependencies,
description="Full resolved dependency set (direct + transitive) from the "
"GitHub dependency-graph SBOM; best-effort, see AllDependencies",
)
advisories: DependencyAdvisories = Field(
default_factory=DependencyAdvisories,
description="Known advisories affecting the resolved set, matched against "
"OSV.dev; best-effort, see DependencyAdvisories",
)
class EcosystemPackage(BaseModel):
"""Facts about a published package this repository ships, from a package
registry (PyPI, npm, Packagist, crates.io, the Go module proxy, Maven
Central, NuGet, …). Registry data is richer and more adoption-relevant
than GitHub stars: real download counts, publish cadence, and
deprecation/yank flags."""
ecosystem: str = Field(
description="pypi | npm | packagist | crates | rubygems | hex | go | maven | nuget"
)
name: str = Field(description="Package identifier within the ecosystem")
registry_url: str
exists: bool = Field(default=True, description="Package was found on the registry")
matches_repo: Optional[bool] = Field(
default=None,
description="Registry's repository URL points back to the scanned repo "
"(None when the registry declares no repository)",
)
latest_version: Optional[str] = None
latest_published_at: Optional[datetime] = None
days_since_latest_publish: Optional[int] = None
first_published_at: Optional[datetime] = None
versions_count: Optional[int] = None
monthly_downloads: Optional[int] = Field(
default=None, description="Downloads in the last ~30 days (approximated for crates.io)"
)
total_downloads: Optional[int] = None
dependents_count: Optional[int] = Field(
default=None, description="Number of registry packages depending on this one, if published"
)
downloads_state: Optional[str] = Field(
default=None,
description="How the download figures were obtained: 'published' (the registry or "
"its stats service answered with figures), 'unpublished' (it tracks none for this "
"package), 'failed' (the stats endpoint errored or stayed rate-limited through "
"every retry this scan), 'carried_forward' (this scan failed and the figures are "
"the previous scan's). The last two exist so a missing figure is never mistaken "
"for a published zero, and a throttled scan is never mistaken for a package "
"nobody downloads. None on reports predating schema 0.32.0",
)
license: Optional[str] = None
maintainers_count: Optional[int] = None
is_deprecated: bool = Field(
default=False, description="npm deprecated / Packagist abandoned"
)
deprecation_note: Optional[str] = Field(
default=None, description="Deprecation message or suggested replacement"
)
latest_version_yanked: Optional[bool] = None
repository_url: Optional[str] = Field(
default=None, description="Repository URL the registry declares for the package"
)
documentation_url: Optional[str] = Field(
default=None,
description="Documentation URL the registry declares for the package "
"(e.g. the Cargo.toml `documentation` key mirrored by crates.io, "
"PyPI project_urls Documentation, RubyGems documentation_uri)",
)
homepage_url: Optional[str] = Field(
default=None,
description="Homepage URL the registry declares for the package, when "
"distinct from the repository",
)
keywords: list[str] = Field(
default_factory=list,
description="Tags/keywords/categories/classifiers the registry lists for the "
"package (PyPI classifiers, npm keywords, crates.io categories+keywords, "
"Packagist keywords, NuGet tags, …); empty where the registry has no such "
"concept or none were declared",
)
declared_type: Optional[str] = Field(
default=None,
description="The artifact type the registry itself publishes, verbatim, where "
'the registry has such a field: Packagist ``type`` ("library", "project", '
'"wordpress-plugin", …), NuGet package types ("DotnetTool", "Template"), Maven '
"``packaging`` (jar/war/pom/maven-plugin). None where the registry declares no "
"type — most of them do not",
)
categories: list[str] = Field(
default_factory=list,
description="Registry categories from a *controlled* vocabulary, kept apart from "
"the free-form ``keywords`` because they are reliable enough to classify on. "
"Only crates.io publishes such a vocabulary today "
'("command-line-utilities", "web-programming::http-server", "api-bindings", …)',
)
class EcosystemData(BaseModel):
"""Registry facts for every package the repository publishes."""
packages: list[EcosystemPackage] = Field(default_factory=list)
class IconRejection(BaseModel):
"""One image the icon cascade considered and refused, and why.
Kept in the report because "this project shows its owner's avatar" is a
conclusion someone will eventually question, and the answer is the list of
things that were tried first.
"""
source_type: str = Field(description="Cascade stage that offered the candidate")
url: str
reason: str = Field(description="Why it was refused, in plain words")