Skip to content

Commit 23f0e66

Browse files
authored
feat: agent co-author detection + derived decision scope (#803)
* feat(git): detect agent co-author trailers and vendor-domain identities * feat(decisions): derive a scope level for each decision record * fix(git): exact-match vendor email allowlist for agent detection A vendor domain plus a substring alias scan over the local part classified any address containing an agent token (jean-claude@, claudette@) as a tier-1 agent, and let one vendor's agent name match at another vendor's domain. Replace with a per-domain anchored allowlist of exact agent identities; the co-author e-mail fallback inherits the same exact matching. * fix(decisions): module links outrank evidence file; drop unreachable function scope - explicit module linkage now takes precedence over the evidence-file fallback, which applies only when nothing else is linked - records with no linkage at all derive null instead of cross-module - multi-file records without module links infer modules from the files' top-level directories - remove the function level: nothing can produce it yet (no symbol linkage) - hide the UI scope filter when no row carries a scope, and expose scope on the api-client response type
1 parent 0aeb9c5 commit 23f0e66

9 files changed

Lines changed: 366 additions & 4 deletions

File tree

packages/api-client/src/types/decisions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export interface DecisionRecordResponse {
2525
last_code_change: string | null;
2626
/** Trust tier of the decision's primary supporting evidence. */
2727
verification?: DecisionVerification;
28+
/**
29+
* Derived granularity of the decision's blast area. Absent on older
30+
* backends; null when the record has no code linkage at all.
31+
*/
32+
scope?: "file" | "module" | "cross-module" | null;
2833
created_at: string;
2934
updated_at: string;
3035
/** Evidence rows backing the record. Populated by the list endpoint only. */
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Derived decision granularity — how much of the codebase a decision spans.
2+
3+
Pure derivation from a record's existing linkage fields (no LLM, no I/O), so
4+
it can run at serialization time for any record, old or new. Levels, narrowest
5+
first: ``file`` < ``module`` < ``cross-module``. ``None`` means the record has
6+
no code linkage at all — better no claim than defaulting the least-grounded
7+
records to the widest level.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from collections.abc import Sequence
13+
14+
__all__ = ["derive_decision_scope"]
15+
16+
17+
def derive_decision_scope(
18+
affected_files: Sequence[str] | None,
19+
affected_modules: Sequence[str] | None,
20+
*,
21+
evidence_file: str | None = None,
22+
) -> str | None:
23+
"""Return the scope level for one decision record, or ``None``.
24+
25+
Rules, in order:
26+
27+
- nothing linked at all: ``file`` when an *evidence_file* pins the record
28+
to one file, else ``None`` — explicit file/module linkage always
29+
outranks the evidence-file fallback
30+
- exactly one affected file → ``file``
31+
- otherwise count distinct modules — the explicitly linked ones, or when
32+
none are linked, the top-level directories of the affected files: one →
33+
``module``, several → ``cross-module``; multiple root-level files with
34+
no directory stay ``file``.
35+
36+
An evidence line narrows nothing on its own — a line without a resolved
37+
symbol still only proves file-level scope — so it is deliberately not a
38+
parameter.
39+
"""
40+
files = {f for f in (affected_files or []) if f}
41+
modules = {m for m in (affected_modules or []) if m}
42+
43+
if not files and not modules:
44+
return "file" if evidence_file else None
45+
if len(files) == 1:
46+
return "file"
47+
if not modules:
48+
modules = {f.split("/", 1)[0] for f in files if "/" in f}
49+
if not modules:
50+
return "file"
51+
return "module" if len(modules) == 1 else "cross-module"

packages/core/src/repowise/core/ingestion/git_indexer/agent_provenance.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,23 @@
118118
_SERVICE_EMAILS: dict[str, tuple[str, int]] = {
119119
"cursoragent@cursor.com": ("cursor", 1),
120120
"devin-ai-integration[bot]@users.noreply.github.com": ("devin", 1),
121+
# Primarily Claude's co-author identity; as AUTHOR it is tier 1 like every
122+
# other service e-mail here.
123+
"noreply@anthropic.com": ("claude", 1),
124+
}
125+
126+
# Agent-vendor e-mail domains → the exact agent identities each vendor emits.
127+
# A domain alone is never enough — real humans work at these companies — so an
128+
# identity here counts only when the LOCAL PART fully matches the vendor's
129+
# allowlisted agent identity (anchored fullmatch, never a substring scan, and
130+
# per vendor, never a flat token set): ``claude@anthropic.com`` and the
131+
# ``claude-<model>`` slugs match; ``jane@anthropic.com``,
132+
# ``jean-claude@anthropic.com`` and ``codex@anthropic.com`` never do.
133+
_VENDOR_DOMAIN_LOCALS: dict[str, tuple[re.Pattern[str], str]] = {
134+
"anthropic.com": (re.compile(r"claude(?:-(?:opus|sonnet|haiku)(?:-[\w.]+)?)?"), "claude"),
135+
"openai.com": (re.compile(r"codex"), "codex"),
136+
"cursor.com": (re.compile(r"cursor(?:agent)?"), "cursor"),
137+
"devin.ai": (re.compile(r"devin"), "devin"),
121138
}
122139

123140
# Commit-message footers → agent (exact service phrases only).
@@ -158,6 +175,13 @@
158175

159176
_AIDER_NAME_RE = re.compile(r"\(aider\)\s*$")
160177

178+
# Any ``Co-authored-by:`` trailer's e-mail, for registry-based identity
179+
# matching: agent service identities the explicit patterns above don't name
180+
# (bot noreply logins, exact service e-mails, vendor-domain identities) are
181+
# resolved through the same :meth:`AgentProvenanceClassifier._identity_match`
182+
# registry — one list, no parallel patterns.
183+
_COAUTHOR_EMAIL_RE = re.compile(r"^co-authored-by:[^<\n]*<([^>\s]+)>", re.IGNORECASE | re.MULTILINE)
184+
161185

162186
def _normalize_agent_token(value: str, *, allow_slug: bool) -> str | None:
163187
"""Resolve a free-text tool/agent name to a canonical label.
@@ -274,6 +298,10 @@ def _identity_match(self, email: str) -> tuple[str, int] | None:
274298
m = self._bot_email_re.match(e)
275299
if m:
276300
return (_BOT_LOGINS[m.group(1).lower()], 1)
301+
local, _, domain = e.partition("@")
302+
vendor = _VENDOR_DOMAIN_LOCALS.get(domain)
303+
if vendor and vendor[0].fullmatch(local):
304+
return (vendor[1], 1)
277305
return None
278306

279307
def classify(
@@ -328,6 +356,10 @@ def classify(
328356
for pat, agent in self._coauthors:
329357
if pat.search(message):
330358
return AgentProvenance(agent, 3, "coauthor_trailer", "high")
359+
for m in _COAUTHOR_EMAIL_RE.finditer(message):
360+
hit = self._identity_match(m.group(1))
361+
if hit:
362+
return AgentProvenance(hit[0], 3, "coauthor_trailer", "high")
331363
return _HUMAN
332364

333365

packages/server/src/repowise/server/schemas/decisions.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from pydantic import BaseModel
99

10+
from repowise.core.analysis.decisions.scope import derive_decision_scope
11+
1012

1113
class EvidencePreview(BaseModel):
1214
"""The top-ranked evidence row, slimmed for list rows."""
@@ -38,6 +40,10 @@ class DecisionRecordResponse(BaseModel):
3840
confidence: float
3941
staleness_score: float
4042
verification: str = "unverified"
43+
# Derived granularity: file | module | cross-module, or None when the
44+
# record has no code linkage at all. Computed at serialization time from
45+
# the linkage fields, so old records get it too.
46+
scope: str | None = None
4147
superseded_by: str | None
4248
last_code_change: datetime | None
4349
created_at: datetime
@@ -51,6 +57,8 @@ class DecisionRecordResponse(BaseModel):
5157

5258
@classmethod
5359
def from_orm(cls, obj: object) -> DecisionRecordResponse:
60+
affected_files = json.loads(obj.affected_files_json) # type: ignore[attr-defined]
61+
affected_modules = json.loads(obj.affected_modules_json) # type: ignore[attr-defined]
5462
return cls(
5563
id=obj.id, # type: ignore[attr-defined]
5664
repository_id=obj.repository_id, # type: ignore[attr-defined]
@@ -67,8 +75,8 @@ def from_orm(cls, obj: object) -> DecisionRecordResponse:
6775
rationale=obj.rationale, # type: ignore[attr-defined]
6876
alternatives=json.loads(obj.alternatives_json), # type: ignore[attr-defined]
6977
consequences=json.loads(obj.consequences_json), # type: ignore[attr-defined]
70-
affected_files=json.loads(obj.affected_files_json), # type: ignore[attr-defined]
71-
affected_modules=json.loads(obj.affected_modules_json), # type: ignore[attr-defined]
78+
affected_files=affected_files,
79+
affected_modules=affected_modules,
7280
tags=json.loads(obj.tags_json), # type: ignore[attr-defined]
7381
source=obj.source, # type: ignore[attr-defined]
7482
evidence_commits=json.loads(obj.evidence_commits_json), # type: ignore[attr-defined]
@@ -77,6 +85,11 @@ def from_orm(cls, obj: object) -> DecisionRecordResponse:
7785
confidence=obj.confidence, # type: ignore[attr-defined]
7886
staleness_score=obj.staleness_score, # type: ignore[attr-defined]
7987
verification=obj.verification, # type: ignore[attr-defined]
88+
scope=derive_decision_scope(
89+
affected_files,
90+
affected_modules,
91+
evidence_file=obj.evidence_file, # type: ignore[attr-defined]
92+
),
8093
superseded_by=obj.superseded_by, # type: ignore[attr-defined]
8194
last_code_change=obj.last_code_change, # type: ignore[attr-defined]
8295
created_at=obj.created_at, # type: ignore[attr-defined]

packages/types/src/decisions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export type DecisionSource =
1919
| "readme_mining"
2020
| "cli";
2121

22+
/** Derived granularity of a decision's blast area, narrowest first. */
23+
export type DecisionScope = "file" | "module" | "cross-module";
24+
2225
export interface DecisionRecord {
2326
id: string;
2427
repository_id: string;
@@ -42,6 +45,11 @@ export interface DecisionRecord {
4245
last_code_change: string | null;
4346
/** Trust tier of the decision's primary supporting evidence. Optional for back-compat. */
4447
verification?: DecisionVerification;
48+
/**
49+
* Derived granularity level. Optional for back-compat with older backends;
50+
* null when the record has no code linkage at all.
51+
*/
52+
scope?: DecisionScope | null;
4553
created_at: string;
4654
updated_at: string;
4755
/** Number of evidence rows backing the record. List endpoint only. */

packages/ui/__tests__/decisions/decisions-table.test.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,74 @@ describe("DecisionsTable", () => {
6666
});
6767
});
6868

69+
it("renders a scope badge when the record carries one", () => {
70+
render(
71+
<DecisionsTable
72+
{...baseProps}
73+
decisions={[
74+
makeDecision({ id: "1", scope: "file" }),
75+
makeDecision({ id: "2", title: "Adopt SWR", scope: "cross-module" }),
76+
]}
77+
/>,
78+
);
79+
// The scope <select> options share these labels, so exclude them.
80+
expect(screen.getAllByText("File").some((el) => el.tagName !== "OPTION")).toBe(true);
81+
expect(
82+
screen.getAllByText("Cross-module").some((el) => el.tagName !== "OPTION"),
83+
).toBe(true);
84+
});
85+
86+
it("invokes onFiltersChange when the scope filter changes", () => {
87+
const onFiltersChange = vi.fn();
88+
render(
89+
<DecisionsTable
90+
{...baseProps}
91+
onFiltersChange={onFiltersChange}
92+
decisions={[makeDecision({ id: "1", scope: "file" })]}
93+
/>,
94+
);
95+
fireEvent.change(screen.getByLabelText("Filter by scope"), {
96+
target: { value: "module" },
97+
});
98+
expect(onFiltersChange).toHaveBeenCalledWith({
99+
status: "all",
100+
source: "all",
101+
scope: "module",
102+
});
103+
});
104+
105+
it("hides the scope filter and ignores a scope value when no record carries scope", () => {
106+
render(
107+
<DecisionsTable
108+
{...baseProps}
109+
filters={{ status: "all", source: "all", scope: "file" }}
110+
decisions={[
111+
makeDecision({ id: "1", title: "Pick Postgres" }),
112+
makeDecision({ id: "2", title: "Adopt SWR" }),
113+
]}
114+
/>,
115+
);
116+
expect(screen.queryByLabelText("Filter by scope")).not.toBeInTheDocument();
117+
// Rows are NOT filtered out by the stale scope value.
118+
expect(screen.getByText("Pick Postgres")).toBeInTheDocument();
119+
expect(screen.getByText("Adopt SWR")).toBeInTheDocument();
120+
});
121+
122+
it("filters rows client-side by scope", () => {
123+
render(
124+
<DecisionsTable
125+
{...baseProps}
126+
filters={{ status: "all", source: "all", scope: "file" }}
127+
decisions={[
128+
makeDecision({ id: "1", title: "Pick Postgres", scope: "file" }),
129+
makeDecision({ id: "2", title: "Adopt SWR", scope: "cross-module" }),
130+
]}
131+
/>,
132+
);
133+
expect(screen.getByText("Pick Postgres")).toBeInTheDocument();
134+
expect(screen.queryByText("Adopt SWR")).not.toBeInTheDocument();
135+
});
136+
69137
it("renders a retry button when an error is supplied with no decisions", () => {
70138
const onRetry = vi.fn();
71139
render(

packages/ui/src/decisions/decisions-table.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
DecisionRecord,
1515
DecisionStatus,
1616
DecisionSource,
17+
DecisionScope,
1718
} from "@repowise-dev/types/decisions";
1819

1920
const STATUS_VARIANT: Record<string, "default" | "fresh" | "stale" | "outdated" | "outline" | "accent"> = {
@@ -30,12 +31,25 @@ const SOURCE_LABEL: Record<string, string> = {
3031
cli: "Manual",
3132
};
3233

34+
const SCOPE_LABEL: Record<string, string> = {
35+
file: "File",
36+
module: "Module",
37+
"cross-module": "Cross-module",
38+
};
39+
3340
export type DecisionStatusFilter = DecisionStatus | "all";
3441
export type DecisionSourceFilter = DecisionSource | "all";
42+
export type DecisionScopeFilter = DecisionScope | "all";
3543

3644
export interface DecisionsTableFilters {
3745
status: DecisionStatusFilter;
3846
source: DecisionSourceFilter;
47+
/**
48+
* Optional for back-compat with callers that predate scope. Unlike
49+
* status/source (server query params), scope is derived at serialization
50+
* time, so the table filters rows client-side.
51+
*/
52+
scope?: DecisionScopeFilter;
3953
}
4054

4155
export interface DecisionsTableProps {
@@ -71,6 +85,16 @@ export function DecisionsTable({
7185
const prefix = linkPrefix ?? `/repos/${repoId}`;
7286
const Link = LinkComponent;
7387

88+
// Scope is derived at serialization time (no server-side query param), so
89+
// this filter applies client-side to the fetched rows. Backends that don't
90+
// serve scope yet leave every row null: hide the filter (it could only
91+
// empty the table) and ignore any lingering scope value.
92+
const scopeFilter = filters.scope ?? "all";
93+
const hasScope = (decisions ?? []).some((d) => d.scope != null);
94+
const visibleDecisions = (decisions ?? []).filter(
95+
(d) => !hasScope || scopeFilter === "all" || d.scope === scopeFilter,
96+
);
97+
7498
const columns: ResponsiveColumn<DecisionRecord>[] = [
7599
{
76100
key: "title",
@@ -123,6 +147,17 @@ export function DecisionsTable({
123147
cellClassName: "text-[var(--color-text-secondary)]",
124148
render: (d) => SOURCE_LABEL[d.source] ?? d.source,
125149
},
150+
{
151+
key: "scope",
152+
header: "Scope",
153+
priority: 3,
154+
render: (d) =>
155+
d.scope ? (
156+
<Badge variant="outline">{SCOPE_LABEL[d.scope] ?? d.scope}</Badge>
157+
) : (
158+
<span className="text-[var(--color-text-tertiary)]"></span>
159+
),
160+
},
126161
{
127162
key: "trust",
128163
header: "Trust",
@@ -234,11 +269,26 @@ export function DecisionsTable({
234269
<option value="readme_mining">Docs mining</option>
235270
<option value="cli">Manual</option>
236271
</select>
272+
{hasScope && (
273+
<select
274+
value={filters.scope ?? "all"}
275+
onChange={(e) =>
276+
onFiltersChange({ ...filters, scope: e.target.value as DecisionScopeFilter })
277+
}
278+
aria-label="Filter by scope"
279+
className="w-full sm:w-auto rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-3 py-1.5 text-sm text-[var(--color-text-primary)]"
280+
>
281+
<option value="all">All scopes</option>
282+
<option value="file">File</option>
283+
<option value="module">Module</option>
284+
<option value="cross-module">Cross-module</option>
285+
</select>
286+
)}
237287
</div>
238288

239289
<ResponsiveTable
240290
columns={columns}
241-
rows={decisions ?? []}
291+
rows={visibleDecisions}
242292
rowKey={(d) => d.id}
243293
empty={empty}
244294
/>

0 commit comments

Comments
 (0)