devtime-ei 0.1.0__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.
Files changed (54) hide show
  1. devtime/__init__.py +9 -0
  2. devtime/ai/__init__.py +0 -0
  3. devtime/ai/local.py +11 -0
  4. devtime/ai/prompts.py +24 -0
  5. devtime/ai/providers.py +41 -0
  6. devtime/assets/devtimeignore.starter +23 -0
  7. devtime/cli.py +374 -0
  8. devtime/config.py +67 -0
  9. devtime/db/__init__.py +0 -0
  10. devtime/db/connection.py +16 -0
  11. devtime/db/migrations.py +114 -0
  12. devtime/db/repository.py +351 -0
  13. devtime/db/schema.sql +145 -0
  14. devtime/fixtures/__init__.py +0 -0
  15. devtime/fixtures/assertions.py +51 -0
  16. devtime/fixtures/loader.py +52 -0
  17. devtime/fixtures/runner.py +73 -0
  18. devtime/intelligence/__init__.py +0 -0
  19. devtime/intelligence/claims.py +235 -0
  20. devtime/intelligence/concepts.py +483 -0
  21. devtime/intelligence/context_pack.py +276 -0
  22. devtime/intelligence/evidence.py +127 -0
  23. devtime/intelligence/lineage.py +21 -0
  24. devtime/intelligence/risk.py +267 -0
  25. devtime/intelligence/scoring.py +99 -0
  26. devtime/mcp/__init__.py +0 -0
  27. devtime/mcp/schemas.py +39 -0
  28. devtime/mcp/server.py +35 -0
  29. devtime/mcp/tools.py +90 -0
  30. devtime/output/__init__.py +0 -0
  31. devtime/output/json_export.py +50 -0
  32. devtime/output/markdown.py +50 -0
  33. devtime/output/terminal.py +208 -0
  34. devtime/paths.py +40 -0
  35. devtime/privacy.py +96 -0
  36. devtime/scanner/__init__.py +0 -0
  37. devtime/scanner/extractors/__init__.py +0 -0
  38. devtime/scanner/extractors/base.py +83 -0
  39. devtime/scanner/extractors/config_files.py +41 -0
  40. devtime/scanner/extractors/docs.py +35 -0
  41. devtime/scanner/extractors/nextjs.py +82 -0
  42. devtime/scanner/extractors/python.py +81 -0
  43. devtime/scanner/extractors/tests.py +61 -0
  44. devtime/scanner/extractors/typescript.py +99 -0
  45. devtime/scanner/file_walker.py +96 -0
  46. devtime/scanner/ignore.py +96 -0
  47. devtime/scanner/language.py +36 -0
  48. devtime/scanner/signals.py +252 -0
  49. devtime_ei-0.1.0.dist-info/METADATA +289 -0
  50. devtime_ei-0.1.0.dist-info/RECORD +54 -0
  51. devtime_ei-0.1.0.dist-info/WHEEL +5 -0
  52. devtime_ei-0.1.0.dist-info/entry_points.txt +2 -0
  53. devtime_ei-0.1.0.dist-info/licenses/LICENSE +201 -0
  54. devtime_ei-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,276 @@
1
+ """Context Packs (Builder Edition, Chapter 14; Trust Repair v0.0.6).
2
+
3
+ Context Packs are governed repository memory for humans and AI agents. Trust Repair
4
+ makes them: (1) refuse to be authoritative when concept evidence is weak, (2) cap
5
+ and rank recommended tests, and (3) attach a reason to every recommended test.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import posixpath
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+
14
+ from devtime.intelligence.claims import ConceptIntelligence
15
+
16
+ MODES = ("overview", "risk", "implementation", "testing", "onboarding", "security")
17
+ MAX_TESTS = 8
18
+
19
+ # Domain terms used to rank/justify test relevance per concept.
20
+ DOMAIN_TERMS = {
21
+ "Billing Webhooks": ("stripe", "paypal", "billing", "payment", "invoice", "checkout", "webhook"),
22
+ "Authentication": ("auth", "jwt", "token", "login", "session", "oauth", "password"),
23
+ "Background Jobs": ("job", "queue", "worker", "task", "celery", "bullmq", "cron"),
24
+ "Data Export": ("export", "download", "csv", "report", "backup"),
25
+ "Admin Permissions": ("admin", "permission", "role", "rbac", "superuser"),
26
+ "File Uploads": ("upload", "multipart", "file", "attachment", "storage"),
27
+ }
28
+
29
+ # Specific import tokens that prove a test references a concept's implementation,
30
+ # so the import reason fires even when the implementation file itself was not
31
+ # captured as evidence (Codex blocker 3: Plane bg_tasks).
32
+ CONCEPT_IMPORT_HINTS = {
33
+ "Background Jobs": ("bgtask", "bgtasks", "bg_task", "celery", "sidekiq", "bullmq", "worker"),
34
+ "Billing Webhooks": ("stripe", "paypal", "braintree", "chargebee"),
35
+ "Authentication": ("nextauth", "next-auth", "passport"),
36
+ "Data Export": (),
37
+ "Admin Permissions": (),
38
+ "File Uploads": ("multer", "busboy", "uploadfile"),
39
+ }
40
+
41
+ # Path segments that mark a directory as test-only. A test-only directory can never
42
+ # be "the same directory as the implementation" (Codex blocker 1: __e2e__).
43
+ _TEST_DIR_SEGMENTS = (
44
+ "tests", "test", "__tests__", "spec", "specs", "e2e", "__e2e__",
45
+ "integration", "unit", "cypress", "playwright", "e2e-tests", "__test__",
46
+ )
47
+
48
+
49
+ def _is_test_only_dir(dir_path: str) -> bool:
50
+ return any(seg in _TEST_DIR_SEGMENTS for seg in dir_path.lower().split("/"))
51
+
52
+
53
+ @dataclass
54
+ class TestRec:
55
+ path: str
56
+ reason: str
57
+ rank: int
58
+
59
+
60
+ @dataclass
61
+ class ContextPack:
62
+ concept: str
63
+ mode: str
64
+ purpose: str
65
+ limited: bool = False
66
+ supported_claims: list[str] = field(default_factory=list)
67
+ decisions: list[str] = field(default_factory=list)
68
+ uncertainty: list[str] = field(default_factory=list)
69
+ do_not_change: list[str] = field(default_factory=list)
70
+ tests_to_run: list[TestRec] = field(default_factory=list)
71
+ agent_guidance: str = ""
72
+ generated_at: str = ""
73
+
74
+
75
+ def _is_weak(ci: ConceptIntelligence) -> bool:
76
+ return getattr(ci.concept, "weak_only", False) or ci.concept.confidence < 0.5
77
+
78
+
79
+ def _do_not_change_warnings(ci: ConceptIntelligence) -> list[str]:
80
+ """Derive 'do not change' warnings from actual evidence, never a hardcoded table.
81
+ Weak/false concepts must not produce authoritative warnings."""
82
+ if _is_weak(ci):
83
+ return [
84
+ "Evidence weak - manual validation required before using this as agent context."
85
+ ]
86
+ kinds = {e.signal.kind for e in ci.evidence}
87
+ warnings: list[str] = []
88
+ if "webhook_signature_verification" in kinds:
89
+ warnings.append("Webhook signature verification behavior.")
90
+ if "token_usage" in kinds:
91
+ warnings.append("Token issuing and verification behavior.")
92
+ if "auth_dependency" in kinds or "middleware" in kinds:
93
+ warnings.append("Authorization / protected-route behavior.")
94
+ if "route" in kinds:
95
+ warnings.append(f"Request handling behavior for {ci.concept.name}.")
96
+ if "background_job" in kinds or "queue" in kinds:
97
+ warnings.append("Job/queue processing behavior.")
98
+ if not warnings:
99
+ warnings.append(f"Core behavior of {ci.concept.name} without a reviewer.")
100
+ return warnings
101
+
102
+
103
+ # Generic path/monorepo tokens that are not specific enough to prove an import
104
+ # relation (avoids "imports" matching on "src", "plane", "packages", etc).
105
+ _COMMON_PATH_TOKENS = {
106
+ "app", "apps", "api", "src", "lib", "libs", "server", "servers", "service",
107
+ "services", "test", "tests", "spec", "specs", "package", "packages", "shared",
108
+ "web", "core", "common", "utils", "util", "index", "main", "models", "model",
109
+ "types", "type", "components", "component", "pages", "routes", "route",
110
+ "handlers", "handler", "dist", "build", "internal", "backend", "frontend",
111
+ "plane", "modules", "module", "config", "configs",
112
+ }
113
+
114
+
115
+ def _impl_modules(ci: ConceptIntelligence) -> set[str]:
116
+ """Specific identifier tokens from non-test evidence paths, for import matching.
117
+ Common monorepo/path tokens are excluded so an import match is meaningful."""
118
+ mods: set[str] = set()
119
+ for e in ci.evidence:
120
+ if e.path and e.signal.kind != "test":
121
+ stem = e.path.lower().replace("\\", "/").replace(".py", "").replace(".ts", "")
122
+ for part in stem.split("/"):
123
+ if len(part) >= 4 and part not in _COMMON_PATH_TOKENS:
124
+ mods.add(part)
125
+ return mods
126
+
127
+
128
+ def _impl_dirs_non_test(ci: ConceptIntelligence) -> set[str]:
129
+ """Directories of real (non-test) implementation evidence files. Excludes any
130
+ directory that is itself test-only (e2e/spec/unit/...)."""
131
+ dirs: set[str] = set()
132
+ for e in ci.evidence:
133
+ if not e.path or e.signal.kind == "test":
134
+ continue
135
+ d = posixpath.dirname(e.path)
136
+ if _is_test_only_dir(d):
137
+ continue # a non-test file living under a test tree is not "the impl dir"
138
+ dirs.add(d)
139
+ return dirs
140
+
141
+
142
+ def _rank_tests(ci: ConceptIntelligence) -> list[TestRec]:
143
+ """Recommend a test ONLY when the relation is provable, with a true reason
144
+ (Codex blockers 2 & 3):
145
+ 1. import/reference to the implementation (highest)
146
+ 2. EXACT same directory as a real implementation file
147
+ otherwise omit - a missing recommendation beats a false reason.
148
+ "same directory" requires literally equal parent directories, never a shared
149
+ package/keyword/monorepo token."""
150
+ impl_dirs = _impl_dirs_non_test(ci)
151
+ impl_modules = _impl_modules(ci)
152
+ impl_paths = [
153
+ e.path.lower().rsplit(".", 1)[0]
154
+ for e in ci.evidence
155
+ if e.path and e.signal.kind != "test"
156
+ ]
157
+ hints = CONCEPT_IMPORT_HINTS.get(ci.concept.name, ())
158
+
159
+ recs: list[TestRec] = []
160
+ seen: set[str] = set()
161
+ for e in ci.evidence:
162
+ if e.signal.kind != "test" or not e.path or e.path in seen:
163
+ continue
164
+ seen.add(e.path)
165
+ d = posixpath.dirname(e.path)
166
+ imports = [str(i).lower() for i in e.signal.metadata.get("imports", [])]
167
+
168
+ # An import matches the implementation if it: references a specific impl
169
+ # module token, matches a dotted module path against an impl file path
170
+ # (plane.bgtasks.copy_s3_object -> plane/bgtasks/copy_s3_object), or hits a
171
+ # concept-specific import hint.
172
+ def _matches(imp: str) -> bool:
173
+ if any(m in imp for m in impl_modules):
174
+ return True
175
+ frag = imp.replace(".", "/")
176
+ if len(frag) >= 6 and any(frag in p for p in impl_paths):
177
+ return True
178
+ return any(h in imp for h in hints)
179
+
180
+ imported = any(_matches(imp) for imp in imports)
181
+ if imported:
182
+ recs.append(TestRec(
183
+ e.path,
184
+ "Recommended because it imports or tests the implementation.",
185
+ 4,
186
+ ))
187
+ elif d in impl_dirs and not _is_test_only_dir(d):
188
+ recs.append(TestRec(
189
+ e.path,
190
+ "Recommended because it is in the same directory as the implementation.",
191
+ 3,
192
+ ))
193
+ # No provable relation -> omit (do not invent a reason).
194
+
195
+ recs.sort(key=lambda r: r.rank, reverse=True)
196
+ return recs[:MAX_TESTS]
197
+
198
+
199
+ def generate_context_pack(ci: ConceptIntelligence, mode: str = "risk") -> ContextPack:
200
+ if mode not in MODES:
201
+ mode = "risk"
202
+ weak = _is_weak(ci)
203
+
204
+ supported = [
205
+ f"{c.text} Evidence: "
206
+ f"{', '.join(dict.fromkeys(e.path for e in c.evidence if e.path)) or 'n/a'}"
207
+ for c in ci.claims
208
+ if c.type != "uncertainty" and c.state in ("supported", "weak", "partial")
209
+ ]
210
+ decisions = [
211
+ e.summary for e in ci.evidence if e.kind == "decision"
212
+ ] or ["No corroborated decision record found."]
213
+ uncertainty = [u.text for u in ci.uncertainties]
214
+ tests = _rank_tests(ci)
215
+
216
+ if weak:
217
+ purpose = (
218
+ f"Context Pack is limited because {ci.concept.name} evidence is weak. "
219
+ "Validate before using as authoritative agent context."
220
+ )
221
+ guidance = (
222
+ "Evidence is weak or generic. Do not treat this concept as established. "
223
+ "Validate against the implementation before acting."
224
+ )
225
+ else:
226
+ purpose = f"{ci.concept.name} is detected from repository evidence."
227
+ guidance = (
228
+ "Do not invent rationale for missing decisions. Ask a human or record a "
229
+ "corroborated decision before changing core behavior."
230
+ if ci.uncertainties
231
+ else "Preserve existing behavior and keep evidence links intact."
232
+ )
233
+
234
+ return ContextPack(
235
+ concept=ci.concept.name,
236
+ mode=mode,
237
+ purpose=purpose,
238
+ limited=weak,
239
+ supported_claims=supported,
240
+ decisions=decisions,
241
+ uncertainty=uncertainty,
242
+ do_not_change=_do_not_change_warnings(ci),
243
+ tests_to_run=tests,
244
+ agent_guidance=guidance,
245
+ generated_at=datetime.now(timezone.utc).isoformat(),
246
+ )
247
+
248
+
249
+ def render_markdown(pack: ContextPack) -> str:
250
+ lines = [
251
+ f"# Context Pack: {pack.concept}",
252
+ f"Mode: {pack.mode}" + (" (LIMITED - weak evidence)" if pack.limited else ""),
253
+ "Generated by: DevTime",
254
+ "Source: local repository memory",
255
+ "",
256
+ "## Purpose",
257
+ pack.purpose,
258
+ "",
259
+ "## Supported Claims",
260
+ ]
261
+ lines += [f"- {c}" for c in pack.supported_claims] or ["- None"]
262
+ lines += ["", "## Decisions"]
263
+ lines += [f"- {d}" for d in pack.decisions]
264
+ lines += ["", "## Uncertainty"]
265
+ lines += [f"- {u}" for u in pack.uncertainty] or ["- None"]
266
+ lines += ["", "## Do Not Change Without Review"]
267
+ lines += [f"- {w}" for w in pack.do_not_change]
268
+ lines += ["", f"## Tests To Run (top {MAX_TESTS}, ranked)"]
269
+ if pack.tests_to_run:
270
+ for t in pack.tests_to_run:
271
+ lines.append(f"- {t.path}")
272
+ lines.append(f" - {t.reason}")
273
+ else:
274
+ lines.append("- No behavior-specific tests found.")
275
+ lines += ["", "## Agent Instruction", pack.agent_guidance]
276
+ return "\n".join(lines)
@@ -0,0 +1,127 @@
1
+ """Evidence system (Builder Edition, Chapter 10).
2
+
3
+ Evidence is the truth layer. It records what material supports a concept, how
4
+ strong it is, and what claim types it can support.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from devtime.intelligence.concepts import ConceptCandidate
12
+ from devtime.scanner.extractors.base import Signal
13
+
14
+ # Signal kind -> evidence kind.
15
+ _KIND_MAP = {
16
+ "route": "route",
17
+ "middleware": "middleware",
18
+ "auth_dependency": "auth",
19
+ "token_usage": "usage",
20
+ "webhook_signature_verification": "behavior",
21
+ "background_job": "worker",
22
+ "queue": "queue",
23
+ "dependency": "dependency",
24
+ "config": "config",
25
+ "test": "test",
26
+ "doc": "doc",
27
+ "decision": "decision",
28
+ }
29
+
30
+ # Evidence strength tiers (Chapter 10).
31
+ STRONG_KINDS = {"route", "auth_dependency", "webhook_signature_verification", "decision"}
32
+ MEDIUM_KINDS = {"middleware", "token_usage", "background_job", "config"}
33
+ # "test" strength depends on whether it is behavior-specific; handled below.
34
+ WEAK_KINDS = {"dependency", "doc"}
35
+
36
+
37
+ @dataclass
38
+ class EvidenceItem:
39
+ concept_slug: str
40
+ kind: str
41
+ strength: str
42
+ summary: str
43
+ path: str | None
44
+ start_line: int | None
45
+ end_line: int | None
46
+ signal: Signal
47
+ supports_claim_types: list[str] = field(default_factory=list)
48
+
49
+
50
+ def map_signal_to_evidence_kind(kind: str) -> str:
51
+ return _KIND_MAP.get(kind, "other")
52
+
53
+
54
+ def estimate_strength(s: Signal) -> str:
55
+ if s.kind == "test":
56
+ # E2E UI specs match keywords by accident -> weak (Reality Validation).
57
+ if s.metadata.get("e2e"):
58
+ return "weak"
59
+ # Behavior-specific test names are strong; bare test presence is medium.
60
+ return "strong" if s.name and len(s.name) > 8 else "medium"
61
+ if s.kind in STRONG_KINDS:
62
+ return "strong"
63
+ if s.kind in MEDIUM_KINDS:
64
+ return "medium"
65
+ return "weak"
66
+
67
+
68
+ def _supports(kind: str) -> list[str]:
69
+ mapping = {
70
+ "route": ["concept", "behavior"],
71
+ "auth": ["concept", "behavior"],
72
+ "behavior": ["behavior"],
73
+ "usage": ["usage"],
74
+ "dependency": ["usage"],
75
+ "test": ["test", "behavior"],
76
+ "decision": ["decision"],
77
+ "config": ["usage"],
78
+ "doc": ["concept"],
79
+ "worker": ["concept", "behavior"],
80
+ "middleware": ["behavior"],
81
+ }
82
+ return mapping.get(kind, ["concept"])
83
+
84
+
85
+ def summarize_signal(s: Signal) -> str:
86
+ if s.kind == "route":
87
+ return f"{s.name} route handles requests in {s.file_rel_path}."
88
+ if s.kind == "test":
89
+ return f"Test '{s.name}' in {s.file_rel_path}."
90
+ if s.kind == "dependency":
91
+ return f"Depends on '{s.name}' ({s.file_rel_path})."
92
+ if s.kind == "webhook_signature_verification":
93
+ return f"Verifies {s.name} webhook signatures in {s.file_rel_path}."
94
+ if s.kind == "auth_dependency":
95
+ return f"Auth dependency '{s.name}' in {s.file_rel_path}."
96
+ if s.kind == "decision":
97
+ return f"Decision record: {s.name} ({s.file_rel_path})."
98
+ if s.kind == "config":
99
+ return f"Config reference '{s.name}' in {s.file_rel_path}."
100
+ return f"{s.kind}: {s.name or ''} ({s.file_rel_path})".strip()
101
+
102
+
103
+ def _rank_key(e: EvidenceItem) -> tuple[int, float]:
104
+ order = {"strong": 3, "medium": 2, "weak": 1, "contradictory": 0}
105
+ return (order.get(e.strength, 0), e.signal.confidence)
106
+
107
+
108
+ def build_evidence(concept: ConceptCandidate) -> list[EvidenceItem]:
109
+ items: list[EvidenceItem] = []
110
+ for s in concept.signals:
111
+ kind = map_signal_to_evidence_kind(s.kind)
112
+ strength = estimate_strength(s)
113
+ items.append(
114
+ EvidenceItem(
115
+ concept_slug=concept.slug,
116
+ kind=kind,
117
+ strength=strength,
118
+ summary=summarize_signal(s),
119
+ path=s.file_rel_path,
120
+ start_line=s.start_line,
121
+ end_line=s.end_line,
122
+ signal=s,
123
+ supports_claim_types=_supports(kind),
124
+ )
125
+ )
126
+ items.sort(key=_rank_key, reverse=True)
127
+ return items
@@ -0,0 +1,21 @@
1
+ """Lineage (Builder Edition / Full Book, Book III Ch.9).
2
+
3
+ Lineage shows how meaning changes over time. V0 is a placeholder: it has no
4
+ git-history backed evolution yet, but the seam exists so later versions can track
5
+ how concepts evolve across code, decisions, tests, and risk.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class LineageEntry:
15
+ concept_slug: str
16
+ note: str
17
+
18
+
19
+ def lineage_for(concept_slug: str) -> list[LineageEntry]:
20
+ # V0: no historical lineage yet.
21
+ return []
@@ -0,0 +1,267 @@
1
+ """Risk review (Builder Edition, Chapter 13; Trust Repair v0.0.6).
2
+
3
+ Risk review checks a change against repository memory. It does not prove a PR is
4
+ broken; it surfaces low-noise, specific, evidence-linked, actionable signals.
5
+
6
+ Trust Repair makes the result *honest* with explicit states:
7
+ - review_failed : DevTime could not read the diff (e.g. git failed).
8
+ - no_findings : the diff was inspected; no supported rule found a problem.
9
+ - unsupported_change_class: known-concept files changed, but no rule covers this change.
10
+ - finding : a supported rule found a risk.
11
+
12
+ "No findings" never means "could not inspect" or "do not know this risk class".
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+
20
+ from devtime.intelligence.claims import ConceptIntelligence
21
+
22
+ MAX_FINDINGS = 5
23
+
24
+ STATE_REVIEW_FAILED = "review_failed"
25
+ STATE_NO_FINDINGS = "no_findings"
26
+ STATE_UNSUPPORTED = "unsupported_change_class"
27
+ STATE_FINDING = "finding"
28
+
29
+
30
+ @dataclass
31
+ class RiskFinding:
32
+ severity: str
33
+ concept: str
34
+ type: str
35
+ text: str
36
+ changed_files: list[str] = field(default_factory=list)
37
+ missing: list[str] = field(default_factory=list)
38
+ suggested_action: str = ""
39
+ human_review_required: bool = False
40
+ why_it_matters: str = ""
41
+
42
+
43
+ @dataclass
44
+ class RiskReview:
45
+ state: str
46
+ findings: list[RiskFinding] = field(default_factory=list)
47
+ affected_concepts: list[str] = field(default_factory=list)
48
+ reason: str = "" # populated for review_failed
49
+
50
+
51
+ @dataclass
52
+ class DiffInfo:
53
+ changed_files: list[str]
54
+ added_lines: list[str]
55
+ removed_lines: list[str]
56
+
57
+ @property
58
+ def changed_text(self) -> str:
59
+ return "\n".join(self.added_lines + self.removed_lines).lower()
60
+
61
+ @property
62
+ def code_lines(self) -> list[str]:
63
+ return [ln for ln in (self.added_lines + self.removed_lines) if not _is_comment(ln)]
64
+
65
+ @property
66
+ def has_code_change(self) -> bool:
67
+ """True if the diff contains at least one non-comment changed line."""
68
+ return any(ln.strip() for ln in self.code_lines)
69
+
70
+
71
+ _COMMENT_RE = re.compile(r"""^\s*(//|#|/\*|\*|<!--|""" r'"""' r"""|''')""")
72
+
73
+
74
+ def _is_comment(line: str) -> bool:
75
+ return bool(_COMMENT_RE.match(line))
76
+
77
+
78
+ def review_failed(reason: str) -> RiskReview:
79
+ return RiskReview(state=STATE_REVIEW_FAILED, reason=reason)
80
+
81
+
82
+ def parse_unified_diff(diff_text: str) -> DiffInfo:
83
+ changed_files: list[str] = []
84
+ added: list[str] = []
85
+ removed: list[str] = []
86
+ for line in diff_text.splitlines():
87
+ m = re.match(r"^\+\+\+ b/(.+)$", line)
88
+ if m:
89
+ changed_files.append(m.group(1))
90
+ continue
91
+ if line.startswith("+++") or line.startswith("---"):
92
+ continue
93
+ if line.startswith("+"):
94
+ added.append(line[1:])
95
+ elif line.startswith("-"):
96
+ removed.append(line[1:])
97
+ return DiffInfo(changed_files=changed_files, added_lines=added, removed_lines=removed)
98
+
99
+
100
+ def _concept_evidence_paths(ci: ConceptIntelligence) -> set[str]:
101
+ return {e.path for e in ci.evidence if e.path}
102
+
103
+
104
+ def _concept_touched(ci: ConceptIntelligence, changed_files: list[str]) -> bool:
105
+ paths = _concept_evidence_paths(ci)
106
+ return any(cf in paths for cf in changed_files)
107
+
108
+
109
+ def _behavior_changed(diff: DiffInfo, *keywords: str) -> bool:
110
+ # A comment-only diff is never a behavior change (Trust Repair). When there is
111
+ # real code change, keyword matches (including in adjacent comments) count.
112
+ if not diff.has_code_change:
113
+ return False
114
+ return any(k in diff.changed_text for k in keywords)
115
+
116
+
117
+ def _test_changed(diff: DiffInfo, patterns: list[str]) -> bool:
118
+ for cf in diff.changed_files:
119
+ low = cf.lower()
120
+ if (".test." in low or ".spec." in low or "test" in low) and any(
121
+ p in low for p in patterns
122
+ ):
123
+ return True
124
+ return any(p in diff.changed_text for p in patterns) and "test" in diff.changed_text
125
+
126
+
127
+ # --- JWT algorithm weakening (P0-2) ------------------------------------------
128
+
129
+ _ALGO_UNSAFE_RE = re.compile(
130
+ r"""(?i)\b(alg|algorithm|jwt[_a-z]*algorithm|signing[_a-z]*algorithm)\b\s*[:=]\s*"""
131
+ r"""['"]?\s*(none|null)\s*['"]?""",
132
+ )
133
+ _ALGO_EMPTY_RE = re.compile(
134
+ r"""(?i)\b(alg|algorithm|jwt[_a-z]*algorithm|signing[_a-z]*algorithm)\b\s*[:=]\s*['"]\s*['"]""",
135
+ )
136
+
137
+
138
+ def _auth_evidence_files(intelligence: list[ConceptIntelligence]) -> set[str]:
139
+ files: set[str] = set()
140
+ for ci in intelligence:
141
+ if ci.concept.name == "Authentication":
142
+ files |= _concept_evidence_paths(ci)
143
+ return files
144
+
145
+
146
+ def _looks_auth_path(path: str) -> bool:
147
+ low = path.lower()
148
+ return any(t in low for t in ("auth", "jwt", "token", "security", "login", "session"))
149
+
150
+
151
+ def detect_jwt_algorithm_risk(
152
+ diff: DiffInfo, intelligence: list[ConceptIntelligence]
153
+ ) -> list[RiskFinding]:
154
+ """Value-aware: an algorithm constant changed to an unsafe value (none/empty).
155
+
156
+ Comment lines are excluded, and the changed line need not mention 'jwt'/'token'.
157
+ """
158
+ auth_files = _auth_evidence_files(intelligence)
159
+ findings: list[RiskFinding] = []
160
+ for line in diff.added_lines:
161
+ if _is_comment(line):
162
+ continue
163
+ if not (_ALGO_UNSAFE_RE.search(line) or _ALGO_EMPTY_RE.search(line)):
164
+ continue
165
+ in_auth = any(cf in auth_files for cf in diff.changed_files) or any(
166
+ _looks_auth_path(cf) for cf in diff.changed_files
167
+ )
168
+ severity = "high" if in_auth else "medium"
169
+ nearby_tests = _test_changed(diff, ["jwt", "token", "auth", "verify"])
170
+ suggested = "Run JWT verification/key-rotation tests and require human security review."
171
+ if not nearby_tests:
172
+ suggested += " No nearby JWT verification tests were found in this diff."
173
+ findings.append(
174
+ RiskFinding(
175
+ severity=severity,
176
+ concept="Authentication",
177
+ type="jwt_algorithm_weakening",
178
+ text="JWT signing algorithm appears to change to an unsafe value: none.",
179
+ changed_files=diff.changed_files,
180
+ missing=["security review", "JWT verification tests"],
181
+ suggested_action=suggested,
182
+ human_review_required=True,
183
+ why_it_matters="This may disable or weaken token signing/verification.",
184
+ )
185
+ )
186
+ break # one precise finding is enough
187
+ return findings
188
+
189
+
190
+ # --- Main entry --------------------------------------------------------------
191
+
192
+ def review_diff(
193
+ diff: DiffInfo, intelligence: list[ConceptIntelligence]
194
+ ) -> RiskReview:
195
+ if not diff.changed_files:
196
+ return RiskReview(state=STATE_NO_FINDINGS)
197
+
198
+ affected = [ci for ci in intelligence if _concept_touched(ci, diff.changed_files)]
199
+
200
+ findings: list[RiskFinding] = []
201
+
202
+ # Value-aware JWT algorithm weakening can fire even when Authentication evidence
203
+ # is only path-adjacent (auth-looking file).
204
+ findings += detect_jwt_algorithm_risk(diff, intelligence)
205
+
206
+ for ci in affected:
207
+ name = ci.concept.name
208
+
209
+ if name == "Billing Webhooks" and _behavior_changed(
210
+ diff, "retry", "idempoten", "duplicate"
211
+ ):
212
+ if not _test_changed(diff, ["duplicate", "webhook", "retry"]):
213
+ findings.append(
214
+ RiskFinding(
215
+ severity="high",
216
+ concept=name,
217
+ type="missing_test_update",
218
+ text="Retry behavior changed without duplicate-delivery test changes.",
219
+ changed_files=diff.changed_files,
220
+ missing=["duplicate-delivery test update", "retry strategy decision"],
221
+ suggested_action=(
222
+ "Update duplicate-delivery tests or record the retry "
223
+ "behavior decision before merge."
224
+ ),
225
+ human_review_required=True,
226
+ why_it_matters=(
227
+ "Billing webhooks may receive duplicate events; retry without "
228
+ "dedupe tests risks double-processing."
229
+ ),
230
+ )
231
+ )
232
+
233
+ if name == "Authentication" and _behavior_changed(diff, "token", "jwt", "refresh"):
234
+ has_decision = any(e.kind == "decision" for e in ci.evidence)
235
+ already_algo = any(f.type == "jwt_algorithm_weakening" for f in findings)
236
+ if not has_decision and not already_algo:
237
+ findings.append(
238
+ RiskFinding(
239
+ severity="medium",
240
+ concept=name,
241
+ type="missing_decision",
242
+ text="Token behavior changed but no related decision was found.",
243
+ changed_files=diff.changed_files,
244
+ missing=["token strategy decision"],
245
+ suggested_action="Record a token strategy decision or link an existing ADR.",
246
+ human_review_required=True,
247
+ )
248
+ )
249
+
250
+ if findings:
251
+ severity_rank = {"critical": 4, "high": 3, "medium": 2, "low": 1}
252
+ findings.sort(key=lambda f: severity_rank.get(f.severity, 0), reverse=True)
253
+ return RiskReview(
254
+ state=STATE_FINDING,
255
+ findings=findings[:MAX_FINDINGS],
256
+ affected_concepts=sorted({f.concept for f in findings}),
257
+ )
258
+
259
+ if affected:
260
+ # Known-concept files changed but no supported rule evaluated this change.
261
+ return RiskReview(
262
+ state=STATE_UNSUPPORTED,
263
+ affected_concepts=sorted({ci.concept.name for ci in affected}),
264
+ )
265
+
266
+ # Diff inspected; nothing DevTime tracks was touched.
267
+ return RiskReview(state=STATE_NO_FINDINGS)