Setting the file. One moment. Build Ownership Map · Security Ownership Map · openai/skills · Skills Docsdef run_git_log
— line 340
This file
- Number
- 32.2
- Position
- 2 of 7
- Type
- Python
- Size
- 34 KB
- Lines
- 956
scripts/build_ownership_map.py
Python·956 lines·34 KB
re
14import subprocess
15import sys
16from collections import defaultdict
17from pathlib import Path
18from typing import Iterable
19
20DEFAULT_SENSITIVE_RULES: list[tuple[str, str, float]] = [
21 ("**/auth/**", "auth", 1.0),
22 ("**/oauth/**", "auth", 1.0),
23 ("**/rbac/**", "auth", 1.0),
24 ("**/session/**", "auth", 1.0),
25 ("**/token/**", "auth", 1.0),
26 ("**/crypto/**", "crypto", 1.0),
27 ("**/tls/**", "crypto", 1.0),
28 ("**/ssl/**", "crypto", 1.0),
29 ("**/secrets/**", "secrets", 1.0),
30 ("**/keys/**", "secrets", 1.0),
31 ("**/*.pem", "secrets", 1.0),
32 ("**/*.key", "secrets", 1.0),
33 ("**/*.p12", "secrets", 1.0),
34 ("**/*.pfx", "secrets", 1.0),
35 ("**/iam/**", "auth", 1.0),
36 ("**/sso/**", "auth", 1.0),
37]
38
39DEFAULT_AUTHOR_EXCLUDE_REGEXES = [
40 "dependabot",
41]
42
43DEFAULT_COCHANGE_EXCLUDES = [
44 "**/Cargo.lock",
45 "**/Cargo.toml",
46 "**/package-lock.json",
47 "**/yarn.lock",
48 "**/pnpm-lock.yaml",
49 "**/go.sum",
50 "**/go.mod",
51 "**/Gemfile.lock",
52 "**/Pipfile.lock",
53 "**/poetry.lock",
54 "**/composer.lock",
55 "**/.github/**",
56 "**/.gitignore",
57 "**/.gitattributes",
58 "**/.gitmodules",
59 "**/.editorconfig",
60 "**/.vscode/**",
61 "**/.idea/**",
62]
63
64
65def parse_args() -> argparse.Namespace:
66 parser = argparse.ArgumentParser(
67 description="Build ownership graphs and security ownership summaries from git history."
68 )
69 parser.add_argument("--repo", default=".", help="Path to the git repo (default: .)")
70 parser.add_argument(
71 "--out",
72 default="ownership-map-out",
73 help="Output directory for graph artifacts",
74 )
75 parser.add_argument("--since", default=None, help="Limit git log to commits since date")
76 parser.add_argument("--until", default=None, help="Limit git log to commits until date")
77 parser.add_argument(
78 "--identity",
79 choices=("author", "committer"),
80 default="author",
81 help="Identity to attribute touches to",
82 )
83 parser.add_argument(
84 "--date-field",
85 choices=("author", "committer"),
86 default="author",
87 help="Date field to use for recency and bucketing",
88 )
89 parser.add_argument(
90 "--include-merges",
91 action="store_true",
92 help="Include merge commits (excluded by default)",
93 )
94 parser.add_argument(
95 "--half-life-days",
96 type=float,
97 default=180.0,
98 help="Half life for recency weighting",
99 )
100 parser.add_argument(
101 "--sensitive-config",
102 default=None,
103 help="CSV file with pattern,tag,weight for sensitive paths",
104 )
105 parser.add_argument(
106 "--owner-threshold",
107 type=float,
108 default=0.5,
109 help="Share threshold for hidden owner detection",
110 )
111 parser.add_argument(
112 "--bus-factor-threshold",
113 type=int,
114 default=1,
115 help="Bus factor threshold for hotspots",
116 )
117 parser.add_argument(
118 "--stale-days",
119 type=int,
120 default=365,
121 help="Days since last touch to consider stale",
122 )
123 parser.add_argument(
124 "--min-touches",
125 type=int,
126 default=1,
127 help="Minimum touches to keep an edge",
128 )
129 parser.add_argument(
130 "--emit-commits",
131 action="store_true",
132 help="Write commit list to commits.jsonl",
133 )
134 parser.add_argument(
135 "--author-exclude-regex",
136 action="append",
137 default=[],
138 help="Regex for author name/email to exclude (repeatable)",
139 )
140 parser.add_argument(
141 "--no-default-author-excludes",
142 action="store_true",
143 help="Disable default author excludes (dependabot)",
144 )
145 parser.add_argument(
146 "--no-cochange",
147 action="store_true",
148 help="Disable co-change graph output",
149 )
150 parser.add_argument(
151 "--cochange-max-files",
152 type=int,
153 default=50,
154 help="Ignore commits touching more than this many files for co-change graph",
155 )
156 parser.add_argument(
157 "--cochange-min-count",
158 type=int,
159 default=2,
160 help="Minimum co-change count to keep file-file edge",
161 )
162 parser.add_argument(
163 "--cochange-min-jaccard",
164 type=float,
165 default=0.05,
166 help="Minimum Jaccard similarity to keep file-file edge",
167 )
168 parser.add_argument(
169 "--cochange-exclude",
170 action="append",
171 default=[],
172 help="Glob to exclude from co-change graph (repeatable)",
173 )
174 parser.add_argument(
175 "--no-default-cochange-excludes",
176 action="store_true",
177 help="Disable default co-change excludes (lockfiles, .github, editor config)",
178 )
179 parser.add_argument(
180 "--no-communities",
181 dest="communities",
182 action="store_false",
183 help="Disable community detection (enabled by default, requires networkx)",
184 )
185 parser.add_argument(
186 "--graphml",
187 action="store_true",
188 help="Emit ownership.graphml (requires networkx)",
189 )
190 parser.add_argument(
191 "--max-community-files",
192 type=int,
193 default=50,
194 help="Max files listed per community",
195 )
196 parser.add_argument(
197 "--community-top-owners",
198 type=int,
199 default=5,
200 help="Top maintainers saved per community",
201 )
202 parser.set_defaults(communities=True)
203 return parser.parse_args()
204
205
206def load_sensitive_rules(path: str | None) -> list[tuple[str, str, float]]:
207 if not path:
208 return list(DEFAULT_SENSITIVE_RULES)
209 rules: list[tuple[str, str, float]] = []
210 with open(path, "r", encoding="utf-8") as handle:
211 for raw in handle:
212 line = raw.strip()
213 if not line or line.startswith("#"):
214 continue
215 parts = [part.strip() for part in line.split(",")]
216 if not parts:
217 continue
218 pattern = parts[0]
219 tag = parts[1] if len(parts) > 1 and parts[1] else "sensitive"
220 weight = float(parts[2]) if len(parts) > 2 and parts[2] else 1.0
221 rules.append((pattern, tag, weight))
222 return rules
223
224
225def parse_date(value: str) -> dt.datetime:
226 parsed = dt.datetime.fromisoformat(value)
227 if parsed.tzinfo is None:
228 parsed = parsed.replace(tzinfo=dt.timezone.utc)
229 return parsed
230
231
232def offset_minutes(timestamp: dt.datetime) -> int | None:
233 offset = timestamp.utcoffset()
234 if offset is None:
235 return None
236 return int(offset.total_seconds() / 60)
237
238
239def format_offset(minutes: int) -> str:
240 sign = "+" if minutes >= 0 else "-"
241 minutes = abs(minutes)
242 return f"{sign}{minutes // 60:02d}:{minutes % 60:02d}"
243
244
245def recency_weighted(now: dt.datetime, when: dt.datetime, half_life_days: float) -> float:
246 if half_life_days <= 0:
247 return 1.0
248 age_days = max(0.0, (now - when).total_seconds() / 86400.0)
249 return math.exp(-math.log(2) * age_days / half_life_days)
250
251
252def match_sensitive(path: str, rules: Iterable[tuple[str, str, float]]) -> dict[str, float]:
253 tags: dict[str, float] = defaultdict(float)
254 posix = path.replace("\\", "/")
255 for pattern, tag, weight in rules:
256 patterns = [pattern]
257 if pattern.startswith("**/"):
258 patterns.append(pattern[3:])
259 for candidate in patterns:
260 if fnmatch.fnmatchcase(posix, candidate):
261 tags[tag] += weight
262 break
263 return tags
264
265
266def matches_glob(path: str, pattern: str) -> bool:
267 posix = path.replace("\\", "/")
268 patterns = [pattern]
269 if pattern.startswith("**/"):
270 patterns.append(pattern[3:])
271 return any(fnmatch.fnmatchcase(posix, candidate) for candidate in patterns)
272
273
274def is_excluded(path: str, patterns: Iterable[str]) -> bool:
275 return any(matches_glob(path, pattern) for pattern in patterns)
276
277
278def author_excluded(name: str, email: str, patterns: Iterable[re.Pattern[str]]) -> bool:
279 if not patterns:
280 return False
281 haystack = f"{name} {email}".strip()
282 return any(pattern.search(haystack) for pattern in patterns)
283
284
285def compute_community_owners(
286 community_files: Iterable[str],
287 people: dict[str, dict[str, object]],
288 file_people_touches: dict[str, dict[str, int]],
289 file_people_recency: dict[str, dict[str, float]],
290 file_people_sensitive: dict[str, dict[str, float]],
291 top_n: int,
292) -> dict[str, object]:
293 touches_by_person: dict[str, int] = defaultdict(int)
294 recency_by_person: dict[str, float] = defaultdict(float)
295 sensitive_by_person: dict[str, float] = defaultdict(float)
296
297 for path in community_files:
298 for person, touches in file_people_touches.get(path, {}).items():
299 touches_by_person[person] += touches
300 for person, recency in file_people_recency.get(path, {}).items():
301 recency_by_person[person] += recency
302 for person, weight in file_people_sensitive.get(path, {}).items():
303 sensitive_by_person[person] += weight
304
305 total_touches = sum(touches_by_person.values())
306 total_recency = sum(recency_by_person.values())
307 total_sensitive = sum(sensitive_by_person.values())
308
309 ranked = sorted(touches_by_person.items(), key=lambda item: item[1], reverse=True)
310 owners = []
311 for person_id, touches in ranked[:top_n]:
312 recency = recency_by_person.get(person_id, 0.0)
313 sensitive = sensitive_by_person.get(person_id, 0.0)
314 owners.append(
315 {
316 "person_id": person_id,
317 "name": people.get(person_id, {}).get("name", person_id),
318 "touches": touches,
319 "touch_share": round(touches / total_touches, 4) if total_touches else 0.0,
320 "recency_share": round(recency / total_recency, 4) if total_recency else 0.0,
321 "sensitive_share": round(sensitive / total_sensitive, 4)
322 if total_sensitive
323 else 0.0,
324 "primary_tz_offset": people.get(person_id, {}).get("primary_tz_offset", ""),
325 }
326 )
327
328 return {
329 "bus_factor": len(touches_by_person),
330 "owner_count": len(touches_by_person),
331 "totals": {
332 "touches": total_touches,
333 "recency_weight": round(total_recency, 6),
334 "sensitive_weight": round(total_sensitive, 2),
335 },
336 "top_maintainers": owners,
337 }
338
339
340def run_git_log(
341 repo: str, since: str | None, until: str | None, include_merges: bool
342) -> Iterable[list[str]]:
343 cmd = [
344 "git",
345 "-C",
346 repo,
347 "log",
348 "--name-only",
349 "--no-renames",
350 "--date=iso-strict",
351 "--format=---%n%H%n%P%n%an%n%ae%n%ad%n%cn%n%ce%n%cd",
352 ]
353 if not include_merges:
354 cmd.append("--no-merges")
355 if since:
356 cmd.extend(["--since", since])
357 if until:
358 cmd.extend(["--until", until])
359
360 proc = subprocess.Popen(
361 cmd,
362 stdout=subprocess.PIPE,
363 stderr=subprocess.PIPE,
364 text=True,
365 )
366 assert proc.stdout is not None
367
368 batch: list[str] = []
369 for line in proc.stdout:
370 batch.append(line.rstrip("\n"))
371 if line.rstrip("\n") == "---" and len(batch) > 1:
372 yield batch[:-1]
373 batch = ["---"]
374
375 if batch:
376 yield batch
377
378 stderr = proc.stderr.read() if proc.stderr else ""
379 exit_code = proc.wait()
380 if exit_code != 0:
381 raise RuntimeError(stderr.strip() or "git log failed")
382
383
384def iter_commits(lines: Iterable[list[str]]) -> Iterable[tuple[dict[str, object], list[str]]]:
385 for chunk in lines:
386 if not chunk or chunk[0] != "---":
387 continue
388 header = chunk[1:9]
389 if len(header) < 8:
390 continue
391 parents = [entry for entry in header[1].split(" ") if entry]
392 commit = {
393 "hash": header[0],
394 "parents": parents,
395 "is_merge": len(parents) > 1,
396 "author_name": header[2],
397 "author_email": header[3],
398 "author_date": header[4],
399 "committer_name": header[5],
400 "committer_email": header[6],
401 "committer_date": header[7],
402 }
403 files = [line for line in chunk[9:] if line.strip()]
404 yield commit, files
405
406
407def ensure_out_dir(path: str) -> Path:
408 out_dir = Path(path)
409 out_dir.mkdir(parents=True, exist_ok=True)
410 return out_dir
411
412
413def write_csv(path: Path, header: list[str], rows: Iterable[list[str]]) -> None:
414 with path.open("w", encoding="utf-8", newline="") as handle:
415 writer = csv.writer(handle)
416 writer.writerow(header)
417 for row in rows:
418 writer.writerow(row)
419
420
421def build_ownership_map(args: argparse.Namespace) -> Path:
422 now = dt.datetime.now(dt.timezone.utc)
423 rules = load_sensitive_rules(args.sensitive_config)
424 out_dir = ensure_out_dir(args.out)
425
426 people: dict[str, dict[str, object]] = {}
427 files: dict[str, dict[str, object]] = {}
428 edges: dict[tuple[str, str], dict[str, object]] = {}
429 file_people_touches: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
430 file_people_recency: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
431 file_people_sensitive: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
432 tag_totals: dict[str, float] = defaultdict(float)
433 tag_person_totals: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
434 person_timezone_counts: dict[str, dict[int, int]] = defaultdict(lambda: defaultdict(int))
435 cochange_counts: dict[tuple[str, str], int] = defaultdict(int)
436 cochange_file_commits: dict[str, int] = defaultdict(int)
437 cochange_commits_used = 0
438 cochange_commits_skipped = 0
439 cochange_commits_filtered = 0
440 cochange_files_excluded = 0
441
442 commits_path = out_dir / "commits.jsonl"
443 commit_handle = None
444 if args.emit_commits:
445 commit_handle = commits_path.open("w", encoding="utf-8")
446
447 total_commits_seen = 0
448 total_commits_included = 0
449 commits_excluded_identities = 0
450 commits_excluded_merges = 0
451 total_edges = 0
452
453 author_exclude_regexes = []
454 if not args.no_default_author_excludes:
455 author_exclude_regexes.extend(DEFAULT_AUTHOR_EXCLUDE_REGEXES)
456 author_exclude_regexes.extend(args.author_exclude_regex)
457 author_exclude_patterns = [
458 re.compile(pattern, re.IGNORECASE) for pattern in author_exclude_regexes
459 ]
460
461 cochange_excludes = []
462 if not args.no_default_cochange_excludes:
463 cochange_excludes.extend(DEFAULT_COCHANGE_EXCLUDES)
464 cochange_excludes.extend(args.cochange_exclude)
465
466 log_lines = run_git_log(args.repo, args.since, args.until, args.include_merges)
467 for commit, touched_files in iter_commits(log_lines):
468 total_commits_seen += 1
469
470 if commit.get("is_merge") and not args.include_merges:
471 commits_excluded_merges += 1
472 continue
473
474 identity_name = commit.get(f"{args.identity}_name", "")
475 identity_email = commit.get(f"{args.identity}_email", "")
476 if author_excluded(
477 identity_name,
478 identity_email,
479 author_exclude_patterns,
480 ):
481 commits_excluded_identities += 1
482 continue
483
484 if not touched_files:
485 continue
486
487 total_commits_included += 1
488 if commit_handle:
489 commit_handle.write(json.dumps({**commit, "files": touched_files}) + "\n")
490
491 identity_name = commit.get(f"{args.identity}_name", "")
492 identity_email = commit.get(f"{args.identity}_email", "") or identity_name
493 commit_date = parse_date(commit.get(f"{args.date_field}_date", ""))
494 recency = recency_weighted(now, commit_date, args.half_life_days)
495 tz_minutes = offset_minutes(commit_date)
496 if tz_minutes is not None:
497 person_timezone_counts[identity_email][tz_minutes] += 1
498 unique_files = sorted(set(touched_files))
499 if not args.no_cochange and len(unique_files) > 1:
500 if len(unique_files) > args.cochange_max_files:
501 cochange_commits_skipped += 1
502 else:
503 filtered_files = [
504 path for path in unique_files if not is_excluded(path, cochange_excludes)
505 ]
506 excluded = len(unique_files) - len(filtered_files)
507 if excluded:
508 cochange_files_excluded += excluded
509 if len(filtered_files) < 2:
510 cochange_commits_filtered += 1
511 if filtered_files:
512 for path in filtered_files:
513 cochange_file_commits[path] += 1
514 if len(filtered_files) >= 2:
515 cochange_commits_used += 1
516 for idx, path in enumerate(filtered_files):
517 for other in filtered_files[idx + 1 :]:
518 cochange_counts[(path, other)] += 1
519
520 person = people.setdefault(
521 identity_email,
522 {
523 "name": identity_name,
524 "email": identity_email,
525 "first_seen": commit_date,
526 "last_seen": commit_date,
527 "commit_count": 0,
528 "touches": 0,
529 "sensitive_touches": 0.0,
530 },
531 )
532 person["commit_count"] = int(person["commit_count"]) + 1
533 person["first_seen"] = min(person["first_seen"], commit_date)
534 person["last_seen"] = max(person["last_seen"], commit_date)
535
536 for path in touched_files:
537 file_entry = files.setdefault(
538 path,
539 {
540 "path": path,
541 "first_seen": commit_date,
542 "last_seen": commit_date,
543 "commit_count": 0,
544 "touches": 0,
545 "authors": set(),
546 "sensitive_tags": {},
547 },
548 )
549 file_entry["commit_count"] = int(file_entry["commit_count"]) + 1
550 file_entry["first_seen"] = min(file_entry["first_seen"], commit_date)
551 file_entry["last_seen"] = max(file_entry["last_seen"], commit_date)
552 file_entry["touches"] = int(file_entry["touches"]) + 1
553 file_entry["authors"].add(identity_email)
554
555 edge = edges.setdefault(
556 (identity_email, path),
557 {
558 "touches": 0,
559 "first_seen": commit_date,
560 "last_seen": commit_date,
561 "recency_weight": 0.0,
562 "sensitive_weight": 0.0,
563 },
564 )
565 edge["touches"] = int(edge["touches"]) + 1
566 edge["first_seen"] = min(edge["first_seen"], commit_date)
567 edge["last_seen"] = max(edge["last_seen"], commit_date)
568 edge["recency_weight"] = float(edge["recency_weight"]) + recency
569
570 tags = match_sensitive(path, rules)
571 if tags:
572 file_entry["sensitive_tags"] = tags
573 sensitive_weight = sum(tags.values())
574 edge["sensitive_weight"] = float(edge["sensitive_weight"]) + sensitive_weight
575 person["sensitive_touches"] = float(person["sensitive_touches"]) + sensitive_weight
576 file_people_sensitive[path][identity_email] += sensitive_weight
577 for tag, weight in tags.items():
578 tag_totals[tag] += weight
579 tag_person_totals[tag][identity_email] += weight
580
581 person["touches"] = int(person["touches"]) + 1
582 file_people_touches[path][identity_email] += 1
583 file_people_recency[path][identity_email] += recency
584 total_edges += 1
585
586 if commit_handle:
587 commit_handle.close()
588
589 people_rows = []
590 for email, person in sorted(people.items()):
591 tz_counts = person_timezone_counts.get(email, {})
592 primary_tz_offset = ""
593 primary_tz_minutes = ""
594 timezone_offsets = ""
595 if tz_counts:
596 primary_tz_minutes_value = max(tz_counts.items(), key=lambda item: (item[1], item[0]))[
597 0
598 ]
599 primary_tz_offset = format_offset(primary_tz_minutes_value)
600 primary_tz_minutes = str(primary_tz_minutes_value)
601 timezone_offsets = ";".join(
602 f"{format_offset(minutes)}:{count}"
603 for minutes, count in sorted(tz_counts.items(), key=lambda item: item[0])
604 )
605 person["primary_tz_offset"] = primary_tz_offset
606 people_rows.append(
607 [
608 email,
609 str(person["name"]),
610 email,
611 person["first_seen"].isoformat(),
612 person["last_seen"].isoformat(),
613 str(person["commit_count"]),
614 str(person["touches"]),
615 f"{person['sensitive_touches']:.2f}",
616 primary_tz_offset,
617 primary_tz_minutes,
618 timezone_offsets,
619 ]
620 )
621
622 file_rows = []
623 for path, file_entry in sorted(files.items()):
624 authors = file_entry["authors"]
625 bus_factor = len(authors)
626 tags = file_entry["sensitive_tags"]
627 tag_list = ";".join(sorted(tags.keys()))
628 sensitivity_score = sum(tags.values()) if tags else 0.0
629 file_rows.append(
630 [
631 path,
632 path,
633 file_entry["first_seen"].isoformat(),
634 file_entry["last_seen"].isoformat(),
635 str(file_entry["commit_count"]),
636 str(file_entry["touches"]),
637 str(bus_factor),
638 f"{sensitivity_score:.2f}",
639 tag_list,
640 ]
641 )
642
643 edge_rows = []
644 for (email, path), edge in edges.items():
645 if int(edge["touches"]) < args.min_touches:
646 continue
647 edge_rows.append(
648 [
649 email,
650 path,
651 str(edge["touches"]),
652 f"{edge['recency_weight']:.6f}",
653 edge["first_seen"].isoformat(),
654 edge["last_seen"].isoformat(),
655 f"{edge['sensitive_weight']:.2f}",
656 ]
657 )
658
659 cochange_rows: list[list[str]] = []
660 if not args.no_cochange:
661 for (file_a, file_b), count in cochange_counts.items():
662 if count < args.cochange_min_count:
663 continue
664 commits_a = cochange_file_commits.get(file_a, 0)
665 commits_b = cochange_file_commits.get(file_b, 0)
666 denom = commits_a + commits_b - count
667 if denom <= 0:
668 continue
669 jaccard = count / denom
670 if jaccard < args.cochange_min_jaccard:
671 continue
672 cochange_rows.append([file_a, file_b, str(count), f"{jaccard:.6f}"])
673
674 write_csv(
675 out_dir / "people.csv",
676 [
677 "person_id",
678 "name",
679 "email",
680 "first_seen",
681 "last_seen",
682 "commit_count",
683 "touches",
684 "sensitive_touches",
685 "primary_tz_offset",
686 "primary_tz_minutes",
687 "timezone_offsets",
688 ],
689 people_rows,
690 )
691 write_csv(
692 out_dir / "files.csv",
693 [
694 "file_id",
695 "path",
696 "first_seen",
697 "last_seen",
698 "commit_count",
699 "touches",
700 "bus_factor",
701 "sensitivity_score",
702 "sensitivity_tags",
703 ],
704 file_rows,
705 )
706 write_csv(
707 out_dir / "edges.csv",
708 [
709 "person_id",
710 "file_id",
711 "touches",
712 "recency_weight",
713 "first_seen",
714 "last_seen",
715 "sensitive_weight",
716 ],
717 edge_rows,
718 )
719 if not args.no_cochange:
720 write_csv(
721 out_dir / "cochange_edges.csv",
722 [
723 "file_a",
724 "file_b",
725 "cochange_count",
726 "jaccard",
727 ],
728 cochange_rows,
729 )
730
731 orphaned_sensitive_code = []
732 bus_factor_hotspots = []
733 for path, file_entry in files.items():
734 tags = file_entry["sensitive_tags"]
735 if not tags:
736 continue
737 bus_factor = len(file_entry["authors"])
738 last_seen = file_entry["last_seen"]
739 age_days = (now - last_seen).days
740 top_owner = None
741 if path in file_people_touches:
742 top_owner = max(file_people_touches[path].items(), key=lambda item: item[1])[0]
743 hotspot = {
744 "path": path,
745 "bus_factor": bus_factor,
746 "last_touch": last_seen.isoformat(),
747 "sensitivity_tags": sorted(tags.keys()),
748 "top_owner": top_owner,
749 }
750 if bus_factor <= args.bus_factor_threshold:
751 bus_factor_hotspots.append(hotspot)
752 if age_days >= args.stale_days:
753 orphaned_sensitive_code.append(
754 {
755 **hotspot,
756 "last_security_touch": last_seen.isoformat(),
757 }
758 )
759
760 hidden_owners = []
761 for tag, total in tag_totals.items():
762 if total <= 0:
763 continue
764 person_totals = tag_person_totals[tag]
765 if not person_totals:
766 continue
767 top_email, top_value = max(person_totals.items(), key=lambda item: item[1])
768 share = top_value / total
769 if share >= args.owner_threshold:
770 person_name = people.get(top_email, {}).get("name", top_email)
771 hidden_owners.append(
772 {
773 "person": top_email,
774 "name": person_name,
775 "controls": f"{share * 100:.0f}% of {tag} code",
776 "category": tag,
777 "share": round(share, 4),
778 }
779 )
780
781 summary = {
782 "generated_at": now.isoformat(),
783 "repo": os.path.abspath(args.repo),
784 "parameters": {
785 "since": args.since,
786 "until": args.until,
787 "half_life_days": args.half_life_days,
788 "bus_factor_threshold": args.bus_factor_threshold,
789 "stale_days": args.stale_days,
790 "owner_threshold": args.owner_threshold,
791 "sensitive_config": args.sensitive_config,
792 "identity": args.identity,
793 "date_field": args.date_field,
794 "include_merges": args.include_merges,
795 "cochange_enabled": not args.no_cochange,
796 "cochange_max_files": args.cochange_max_files,
797 "cochange_min_count": args.cochange_min_count,
798 "cochange_min_jaccard": args.cochange_min_jaccard,
799 "cochange_default_excludes": not args.no_default_cochange_excludes,
800 "cochange_excludes": cochange_excludes,
801 "author_default_excludes": not args.no_default_author_excludes,
802 "author_exclude_regexes": author_exclude_regexes,
803 "community_top_owners": args.community_top_owners,
804 },
805 "orphaned_sensitive_code": orphaned_sensitive_code,
806 "hidden_owners": hidden_owners,
807 "bus_factor_hotspots": bus_factor_hotspots,
808 "stats": {
809 "commits": total_commits_included,
810 "commits_seen": total_commits_seen,
811 "commits_excluded_identities": commits_excluded_identities,
812 "commits_excluded_merges": commits_excluded_merges,
813 "edges": total_edges,
814 "people": len(people),
815 "files": len(files),
816 "cochange_pairs_total": len(cochange_counts) if not args.no_cochange else 0,
817 "cochange_edges": len(cochange_rows) if not args.no_cochange else 0,
818 "cochange_commits_used": cochange_commits_used if not args.no_cochange else 0,
819 "cochange_commits_skipped": cochange_commits_skipped if not args.no_cochange else 0,
820 "cochange_commits_filtered": cochange_commits_filtered if not args.no_cochange else 0,
821 "cochange_files_excluded": cochange_files_excluded if not args.no_cochange else 0,
822 },
823 }
824
825 with (out_dir / "summary.json").open("w", encoding="utf-8") as handle:
826 json.dump(summary, handle, indent=2)
827
828 if args.communities or args.graphml:
829 try:
830 import networkx as nx
831 from networkx.algorithms import bipartite
832 except ImportError:
833 raise RuntimeError(
834 "networkx is required for communities/graphml output. Install with: pip install networkx"
835 )
836 else:
837 graph_bipartite = None
838 graph_cochange = None
839 person_nodes = set()
840 file_nodes = set()
841 community_index: dict[str, int] = {}
842 community_metadata: list[dict[str, object]] = []
843
844 if args.graphml or (args.communities and (args.no_cochange or not cochange_rows)):
845 graph_bipartite = nx.Graph()
846 for (email, path), edge in edges.items():
847 if int(edge["touches"]) < args.min_touches:
848 continue
849 graph_bipartite.add_node(email, node_type="person")
850 graph_bipartite.add_node(path, node_type="file")
851 graph_bipartite.add_edge(email, path, weight=float(edge["touches"]))
852 person_nodes.add(email)
853 file_nodes.add(path)
854
855 if not args.no_cochange and cochange_rows:
856 graph_cochange = nx.Graph()
857 for file_a, file_b, count, jaccard in cochange_rows:
858 graph_cochange.add_edge(
859 file_a,
860 file_b,
861 weight=float(jaccard),
862 count=int(count),
863 )
864
865 if args.communities:
866 communities_result = None
867 if graph_cochange is not None:
868 communities_result = list(
869 nx.algorithms.community.greedy_modularity_communities(
870 graph_cochange, weight="weight"
871 )
872 )
873 elif graph_bipartite is not None and file_nodes:
874 projected = bipartite.weighted_projected_graph(graph_bipartite, file_nodes)
875 communities_result = list(
876 nx.algorithms.community.greedy_modularity_communities(projected)
877 )
878
879 if communities_result is not None:
880 serialized = []
881 for idx, community in enumerate(communities_result, start=1):
882 files_list = sorted(community)
883 owners = compute_community_owners(
884 files_list,
885 people,
886 file_people_touches,
887 file_people_recency,
888 file_people_sensitive,
889 args.community_top_owners,
890 )
891 for path in files_list:
892 community_index[path] = idx
893 entry = {
894 "id": idx,
895 "size": len(files_list),
896 "files": files_list[: args.max_community_files],
897 "maintainers": owners["top_maintainers"],
898 "bus_factor": owners["bus_factor"],
899 "owner_count": owners["owner_count"],
900 "totals": owners["totals"],
901 }
902 serialized.append(entry)
903 metadata = dict(entry)
904 metadata.pop("files", None)
905 community_metadata.append(metadata)
906 with (out_dir / "communities.json").open("w", encoding="utf-8") as handle:
907 json.dump(serialized, handle, indent=2)
908
909 if args.communities:
910 for node, community_id in community_index.items():
911 if graph_cochange is not None and node in graph_cochange:
912 graph_cochange.nodes[node]["community_id"] = community_id
913 if graph_bipartite is not None and node in graph_bipartite:
914 graph_bipartite.nodes[node]["community_id"] = community_id
915
916 graph_for_json = graph_cochange or graph_bipartite
917 if graph_for_json is not None:
918 try:
919 from networkx.readwrite import json_graph
920 except ImportError:
921 pass
922 else:
923 data = json_graph.node_link_data(graph_for_json, edges="edges")
924 data.setdefault("graph", {})
925 data["graph"]["community_maintainers"] = community_metadata
926 json_name = (
927 "cochange.graph.json"
928 if graph_for_json is graph_cochange
929 else "ownership.graph.json"
930 )
931 with (out_dir / json_name).open("w", encoding="utf-8") as handle:
932 json.dump(data, handle, indent=2)
933
934 if args.graphml:
935 if graph_bipartite is not None:
936 nx.write_graphml(graph_bipartite, out_dir / "ownership.graphml")
937 if graph_cochange is not None:
938 nx.write_graphml(graph_cochange, out_dir / "cochange.graphml")
939
940 return out_dir
941
942
943def main() -> int:
944 args = parse_args()
945 try:
946 out_dir = build_ownership_map(args)
947 except RuntimeError as exc:
948 print(str(exc), file=sys.stderr)
949 return 1
950
951 print(f"Ownership map written to {out_dir}")
952 return 0
953
954
955if __name__ == "__main__":
956 raise SystemExit(main())