sourcecode 1.30.13__py3-none-any.whl → 1.30.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.30.13"
3
+ __version__ = "1.30.15"
sourcecode/cli.py CHANGED
@@ -1622,6 +1622,11 @@ def prepare_context_cmd(
1622
1622
  "--symptom",
1623
1623
  help="(fix-bug) Keyword hint for the bug: boosts matching files and surfaces related code notes.",
1624
1624
  ),
1625
+ format: Optional[str] = typer.Option(
1626
+ None,
1627
+ "--format",
1628
+ help="Output format: json (default) | github-comment (Markdown PR comment for review-pr task)",
1629
+ ),
1625
1630
  ) -> None:
1626
1631
  """Task-specific context for AI coding agents.
1627
1632
 
@@ -1895,7 +1900,12 @@ def prepare_context_cmd(
1895
1900
  if llm_prompt:
1896
1901
  out["llm_prompt"] = builder.render_prompt(output)
1897
1902
 
1898
- _pc_content = json.dumps(out, indent=2, ensure_ascii=False)
1903
+ if format == "github-comment" and task == "review-pr":
1904
+ from sourcecode.pr_comment_renderer import render_github_comment
1905
+ _pc_content = render_github_comment(out)
1906
+ else:
1907
+ _pc_content = json.dumps(out, indent=2, ensure_ascii=False)
1908
+
1899
1909
  if output_path is not None:
1900
1910
  output_path.write_text(_pc_content, encoding="utf-8")
1901
1911
  else:
@@ -33,43 +33,57 @@ _METHOD_NAME_RE = re.compile(
33
33
  r'(\w+)\s*\(',
34
34
  )
35
35
 
36
- # Runtime signal patterns: (compiled_regex, note_text)
37
- # Only signals with explicit code evidence — no inference.
38
- # Three categories: condition | branch | async
39
- _RUNTIME_SIGNALS: list[tuple[re.Pattern, str]] = [
36
+ # Runtime signal patterns: (compiled_regex, note_text, epistemic_level)
37
+ # epistemic_level follows the 4-value contract:
38
+ # STRUCTURAL SIGNAL — annotation or type-system evidence directly observed in source
39
+ # INFERRED (LOW CONFIDENCE) heuristic code-pattern match; no full structural proof
40
+ _RUNTIME_SIGNALS: list[tuple[re.Pattern, str, str]] = [
40
41
  # ── Conditional / auth guards ─────────────────────────────────────────────
41
42
  (re.compile(r'@PreAuthorize|@Secured|@RolesAllowed', re.IGNORECASE),
42
- "condition: authorization check present (@PreAuthorize / @Secured)"),
43
+ "condition: authorization check present (@PreAuthorize / @Secured)",
44
+ "STRUCTURAL SIGNAL"),
43
45
  (re.compile(r'isAuthenticated\(\)|hasRole\(|hasAuthority\(|SecurityContextHolder', re.IGNORECASE),
44
- "condition: reads authentication context"),
46
+ "condition: reads authentication context",
47
+ "STRUCTURAL SIGNAL"),
45
48
  (re.compile(r'featureFlag|FeatureToggle|\.isEnabled\s*\(|\.isActive\s*\(', re.IGNORECASE),
46
- "condition: feature flag gates execution"),
49
+ "condition: feature flag gates execution",
50
+ "INFERRED (LOW CONFIDENCE)"),
47
51
  # Null/empty guard with early return — matches if (...null/empty...) return/throw on same line
48
52
  (re.compile(r'if\s*\([^)]*(?:==\s*null|!=\s*null|isEmpty\s*\(\)|isBlank\s*\(\))[^)]*\)'
49
53
  r'\s*(?:\{?\s*)?(?:return|throw)\b', re.IGNORECASE),
50
- "condition: null/empty guard with early return"),
54
+ "condition: null/empty guard with early return",
55
+ "STRUCTURAL SIGNAL"),
51
56
 
52
57
  # ── Optional execution / branching ────────────────────────────────────────
53
58
  (re.compile(r'@Cacheable|@CacheEvict|@CachePut', re.IGNORECASE),
54
- "branch: Spring cache may short-circuit downstream call"),
59
+ "branch: Spring cache annotation present downstream call may be short-circuited",
60
+ "STRUCTURAL SIGNAL"),
55
61
  (re.compile(r'\.getIfPresent\s*\(|cache\.get\s*\(|cacheManager\.', re.IGNORECASE),
56
- "branch: manual cache lookup may short-circuit"),
62
+ "branch: manual cache lookup detected — downstream call may be short-circuited",
63
+ "INFERRED (LOW CONFIDENCE)"),
57
64
  (re.compile(r'Optional\s*<|\.orElseThrow\s*\(|\.orElseGet\s*\(|\.orElse\s*\(', re.IGNORECASE),
58
- "branch: result may be absent (Optional)"),
65
+ "branch: Optional type in use — result may be absent",
66
+ "STRUCTURAL SIGNAL"),
59
67
 
60
68
  # ── Async / side effects ──────────────────────────────────────────────────
61
69
  (re.compile(r'@Async\b'),
62
- "async: runs in separate thread (@Async)"),
70
+ "async: @Async annotation present — runs in separate thread",
71
+ "STRUCTURAL SIGNAL"),
63
72
  (re.compile(r'CompletableFuture|\.supplyAsync\s*\(|\.runAsync\s*\('),
64
- "async: non-blocking future-based execution"),
73
+ "async: CompletableFuture detected — non-blocking execution",
74
+ "STRUCTURAL SIGNAL"),
65
75
  (re.compile(r'\basync\s+def\b|\bawait\b', re.IGNORECASE),
66
- "async: non-blocking (async/await)"),
76
+ "async: async/await detected — non-blocking execution",
77
+ "STRUCTURAL SIGNAL"),
67
78
  (re.compile(r'publishEvent\s*\(|applicationEventPublisher|eventPublisher\.', re.IGNORECASE),
68
- "async: Spring application event emitted"),
79
+ "async: Spring application event emitted",
80
+ "STRUCTURAL SIGNAL"),
69
81
  (re.compile(r'kafkaTemplate\.|KafkaProducer|@KafkaListener', re.IGNORECASE),
70
- "async: Kafka message produced"),
82
+ "async: Kafka producer detected",
83
+ "STRUCTURAL SIGNAL"),
71
84
  (re.compile(r'rabbitTemplate\.|amqpTemplate\.|@RabbitListener', re.IGNORECASE),
72
- "async: RabbitMQ message sent"),
85
+ "async: RabbitMQ producer detected",
86
+ "STRUCTURAL SIGNAL"),
73
87
  ]
74
88
 
75
89
 
@@ -98,18 +112,19 @@ def _read_safe(root: Path, rel_path: str) -> str:
98
112
  return ""
99
113
 
100
114
 
101
- def _collect_runtime_notes(content: str, lang: str) -> list[str]:
115
+ def _collect_runtime_notes(content: str, lang: str) -> list[dict]:
102
116
  """Scan comment-stripped content for explicit runtime behavior signals.
103
117
 
104
118
  Returns only notes backed by a direct code pattern match.
119
+ Each entry: {note: str, epistemic_level: str}.
105
120
  Returns [] when no signals are found.
106
121
  """
107
122
  clean = _strip_comments(content, lang)
108
- notes: list[str] = []
123
+ notes: list[dict] = []
109
124
  seen: set[str] = set()
110
- for pattern, note in _RUNTIME_SIGNALS:
125
+ for pattern, note, epistemic_level in _RUNTIME_SIGNALS:
111
126
  if note not in seen and pattern.search(clean):
112
- notes.append(note)
127
+ notes.append({"note": note, "epistemic_level": epistemic_level})
113
128
  seen.add(note)
114
129
  return notes
115
130
 
@@ -348,8 +363,11 @@ def analyze_execution_paths(
348
363
  "entry_point": {"step": entry_point_str, "notes": entry_notes},
349
364
  "path": path_items,
350
365
  "end_state": _detect_end_state([item["step"] for item in path_items]),
366
+ "end_state_epistemic_level": "INFERRED (LOW CONFIDENCE)",
351
367
  })
352
368
 
369
+ return result
370
+
353
371
 
354
372
  # ── Behavioral impact helpers ─────────────────────────────────────────────────
355
373
 
@@ -363,9 +381,13 @@ def _domain_from_class(class_name: str) -> str:
363
381
  return re.sub(r"(?<=[a-z])(?=[A-Z])", " ", stripped).strip().lower()
364
382
 
365
383
 
366
- def _impact_item(statement: str, support: str, certainty: str) -> dict:
367
- truth_level = "observed" if certainty == "high" else "inferred"
368
- return {"statement": statement, "support": support, "certainty": certainty, "truth_level": truth_level}
384
+ def _impact_item(statement: str, support: str, certainty: str, *, epistemic_level: Optional[str] = None) -> dict:
385
+ if epistemic_level is None:
386
+ if certainty in ("high", "medium"):
387
+ epistemic_level = "STRUCTURAL SIGNAL"
388
+ else:
389
+ epistemic_level = "INFERRED (LOW CONFIDENCE)"
390
+ return {"statement": statement, "support": support, "epistemic_level": epistemic_level}
369
391
 
370
392
 
371
393
  def _impact_descriptions(
@@ -381,27 +403,27 @@ def _impact_descriptions(
381
403
 
382
404
  if changed_type in _REPO_ARTIFACT_TYPES:
383
405
  items.append(_impact_item(
384
- f"{domain} persistence may be affected (inferred from path)" if domain else "persistence may be affected (inferred from path)",
385
- f"{changed_class} classified as repository from path",
406
+ f"{domain} persistence layer modified" if domain else "persistence layer modified",
407
+ f"{changed_class} classified as {changed_type} (artifact_type evidence)",
386
408
  certainty,
387
409
  ))
388
410
  elif changed_type in _SERVICE_ARTIFACT_TYPES:
389
411
  if end_state == "DB write":
390
412
  items.append(_impact_item(
391
- f"{domain} persistence may be affected (repository with DB write in path)" if domain else "persistence may be affected (repository with DB write in path)",
392
- f"{changed_class} delegates to repository with DB write",
413
+ f"{domain} service layer: DB write in call path" if domain else "service layer: DB write in call path",
414
+ f"{changed_class} delegates to repository with DB write (code evidence)",
393
415
  certainty,
394
416
  ))
395
417
  else:
396
418
  items.append(_impact_item(
397
- f"{domain} behavior may change" if domain else "behavior may change",
398
- f"{changed_class} is a service in path",
419
+ f"{domain} service layer modified" if domain else "service layer modified",
420
+ f"{changed_class} is a service (artifact_type={changed_type})",
399
421
  certainty,
400
422
  ))
401
423
  else:
402
424
  items.append(_impact_item(
403
- f"{domain} behavior may change" if domain else "behavior may change",
404
- f"{changed_class} is in path",
425
+ f"{domain} component modified" if domain else "component modified",
426
+ f"{changed_class} found in traced call path (artifact_type={changed_type})",
405
427
  certainty,
406
428
  ))
407
429
 
@@ -440,15 +462,16 @@ def _impact_descriptions_for_controller(
440
462
  domain = d
441
463
  break
442
464
  items.append(_impact_item(
443
- f"{domain} persistence may be affected (repository with DB write in path)" if domain else "data persistence may be affected (repository with DB write in path)",
444
- "repository with DB write detected in path",
465
+ f"{domain} persistence layer: repository with DB write in call path" if domain else "persistence layer: repository with DB write in call path",
466
+ "repository with DB write detected in traced call path (code evidence)",
445
467
  certainty,
446
468
  ))
447
469
  else:
448
470
  items.append(_impact_item(
449
- "request handler behavior may change",
450
471
  "controller entry point modified",
472
+ "controller entry point is in changed files (direct diff evidence)",
451
473
  certainty,
474
+ epistemic_level="FACT",
452
475
  ))
453
476
 
454
477
  if re.search(r"@PreAuthorize|@Secured|@RolesAllowed|hasRole\(|isAuthenticated", ctrl_clean, re.IGNORECASE):
@@ -541,6 +564,7 @@ def analyze_behavioral_impact(
541
564
  "affected_path": affected_path,
542
565
  "impact": _impact_descriptions_for_controller(affected_path, end_state, ctrl_clean, evidence_level),
543
566
  "end_state": end_state,
567
+ "end_state_epistemic_level": "INFERRED (LOW CONFIDENCE)",
544
568
  "confidence": confidence,
545
569
  "evidence_level": evidence_level,
546
570
  "trace": trace,
@@ -638,6 +662,7 @@ def analyze_behavioral_impact(
638
662
  "affected_path": affected_path,
639
663
  "impact": _impact_descriptions(changed_class, changed_type, end_state, ctrl_clean, evidence_level),
640
664
  "end_state": end_state,
665
+ "end_state_epistemic_level": "INFERRED (LOW CONFIDENCE)",
641
666
  "confidence": confidence,
642
667
  "evidence_level": evidence_level,
643
668
  "trace": trace,
@@ -0,0 +1,369 @@
1
+ """pr_comment_renderer.py — Renders review-pr output as a GitHub PR comment.
2
+
3
+ Mandatory 5-section format:
4
+ 1. PR Change Summary — FACT only
5
+ 2. Impacted Execution Flow — STRUCTURAL SIGNAL or FACT only, per-step evidence
6
+ 3. Review Priority Order — ranked by evidence-backed impact
7
+ 4. Risk / Impact Signals — labeled signals, never "risk" as conclusion
8
+ 5. Review Guidance — uncertain items, evidence gaps
9
+
10
+ Contract:
11
+ - Every statement carries an explicit epistemic label.
12
+ - No speculation without label.
13
+ - No hidden confidence blending.
14
+ - Sections omitted when evidence is insufficient.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ _BADGE: dict[str, str] = {
22
+ "FACT": "`FACT`",
23
+ "STRUCTURAL SIGNAL": "`STRUCTURAL SIGNAL`",
24
+ "INFERRED (LOW CONFIDENCE)": "`INFERRED (LOW CONFIDENCE)`",
25
+ "OMITTED": "`OMITTED`",
26
+ }
27
+
28
+ _STRUCTURAL_EVIDENCE = frozenset({"direct_injection", "direct_call"})
29
+
30
+ _MAX_PRIORITY_FILES = 10
31
+ _MAX_FLOW_PATHS = 3
32
+ _MAX_SIGNALS = 12
33
+ _MAX_UNCERTAIN = 6
34
+
35
+
36
+ def _badge(level: str) -> str:
37
+ return _BADGE.get(level, f"`{level}`")
38
+
39
+
40
+ def _short(path: str) -> str:
41
+ parts = Path(path).parts
42
+ return "/".join(parts[-2:]) if len(parts) > 2 else path
43
+
44
+
45
+ def _evidence_to_epistemic(evidence_level: str) -> str:
46
+ if evidence_level in _STRUCTURAL_EVIDENCE:
47
+ return "STRUCTURAL SIGNAL"
48
+ if evidence_level == "heuristic_only":
49
+ return "INFERRED (LOW CONFIDENCE)"
50
+ return "OMITTED"
51
+
52
+
53
+ # ── Section 1: PR Change Summary ──────────────────────────────────────────────
54
+
55
+ def _section_change_summary(out: dict) -> str:
56
+ runtime: list[dict] = out.get("runtime_changes", [])
57
+ build: dict = out.get("build_changes", {})
58
+ build_files: list[str] = build.get("files", []) if build else []
59
+ base = out.get("base_ref") or out.get("since") or "HEAD~1"
60
+
61
+ committed: list[dict] = out.get("committed_changes", [])
62
+ uncommitted: list[dict] = out.get("uncommitted_changes", [])
63
+
64
+ lines = [
65
+ "### 1. PR Change Summary",
66
+ "",
67
+ f"**Diff:** `git diff {base}...HEAD` &nbsp;|&nbsp; "
68
+ f"**Committed:** {len(committed)} files {_badge('FACT')} &nbsp;|&nbsp; "
69
+ f"**Unstaged:** {len(uncommitted)} files {_badge('FACT')}",
70
+ "",
71
+ ]
72
+
73
+ if uncommitted:
74
+ lines.append(
75
+ f"> **Note:** {len(uncommitted)} file(s) in working tree not committed — "
76
+ "present in analysis, absent from PR diff."
77
+ )
78
+ lines.append("")
79
+
80
+ if not runtime and not build_files:
81
+ lines.append("*No runtime files changed.*")
82
+ return "\n".join(lines)
83
+
84
+ lines += ["| File | Artifact Type | Role | Source |",
85
+ "|------|--------------|------|--------|"]
86
+
87
+ for rc in runtime:
88
+ path = rc.get("path", "")
89
+ atype = rc.get("artifact_type", "")
90
+ effect = rc.get("change_effect", {})
91
+ stmt = effect.get("statement", "") if isinstance(effect, dict) else ""
92
+ diff_src = rc.get("diff_source", "committed")
93
+ src_label = "committed" if "committed" in diff_src else "unstaged"
94
+ lines.append(f"| `{_short(path)}` | `{atype}` | {stmt} | {src_label} |")
95
+
96
+ for bf in build_files:
97
+ lines.append(f"| `{_short(bf)}` | `build_manifest` | build / dependency configuration | committed |")
98
+
99
+ lines.append("")
100
+ lines.append(f"*All file entries above are {_badge('FACT')} — directly observed in diff.*")
101
+ return "\n".join(lines)
102
+
103
+
104
+ # ── Section 2: Impacted Execution Flow ────────────────────────────────────────
105
+
106
+ def _section_execution_flow(out: dict) -> str:
107
+ behavioral: list[dict] = out.get("behavioral_impact", [])
108
+
109
+ # Only STRUCTURAL SIGNAL or FACT paths — exclude heuristic_only
110
+ strong = [
111
+ bi for bi in behavioral
112
+ if bi.get("evidence_level", "none") in _STRUCTURAL_EVIDENCE
113
+ ]
114
+ weak = [
115
+ bi for bi in behavioral
116
+ if bi.get("evidence_level", "none") == "heuristic_only"
117
+ ]
118
+
119
+ lines = ["### 2. Impacted Execution Flow", ""]
120
+
121
+ if not strong and not weak:
122
+ lines.append("*No traceable execution flow — insufficient structural evidence.*")
123
+ return "\n".join(lines)
124
+
125
+ if strong:
126
+ for bi in strong[:_MAX_FLOW_PATHS]:
127
+ ev = bi.get("evidence_level", "")
128
+ ep = _evidence_to_epistemic(ev)
129
+ entry = bi.get("entry_point", "")
130
+ affected: list[str] = bi.get("affected_path", [])
131
+ end_state = bi.get("end_state", "")
132
+ trace: list[str] = bi.get("trace", [])
133
+
134
+ end_state_ep = bi.get("end_state_epistemic_level", "INFERRED (LOW CONFIDENCE)")
135
+ steps = [f"`{entry}`"] + [f"`{s}`" for s in affected]
136
+ if end_state:
137
+ steps.append(f"**{end_state}** {_badge(end_state_ep)}")
138
+
139
+ lines.append(f"**Flow:** {' → '.join(steps)}")
140
+ lines.append(f"**Evidence:** {_badge(ep)}")
141
+
142
+ if trace:
143
+ for t in trace:
144
+ lines.append(f"- {t}")
145
+ lines.append("")
146
+
147
+ if weak:
148
+ lines.append(f"**Excluded from flow** ({len(weak)} path(s) — evidence insufficient):")
149
+ for bi in weak[:3]:
150
+ entry = bi.get("entry_point", "")
151
+ lines.append(
152
+ f"- `{entry}` path — {_badge('INFERRED (LOW CONFIDENCE)')}: "
153
+ "class reference detected but no injection or call evidence"
154
+ )
155
+ lines.append("")
156
+
157
+ return "\n".join(lines).rstrip()
158
+
159
+
160
+ # ── Section 3: Review Priority Order ─────────────────────────────────────────
161
+
162
+ def _section_priority_order(out: dict) -> str:
163
+ order: list[str] = out.get("suggested_review_order", []) or out.get("review_hotspots", [])
164
+ runtime: list[dict] = out.get("runtime_changes", [])
165
+
166
+ cls_map: dict[str, dict] = {rc["path"]: rc for rc in runtime}
167
+
168
+ lines = ["### 3. Review Priority Order", ""]
169
+
170
+ if not order:
171
+ lines.append("*No priority order available — diff scope too narrow to rank.*")
172
+ return "\n".join(lines)
173
+
174
+ for i, path in enumerate(order[:_MAX_PRIORITY_FILES], 1):
175
+ rc = cls_map.get(path, {})
176
+ effect = rc.get("change_effect", {})
177
+ stmt = effect.get("statement", "application source") if isinstance(effect, dict) else "application source"
178
+ ep = effect.get("epistemic_level", "STRUCTURAL SIGNAL") if isinstance(effect, dict) else "STRUCTURAL SIGNAL"
179
+ atype = rc.get("artifact_type", "source")
180
+ lines.append(f"{i}. `{_short(path)}` — {_badge(ep)}: {atype} — {stmt}")
181
+
182
+ return "\n".join(lines)
183
+
184
+
185
+ # ── Section 4: Risk / Impact Signals ─────────────────────────────────────────
186
+
187
+ def _section_signals(out: dict) -> str:
188
+ lines = ["### 4. Risk / Impact Signals", ""]
189
+ signals: list[str] = []
190
+
191
+ # Security files in diff — FACT (files are in diff)
192
+ sec = out.get("security_impact", {})
193
+ if sec:
194
+ files: list[str] = sec.get("affected_resources", [])
195
+ ep = sec.get("epistemic_level", "STRUCTURAL SIGNAL")
196
+ basis = sec.get("basis", "")
197
+ if files:
198
+ names = ", ".join(f"`{_short(f)}`" for f in files)
199
+ signals.append(f"{_badge(ep)}: security-classified files changed — {names}")
200
+ if basis:
201
+ signals.append(f" *Classification basis: {basis}*")
202
+ risk_ep = sec.get("risk_epistemic_level", "INFERRED (LOW CONFIDENCE)")
203
+ signals.append(
204
+ f"{_badge(risk_ep)}: authentication / access-control behavior may be affected — "
205
+ "inspect changed security files to confirm"
206
+ )
207
+
208
+ # Transactional boundary — STRUCTURAL SIGNAL if @Transactional detected, else INFERRED
209
+ txn = out.get("transactional_impact", {})
210
+ if txn:
211
+ files_t: list[str] = txn.get("affected_transactions", [])
212
+ ep_t = txn.get("epistemic_level", "STRUCTURAL SIGNAL")
213
+ basis_t = txn.get("basis", "")
214
+ risk_ep_t = txn.get("risk_epistemic_level", "INFERRED (LOW CONFIDENCE)")
215
+ if files_t:
216
+ names_t = ", ".join(f"`{_short(f)}`" for f in files_t)
217
+ signals.append(f"{_badge(ep_t)}: service/business-logic files changed — {names_t}")
218
+ if basis_t:
219
+ signals.append(f" *Classification basis: {basis_t}*")
220
+ signals.append(
221
+ f"{_badge(risk_ep_t)}: transactional boundary may be affected — "
222
+ "@Transactional scope not confirmed by AST"
223
+ )
224
+
225
+ # Configuration files — FACT
226
+ cfg = out.get("configuration_impact", {})
227
+ if cfg:
228
+ cfg_files: list[str] = cfg.get("changed_configs", [])
229
+ if cfg_files:
230
+ names_c = ", ".join(f"`{_short(f)}`" for f in cfg_files)
231
+ signals.append(f"{_badge('FACT')}: configuration files modified — {names_c}")
232
+
233
+ # Behavioral impact signals — use each item's epistemic_level
234
+ behavioral: list[dict] = out.get("behavioral_impact", [])
235
+ for bi in behavioral[:_MAX_FLOW_PATHS]:
236
+ for item in bi.get("impact", []):
237
+ stmt = item.get("statement", "")
238
+ ep_i = item.get("epistemic_level", "INFERRED (LOW CONFIDENCE)")
239
+ support = item.get("support", "")
240
+ if stmt:
241
+ signals.append(f"{_badge(ep_i)}: {stmt}")
242
+ if support:
243
+ signals.append(f" *{support}*")
244
+
245
+ # Runtime notes from execution paths
246
+ exec_paths: list[dict] = out.get("execution_paths", [])
247
+ seen_notes: set[str] = set()
248
+ for ep_dict in exec_paths:
249
+ entry_notes: list = ep_dict.get("entry_point", {}).get("notes", [])
250
+ path_notes: list = [n for item in ep_dict.get("path", []) for n in item.get("notes", [])]
251
+ for note_obj in entry_notes + path_notes:
252
+ if isinstance(note_obj, dict):
253
+ note = note_obj.get("note", "")
254
+ n_ep = note_obj.get("epistemic_level", "STRUCTURAL SIGNAL")
255
+ if note and note not in seen_notes:
256
+ signals.append(f"{_badge(n_ep)}: {note}")
257
+ seen_notes.add(note)
258
+
259
+ if not signals:
260
+ lines.append("*No impact signals detected — insufficient evidence.*")
261
+ return "\n".join(lines)
262
+
263
+ lines.extend(signals[:_MAX_SIGNALS])
264
+ return "\n".join(lines)
265
+
266
+
267
+ # ── Section 5: Review Guidance ────────────────────────────────────────────────
268
+
269
+ def _section_guidance(out: dict) -> str:
270
+ lines = ["### 5. Review Guidance", ""]
271
+
272
+ # Inspect first — top priority files
273
+ order: list[str] = out.get("suggested_review_order", []) or out.get("review_hotspots", [])
274
+ runtime: list[dict] = out.get("runtime_changes", [])
275
+ cls_map: dict[str, dict] = {rc["path"]: rc for rc in runtime}
276
+
277
+ if order:
278
+ lines.append("**Inspect first:**")
279
+ for path in order[:3]:
280
+ rc = cls_map.get(path, {})
281
+ effect = rc.get("change_effect", {})
282
+ stmt = effect.get("statement", "") if isinstance(effect, dict) else ""
283
+ ep = effect.get("epistemic_level", "STRUCTURAL SIGNAL") if isinstance(effect, dict) else "STRUCTURAL SIGNAL"
284
+ label = f" — {stmt}" if stmt else ""
285
+ lines.append(f"- `{_short(path)}`{label} {_badge(ep)}")
286
+ lines.append("")
287
+
288
+ # Uncertain — heuristic-only behavioral paths
289
+ behavioral: list[dict] = out.get("behavioral_impact", [])
290
+ weak_paths = [
291
+ bi for bi in behavioral
292
+ if bi.get("evidence_level", "none") == "heuristic_only"
293
+ ]
294
+ if weak_paths:
295
+ lines.append("**Uncertain (heuristic-only — verify manually):**")
296
+ for bi in weak_paths[:_MAX_UNCERTAIN]:
297
+ entry = bi.get("entry_point", "")
298
+ affected: list[str] = bi.get("affected_path", [])
299
+ path_str = " → ".join(affected) if affected else "unknown"
300
+ lines.append(
301
+ f"- `{entry}` → {path_str} "
302
+ f"{_badge('INFERRED (LOW CONFIDENCE)')}: class reference only, no injection/call proof"
303
+ )
304
+ lines.append("")
305
+
306
+ # Evidence gaps
307
+ gaps: list[str] = out.get("gaps", [])
308
+ cov = out.get("test_coverage_risk", {})
309
+ test_files: list[str] = cov.get("changed_files_without_tests", [])
310
+ test_basis = cov.get("basis", "")
311
+ cov_ep = cov.get("epistemic_level", "INFERRED (LOW CONFIDENCE)")
312
+
313
+ gap_items: list[str] = list(gaps)
314
+ if test_files:
315
+ shown = test_files[:5]
316
+ omitted = len(test_files) - len(shown)
317
+ names = ", ".join(f"`{_short(f)}`" for f in shown)
318
+ suffix = f" +{omitted} more" if omitted else ""
319
+ gap_items.append(
320
+ f"changed files without test coverage: {names}{suffix} "
321
+ f"{_badge(cov_ep)}"
322
+ + (f" — {test_basis}" if test_basis else "")
323
+ )
324
+
325
+ if gap_items:
326
+ lines.append("**Evidence gaps / missing coverage:**")
327
+ for g in gap_items:
328
+ lines.append(f"- {g}")
329
+ else:
330
+ lines.append("*No evidence gaps detected.*")
331
+
332
+ return "\n".join(lines)
333
+
334
+
335
+ # ── Entry point ───────────────────────────────────────────────────────────────
336
+
337
+ def render_github_comment(out: dict) -> str:
338
+ """Render review-pr TaskOutput dict as a GitHub PR comment (Markdown).
339
+
340
+ 5 mandatory sections. Every statement carries an explicit epistemic label.
341
+ """
342
+ base = out.get("base_ref") or out.get("since") or "HEAD~1"
343
+ header = (
344
+ "## sourcecode PR Analysis\n\n"
345
+ f"**Base:** `{base}` &nbsp;|&nbsp; "
346
+ f"**Review type:** pull request &nbsp;|&nbsp; "
347
+ f"**Epistemic contract:** FACT · STRUCTURAL SIGNAL · INFERRED (LOW CONFIDENCE) · OMITTED"
348
+ )
349
+
350
+ sections = [
351
+ header,
352
+ _section_change_summary(out),
353
+ _section_execution_flow(out),
354
+ _section_priority_order(out),
355
+ _section_signals(out),
356
+ _section_guidance(out),
357
+ ]
358
+
359
+ footer = (
360
+ "*Generated by [sourcecode](https://github.com/sourcecode-ai/sourcecode). "
361
+ "Labels: "
362
+ "`FACT` = diff/AST evidence · "
363
+ "`STRUCTURAL SIGNAL` = annotation/import/wiring · "
364
+ "`INFERRED (LOW CONFIDENCE)` = heuristic pattern · "
365
+ "`OMITTED` = insufficient evidence.*"
366
+ )
367
+ sections.append(footer)
368
+
369
+ return "\n\n---\n\n".join(s for s in sections if s.strip())