Skip to content

Commit 6a9b146

Browse files
feat(mcp): surface alias keys a documented data-shape omits (#780)
The documented-shape path answers "what fields does <blob> contain" at high confidence and tells the caller no verification Read is needed. But a docstring lists the declared shape, which can be a subset of what the records actually carry: consumers often read a legacy alias the doc never mentions. For co_change_partners_json, four consumers defensively read `partner.get("co_change_count") or partner.get("count")` and `... or partner.get("path")`, so `count` and `path` are real keys the documented shape omits. Answering high-confidence "cite it, no Read needed" while hiding those is a confidently-incomplete answer. Mine them and surface them. After the documented shape is chosen, scan the identifier's consumer files for the alias idiom `<recv>.get("<A>") or <recv>.get("<B>")` where one key is a documented field; the other is an alias for it. Requiring the `or` fallback on a shared receiver keeps this tight: an assignment that merely co-mentions a documented key on an unrelated record, or a test assertion, does not match. The extras ride back in a new `data_shape.also_accessed` block (field + file:line), and the answer text names them so the agent handles them instead of trusting a subset. The documented fields stay authoritative; confidence stays high on the declared core. Adds three tests: an omitted `or`-fallback alias is surfaced, a clean shape with no divergence carries no also_accessed, and a cross-record co-mention (an assignment, not a fallback) is correctly not reported as an alias.
1 parent 199293a commit 6a9b146

3 files changed

Lines changed: 205 additions & 13 deletions

File tree

packages/server/src/repowise/server/mcp_server/tool_answer/answer.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -303,21 +303,43 @@ def _build_data_shape_payload(grounded: dict, t0: float, repository) -> dict:
303303
ident = grounded["identifier"]
304304
fields = grounded["fields"]
305305
sources = grounded["sources"]
306+
also_accessed = grounded.get("also_accessed") or []
306307
citations = sorted({s["file"] for s in sources})
307308
field_list = ", ".join(f"`{f}`" for f in fields)
308309
doc_src = next((s for s in sources if s["kind"] == "docstring"), None)
309310
if grounded["grounding"] == "docstring":
310311
where = f"{doc_src['file']}:{doc_src['line']}" if doc_src else citations[0]
311-
answer = (
312-
f"Each entry in `{ident}` has {len(fields)} field(s): {field_list}. "
313-
f"This is the documented shape at {where}; cite it directly, no "
314-
"verification Read needed."
315-
)
316-
note = (
317-
"Grounded in the documented field shape mined from source (the "
318-
"quoted keys in the docstring/comment at the cited line). "
319-
"data_shape.sources lists every field's origin line."
320-
)
312+
if also_accessed:
313+
# The doc lists the declared shape, but consumers read alias key(s)
314+
# it omits (a legacy fallback). Surface them: telling the agent "no
315+
# Read needed" while hiding a key it must handle would be a
316+
# confidently-incomplete answer.
317+
alias_list = ", ".join(f"`{a['field']}`" for a in also_accessed)
318+
first_alias = also_accessed[0]
319+
answer = (
320+
f"Each entry in `{ident}` has {len(fields)} documented field(s): "
321+
f"{field_list} (documented shape at {where}). Consumers also read "
322+
f"{alias_list} as a fallback (e.g. {first_alias['file']}:"
323+
f"{first_alias['line']}) - an alias the docstring omits, so handle "
324+
f"{alias_list} too if you touch this."
325+
)
326+
note = (
327+
"Grounded in the documented field shape, plus alias key(s) "
328+
"consumers read beside a documented field that the docstring "
329+
"omits (see data_shape.also_accessed). The documented fields are "
330+
"authoritative; the aliases are real keys the code defends against."
331+
)
332+
else:
333+
answer = (
334+
f"Each entry in `{ident}` has {len(fields)} field(s): {field_list}. "
335+
f"This is the documented shape at {where}; cite it directly, no "
336+
"verification Read needed."
337+
)
338+
note = (
339+
"Grounded in the documented field shape mined from source (the "
340+
"quoted keys in the docstring/comment at the cited line). "
341+
"data_shape.sources lists every field's origin line."
342+
)
321343
else:
322344
first = sources[0]
323345
answer = (
@@ -340,6 +362,7 @@ def _build_data_shape_payload(grounded: dict, t0: float, repository) -> dict:
340362
"identifier": ident,
341363
"fields": fields,
342364
"sources": sources,
365+
**({"also_accessed": also_accessed} if also_accessed else {}),
343366
},
344367
"fallback_targets": citations,
345368
"retrieval": [],

packages/server/src/repowise/server/mcp_server/tool_answer/data_shape.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,29 @@ def _order_candidates(files: list[str]) -> list[str]:
142142
)
143143

144144

145-
def _run_grep(repo_root: Path, args: list[str], identifier: str) -> subprocess.CompletedProcess | None:
145+
def _run_grep(
146+
repo_root: Path, args: list[str], identifier: str
147+
) -> subprocess.CompletedProcess | None:
146148
"""Run a bounded, transport-safe ``git grep`` variant; ``None`` on failure."""
147149
try:
148150
return subprocess.run(
149151
# --no-pager + stdin=DEVNULL are load-bearing: this can run inside a
150152
# stdio MCP server whose stdin IS the JSON-RPC pipe, so a pager that
151153
# reads stdin would deadlock the transport.
152-
["git", "--no-pager", "grep", *args, "-l", "-I", "-F", "-w", "-e", identifier, "--", *_GREP_PATHSPECS],
154+
[
155+
"git",
156+
"--no-pager",
157+
"grep",
158+
*args,
159+
"-l",
160+
"-I",
161+
"-F",
162+
"-w",
163+
"-e",
164+
identifier,
165+
"--",
166+
*_GREP_PATHSPECS,
167+
],
153168
cwd=str(repo_root),
154169
capture_output=True,
155170
text=True,
@@ -259,6 +274,40 @@ def _doc_shape_in_file(
259274
return None
260275

261276

277+
# A ``<receiver>.get("<key>")`` access - group(1) receiver, group(2) key.
278+
_GET_ACCESS = re.compile(r"([A-Za-z_]\w*)\s*\.\s*get\(\s*['\"]([A-Za-z_]\w*)['\"]")
279+
280+
281+
def _alias_keys_on_documented_lines(
282+
lines: list[str], doc_fields: set[str]
283+
) -> list[tuple[str, int]]:
284+
"""Alias keys a documented field is read as a fallback for.
285+
286+
Targets one idiom precisely: ``<recv>.get("<A>") or <recv>.get("<B>")`` - the
287+
same receiver reads two keys joined by ``or``, so when one is a documented
288+
field the other is an alias for it (``partner.get("co_change_count") or
289+
partner.get("count")`` -> ``count``; ``... or partner.get("path")`` ->
290+
``path``). Requiring the ``or`` fallback and a shared receiver keeps this tight:
291+
an assignment that merely co-mentions a documented key on a different record
292+
(``meta["prior_defect_count"] = ...meta["file_path"]``) or a test assertion
293+
does not match. Returns ``(alias, line)`` for keys not in ``doc_fields``.
294+
"""
295+
out: list[tuple[str, int]] = []
296+
for idx, line in enumerate(lines, 1):
297+
if " or " not in line:
298+
continue
299+
by_recv: dict[str, list[str]] = {}
300+
for m in _GET_ACCESS.finditer(line):
301+
by_recv.setdefault(m.group(1), []).append(m.group(2))
302+
for keys in by_recv.values():
303+
if len(keys) < 2 or not any(k in doc_fields for k in keys):
304+
continue
305+
for k in keys:
306+
if k not in doc_fields:
307+
out.append((k, idx))
308+
return out
309+
310+
262311
def _accessed_fields_in_file(
263312
lines: list[str], identifier: str, mentions: list[int]
264313
) -> list[tuple[str, int]]:
@@ -323,6 +372,9 @@ def mine_data_shape(repo_root: Path | None, question_ids: set[str]) -> dict | No
323372
"grounding": "docstring" | "access",
324373
"confidence": "high" | "medium",
325374
"sources": [{"file", "line", "kind"}], # kind in {docstring, access}
375+
# docstring grounding only, and only when present: keys consumers read
376+
# beside a documented field that the doc omits (aliases / optional keys)
377+
"also_accessed": [{"field", "file", "line"}],
326378
}
327379
"""
328380
if repo_root is None:
@@ -344,6 +396,7 @@ def mine_data_shape(repo_root: Path | None, question_ids: set[str]) -> dict | No
344396
access_fields: list[str] = []
345397
access_sources: list[dict] = []
346398
access_seen: set[str] = set()
399+
file_lines: dict[str, list[str]] = {} # cached for the divergence pass
347400

348401
for rel in files:
349402
lines = _read_lines(root, rel)
@@ -352,6 +405,7 @@ def mine_data_shape(repo_root: Path | None, question_ids: set[str]) -> dict | No
352405
mentions = _mention_lines(lines, identifier)
353406
if not mentions:
354407
continue
408+
file_lines[rel] = lines
355409
doc = _doc_shape_in_file(lines, identifier, mentions)
356410
if doc is not None:
357411
fields, line = doc
@@ -387,13 +441,30 @@ def _rank(item: tuple[frozenset, list[tuple[list[str], str, int]]]):
387441
if any(src["file"] == s["file"] for s in sources):
388442
continue
389443
sources.append(src)
390-
return {
444+
# Divergence: keys consumers read right beside a documented field but
445+
# the doc never lists (a legacy alias like ``count`` for
446+
# ``co_change_count``, an optional key). The documented shape is
447+
# authoritative for what it declares, but if we said "cite it, no Read
448+
# needed" while hiding a key four consumers defensively handle, an
449+
# agent could ship a change that ignores it. Surface it instead.
450+
doc_field_set = set(fields)
451+
also_accessed: list[dict] = []
452+
also_seen: set[str] = set()
453+
for rel, lines in file_lines.items():
454+
for key, line in _alias_keys_on_documented_lines(lines, doc_field_set):
455+
if key not in also_seen:
456+
also_seen.add(key)
457+
also_accessed.append({"field": key, "file": rel, "line": line})
458+
result: dict = {
391459
"identifier": identifier,
392460
"fields": fields,
393461
"grounding": "docstring",
394462
"confidence": "high",
395463
"sources": sources,
396464
}
465+
if also_accessed:
466+
result["also_accessed"] = also_accessed
467+
return result
397468

398469
# No documented shape - fall back to consistent key accesses.
399470
if len(access_fields) >= _DATA_SHAPE_MIN_FIELDS:

tests/unit/server/mcp/test_answer_data_shape.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,5 +237,103 @@ def consume(event_payload_json):
237237
assert out["fields"] == ["kind", "ts", "actor"]
238238

239239

240+
# --- Alias divergence (doc omits a key consumers read as a fallback) -------
241+
242+
243+
def test_documented_shape_surfaces_alias_fallback(tmp_path):
244+
"""A doc shape that omits an ``or``-fallback alias must surface the alias.
245+
246+
The docstring is authoritative for what it declares, but if a consumer reads
247+
``x.get("weight_json") or x.get("weight")`` the ``weight`` alias is a real key
248+
the tool must not hide behind "no verification needed".
249+
"""
250+
_write(
251+
tmp_path,
252+
"pkg/models.py",
253+
'''\
254+
class M:
255+
"""``metric_rows_json`` is a JSON list of
256+
``{"file_path", "weight_json", "ts"}`` metric records."""
257+
258+
metric_rows_json: str
259+
''',
260+
)
261+
_write(
262+
tmp_path,
263+
"pkg/consume.py",
264+
"""\
265+
def use(metric_rows_json):
266+
for row in parse(metric_rows_json):
267+
w = row.get("weight_json") or row.get("weight") or 0
268+
return w
269+
""",
270+
)
271+
out = mine_data_shape(tmp_path, {"metric_rows_json"})
272+
assert out is not None
273+
assert out["grounding"] == "docstring"
274+
assert out["fields"] == ["file_path", "weight_json", "ts"]
275+
aliases = {a["field"] for a in out.get("also_accessed", [])}
276+
assert aliases == {"weight"}
277+
assert out["also_accessed"][0]["file"] == "pkg/consume.py"
278+
279+
280+
def test_no_alias_key_when_none_diverges(tmp_path):
281+
"""A clean documented shape with no fallback alias carries no also_accessed."""
282+
_write(
283+
tmp_path,
284+
"pkg/clean.py",
285+
'''\
286+
class M:
287+
"""``clean_rows_json`` is a JSON list of
288+
``{"file_path", "score", "ts"}`` records."""
289+
290+
clean_rows_json: str
291+
292+
293+
def use(clean_rows_json):
294+
for r in parse(clean_rows_json):
295+
s = r.get("score")
296+
return s
297+
''',
298+
)
299+
out = mine_data_shape(tmp_path, {"clean_rows_json"})
300+
assert out is not None
301+
assert "also_accessed" not in out
302+
303+
304+
def test_alias_precision_ignores_cross_record_comention(tmp_path):
305+
"""A documented key co-mentioned on a different record is NOT an alias.
306+
307+
``meta["other_count"] = src[meta["file_path"]]`` reads a documented field
308+
(``file_path``) and another key on the same line, but it's an assignment on an
309+
unrelated record, not an ``or`` fallback - it must not be reported as an alias.
310+
"""
311+
_write(
312+
tmp_path,
313+
"pkg/models.py",
314+
'''\
315+
class M:
316+
"""``rows_json`` is a JSON list of
317+
``{"file_path", "amount", "ts"}`` records."""
318+
319+
rows_json: str
320+
''',
321+
)
322+
_write(
323+
tmp_path,
324+
"pkg/enrich.py",
325+
"""\
326+
def enrich(rows_json, meta, src):
327+
for row in parse(rows_json):
328+
amount = row.get("amount")
329+
meta["other_count"] = src[meta["file_path"]]
330+
return amount, meta
331+
""",
332+
)
333+
out = mine_data_shape(tmp_path, {"rows_json"})
334+
assert out is not None
335+
assert "also_accessed" not in out
336+
337+
240338
def test_none_repo_root_is_safe():
241339
assert mine_data_shape(None, {"anything_json"}) is None

0 commit comments

Comments
 (0)