Skip to content

Commit 083a0e7

Browse files
author
clanker
committed
DE-090 P06: implement show --related neighbourhood view
Add --related flag to show spec, show delta, show requirement, show issue (the four primary navigation anchors per DEC-090-13). Implementation: - format_related_section() in relation_formatters.py — pure formatter for 'Referenced by' section grouped by kind - _gather_reverse_refs() / _gather_forward_refs() helpers in show.py — per-kind registry loading (DEC-090-15), lazy imports - _PREFIX_TO_KIND / _RELATED_REGISTRIES lookup tables for kind inference and per-entity registry selection - show spec --related replaces count view (DEC-090-08, internal review #3) - show delta --related extends existing audit/revision lookups with backlog - show requirement --related adds new reverse lookup capability - show issue --related loads delta registry - --related --json adds 'related' key with forward + referenced_by structure Design decisions followed: DEC-090-12 (one-hop only), DEC-090-13 (4 anchor kinds), DEC-090-15 (per-kind registry loading) Tests: 13 new tests (5 formatter, 8 integration via CliRunner) - VT-090-P5-1: show spec/requirement --related one-hop neighbourhood - VT-090-P5-2: show delta --related - VT-090-P5-3: --related --json output structure (spec, delta, issue) - VT-090-P5-4: --related replaces count view - VT-090-P5-5: no references → no Referenced by section All 318 related tests pass (show_test, relation_formatters_test, spec_formatters_test, change_formatters_test, common_test).
1 parent 1a9c6ee commit 083a0e7

4 files changed

Lines changed: 375 additions & 41 deletions

File tree

supekku/cli/show.py

Lines changed: 214 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
from supekku.cli.common import (
1212
EXIT_FAILURE,
1313
EXIT_SUCCESS,
14+
ARTIFACT_PREFIXES,
1415
ArtifactNotFoundError,
1516
ArtifactRef,
1617
ContentTypeOption,
1718
InferringGroup,
1819
RootOption,
1920
emit_artifact,
21+
load_all_artifacts,
2022
resolve_artifact,
2123
resolve_by_id,
2224
)
@@ -39,8 +41,81 @@
3941
)
4042
from supekku.scripts.lib.formatters.spec_formatters import format_spec_details
4143
from supekku.scripts.lib.formatters.standard_formatters import format_standard_details
44+
from supekku.scripts.lib.formatters.relation_formatters import format_related_section
4245
from supekku.scripts.lib.memory.registry import MemoryRegistry
4346

47+
# Reverse mapping: ID prefix → artifact kind for forward reference grouping.
48+
_PREFIX_TO_KIND: dict[str, str] = {v: k for k, v in ARTIFACT_PREFIXES.items()}
49+
# Spec prefixes are not in ARTIFACT_PREFIXES — add common ones.
50+
_PREFIX_TO_KIND.update({
51+
"SPEC-": "spec",
52+
"PROD-": "spec",
53+
})
54+
55+
# Per-kind reverse lookup registries (DEC-090-15).
56+
_RELATED_REGISTRIES: dict[str, tuple[str, ...]] = {
57+
"spec": ("delta", "revision", "audit", "adr", "requirement", "policy", "standard"),
58+
"delta": ("audit", "revision", "backlog"),
59+
"requirement": ("delta", "adr", "policy", "standard"),
60+
"issue": ("delta",),
61+
}
62+
63+
64+
def _infer_kind_from_id(target_id: str) -> str:
65+
"""Infer artifact kind from a target ID prefix.
66+
67+
Falls back to ``"unknown"`` if no prefix matches.
68+
"""
69+
upper = target_id.upper()
70+
for prefix, kind in _PREFIX_TO_KIND.items():
71+
if upper.startswith(prefix):
72+
return kind
73+
return "unknown"
74+
75+
76+
def _gather_reverse_refs(
77+
repo_root: Path,
78+
entity_id: str,
79+
entity_kind: str,
80+
) -> dict[str, list[tuple[str, str]]]:
81+
"""Gather reverse references grouped by kind for ``--related``.
82+
83+
Loads only registries relevant to *entity_kind* per DEC-090-15.
84+
85+
Returns:
86+
Dict mapping kind → [(id, name), ...] for each referencing artifact.
87+
"""
88+
from supekku.scripts.lib.relations.query import find_related_to # noqa: PLC0415
89+
90+
registry_kinds = _RELATED_REGISTRIES.get(entity_kind, ())
91+
result: dict[str, list[tuple[str, str]]] = {}
92+
for kind in registry_kinds:
93+
try:
94+
artifacts = load_all_artifacts(repo_root, kind)
95+
related = find_related_to(artifacts, entity_id)
96+
if related:
97+
result[kind] = [
98+
(getattr(a, "id", getattr(a, "uid", "?")),
99+
getattr(a, "name", getattr(a, "title", "")))
100+
for a in related
101+
]
102+
except (FileNotFoundError, ValueError):
103+
continue # Registry not available — skip silently
104+
return result
105+
106+
107+
def _gather_forward_refs(
108+
artifact: Any,
109+
) -> list[dict[str, str]]:
110+
"""Gather forward references from *artifact* for ``--related`` JSON."""
111+
from supekku.scripts.lib.relations.query import collect_references # noqa: PLC0415
112+
113+
return [
114+
{"type": hit.detail or "", "target": hit.target, "source": hit.source}
115+
for hit in collect_references(artifact)
116+
]
117+
118+
44119
app = typer.Typer(
45120
help="Show detailed artifact information",
46121
no_args_is_help=True,
@@ -59,6 +134,9 @@ def show_spec( # noqa: PLR0913
59134
requirements: Annotated[
60135
bool, typer.Option("--requirements", help="Show full requirements list")
61136
] = False,
137+
related: Annotated[
138+
bool, typer.Option("--related", help="Show one-hop neighbourhood (forward + reverse references)")
139+
] = False,
62140
content_type: ContentTypeOption = None,
63141
root: RootOption = None,
64142
) -> None:
@@ -94,40 +172,51 @@ def show_spec( # noqa: PLR0913
94172
except (FileNotFoundError, ValueError):
95173
pass # No requirements registry — counts stay at zero
96174

97-
# Reverse lookup counts: which change artifacts reference this spec
175+
# Reverse lookup: counts for default view, full neighbourhood for --related
98176
delta_count = revision_count = audit_count = 0
99-
try:
100-
from supekku.scripts.lib.changes.registry import ChangeRegistry # noqa: PLC0415
101-
from supekku.scripts.lib.relations.query import find_related_to # noqa: PLC0415
102-
103-
for kind in ("delta", "revision", "audit"):
104-
change_reg = ChangeRegistry(root=repo_root, kind=kind)
105-
related = find_related_to(change_reg.iter(), ref.id)
106-
if kind == "delta":
107-
delta_count = len(related)
108-
elif kind == "revision":
109-
revision_count = len(related)
110-
else:
111-
audit_count = len(related)
112-
except (FileNotFoundError, ValueError):
113-
pass # No change registry — counts stay at zero
177+
reverse_refs: dict[str, list[tuple[str, str]]] = {}
178+
if related:
179+
reverse_refs = _gather_reverse_refs(repo_root, ref.id, "spec")
180+
delta_count = len(reverse_refs.get("delta", []))
181+
revision_count = len(reverse_refs.get("revision", []))
182+
audit_count = len(reverse_refs.get("audit", []))
183+
else:
184+
try:
185+
from supekku.scripts.lib.changes.registry import ChangeRegistry # noqa: PLC0415
186+
from supekku.scripts.lib.relations.query import find_related_to # noqa: PLC0415
187+
188+
for kind in ("delta", "revision", "audit"):
189+
change_reg = ChangeRegistry(root=repo_root, kind=kind)
190+
related_arts = find_related_to(change_reg.iter(), ref.id)
191+
if kind == "delta":
192+
delta_count = len(related_arts)
193+
elif kind == "revision":
194+
revision_count = len(related_arts)
195+
else:
196+
audit_count = len(related_arts)
197+
except (FileNotFoundError, ValueError):
198+
pass # No change registry — counts stay at zero
114199

115200
def _format(r): # type: ignore[no-untyped-def]
116-
return format_spec_details(
201+
base = format_spec_details(
117202
r,
118203
root=root,
119204
fr_count=fr_count,
120205
nf_count=nf_count,
121206
other_req_count=other_req_count,
122-
delta_count=delta_count,
123-
revision_count=revision_count,
124-
audit_count=audit_count,
207+
delta_count=0 if related else delta_count,
208+
revision_count=0 if related else revision_count,
209+
audit_count=0 if related else audit_count,
125210
requirements_list=requirements_list,
126211
)
212+
if related:
213+
related_lines = format_related_section(reverse_refs)
214+
return base + "\n".join(related_lines)
215+
return base
127216

128217
def _json(r): # type: ignore[no-untyped-def]
129218
data = r.to_dict(repo_root)
130-
if delta_count or revision_count or audit_count:
219+
if not related and (delta_count or revision_count or audit_count):
131220
data["reverse_lookup_counts"] = {
132221
"deltas": delta_count,
133222
"revisions": revision_count,
@@ -138,6 +227,14 @@ def _json(r): # type: ignore[no-untyped-def]
138227
{"id": rid, "kind": kind, "title": title}
139228
for rid, kind, title in requirements_list
140229
]
230+
if related:
231+
data["related"] = {
232+
"forward": _gather_forward_refs(ref.record),
233+
"referenced_by": {
234+
kind: [{"id": aid, "name": aname} for aid, aname in refs]
235+
for kind, refs in reverse_refs.items()
236+
},
237+
}
141238
return json.dumps(data, indent=2)
142239

143240
emit_artifact(
@@ -162,6 +259,9 @@ def show_delta(
162259
raw_output: Annotated[
163260
bool, typer.Option("--raw", help="Output raw file content")
164261
] = False,
262+
related: Annotated[
263+
bool, typer.Option("--related", help="Show one-hop neighbourhood (forward + reverse references)")
264+
] = False,
165265
content_type: ContentTypeOption = None,
166266
root: RootOption = None,
167267
) -> None:
@@ -173,24 +273,37 @@ def show_delta(
173273
# Reverse lookups: audits and revisions referencing this delta
174274
linked_audits: list[tuple[str, str]] = []
175275
linked_revisions: list[tuple[str, str]] = []
176-
try:
177-
from supekku.scripts.lib.changes.registry import ChangeRegistry # noqa: PLC0415
178-
from supekku.scripts.lib.relations.query import find_related_to # noqa: PLC0415
276+
reverse_refs: dict[str, list[tuple[str, str]]] = {}
277+
if related:
278+
reverse_refs = _gather_reverse_refs(repo_root, ref.id, "delta")
279+
linked_audits = reverse_refs.get("audit", [])
280+
linked_revisions = reverse_refs.get("revision", [])
281+
else:
282+
try:
283+
from supekku.scripts.lib.changes.registry import ChangeRegistry # noqa: PLC0415
284+
from supekku.scripts.lib.relations.query import find_related_to # noqa: PLC0415
179285

180-
for kind, dest in (("audit", linked_audits), ("revision", linked_revisions)):
181-
change_reg = ChangeRegistry(root=repo_root, kind=kind)
182-
for artifact in find_related_to(change_reg.iter(), ref.id):
183-
dest.append((artifact.id, artifact.name))
184-
except (FileNotFoundError, ValueError):
185-
pass
286+
for kind, dest in (("audit", linked_audits), ("revision", linked_revisions)):
287+
change_reg = ChangeRegistry(root=repo_root, kind=kind)
288+
for artifact in find_related_to(change_reg.iter(), ref.id):
289+
dest.append((artifact.id, artifact.name))
290+
except (FileNotFoundError, ValueError):
291+
pass
186292

187293
def _format(r): # type: ignore[no-untyped-def]
188-
return format_delta_details(
294+
base = format_delta_details(
189295
r,
190296
root=root,
191297
linked_audits=linked_audits,
192298
linked_revisions=linked_revisions,
193299
)
300+
if related:
301+
# Append full neighbourhood (excluding audit/revision already shown)
302+
extra_refs = {k: v for k, v in reverse_refs.items() if k not in ("audit", "revision")}
303+
if extra_refs:
304+
related_lines = format_related_section(extra_refs)
305+
return base + "\n".join(related_lines)
306+
return base
194307

195308
def _json(r): # type: ignore[no-untyped-def]
196309
data = format_delta_details_json(r, root=root)
@@ -206,6 +319,14 @@ def _json(r): # type: ignore[no-untyped-def]
206319
parsed["linked_revisions"] = [
207320
{"id": rid, "name": rname} for rid, rname in linked_revisions
208321
]
322+
if related:
323+
parsed["related"] = {
324+
"forward": _gather_forward_refs(ref.record),
325+
"referenced_by": {
326+
kind: [{"id": aid, "name": aname} for aid, aname in refs]
327+
for kind, refs in reverse_refs.items()
328+
},
329+
}
209330
return _json_mod.dumps(parsed, indent=2)
210331

211332
emit_artifact(
@@ -251,27 +372,55 @@ def show_revision(
251372

252373

253374
@app.command("requirement")
254-
def show_requirement(
375+
def show_requirement( # noqa: PLR0913
255376
req_id: Annotated[str, typer.Argument(help="Requirement ID (e.g., SPEC-009.FR-001)")],
256377
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
257378
path_only: Annotated[bool, typer.Option("--path", help="Output path only")] = False,
258379
raw_output: Annotated[
259380
bool, typer.Option("--raw", help="Output raw file content")
260381
] = False,
382+
related: Annotated[
383+
bool, typer.Option("--related", help="Show one-hop neighbourhood (forward + reverse references)")
384+
] = False,
261385
content_type: ContentTypeOption = None,
262386
root: RootOption = None,
263387
) -> None:
264388
"""Show detailed information about a requirement."""
265389
try:
266390
ref = resolve_artifact("requirement", req_id, root)
391+
repo_root = find_repo_root(root)
392+
393+
reverse_refs: dict[str, list[tuple[str, str]]] = {}
394+
if related:
395+
reverse_refs = _gather_reverse_refs(repo_root, ref.id, "requirement")
396+
397+
def _format(r): # type: ignore[no-untyped-def]
398+
base = format_requirement_details(r)
399+
if related:
400+
related_lines = format_related_section(reverse_refs)
401+
return base + "\n".join(related_lines)
402+
return base
403+
404+
def _json(r): # type: ignore[no-untyped-def]
405+
data = r.to_dict()
406+
if related:
407+
data["related"] = {
408+
"forward": _gather_forward_refs(ref.record),
409+
"referenced_by": {
410+
kind: [{"id": aid, "name": aname} for aid, aname in refs]
411+
for kind, refs in reverse_refs.items()
412+
},
413+
}
414+
return json.dumps(data, indent=2)
415+
267416
emit_artifact(
268417
ref,
269418
json_output=json_output,
270419
path_only=path_only,
271420
raw_output=raw_output,
272421
content_type=content_type,
273-
format_fn=format_requirement_details,
274-
json_fn=lambda r: json.dumps(r.to_dict(), indent=2),
422+
format_fn=_format,
423+
json_fn=_json,
275424
)
276425
except ArtifactNotFoundError as e:
277426
typer.echo(f"Error: {e}", err=True)
@@ -658,29 +807,55 @@ def show_drift(
658807

659808

660809
@app.command("issue")
661-
def show_issue(
810+
def show_issue( # noqa: PLR0913
662811
issue_id: Annotated[str, typer.Argument(help="Issue ID (e.g., ISSUE-001)")],
663812
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
664813
path_only: Annotated[bool, typer.Option("--path", help="Output path only")] = False,
665814
raw_output: Annotated[
666815
bool, typer.Option("--raw", help="Output raw file content")
667816
] = False,
817+
related: Annotated[
818+
bool, typer.Option("--related", help="Show one-hop neighbourhood (forward + reverse references)")
819+
] = False,
668820
content_type: ContentTypeOption = None,
669821
root: RootOption = None,
670822
) -> None:
671823
"""Show detailed information about an issue."""
672824
try:
673825
ref = resolve_artifact("issue", issue_id, root)
826+
repo_root = find_repo_root(root)
827+
828+
reverse_refs: dict[str, list[tuple[str, str]]] = {}
829+
if related:
830+
reverse_refs = _gather_reverse_refs(repo_root, ref.id, "issue")
831+
832+
def _format(r): # type: ignore[no-untyped-def]
833+
base = f"Issue: {r.id}\nName: {r.title}\nStatus: {r.status}\nKind: {r.kind}"
834+
if related:
835+
related_lines = format_related_section(reverse_refs)
836+
return base + "\n".join(related_lines)
837+
return base
838+
839+
def _json(r): # type: ignore[no-untyped-def]
840+
data = r.to_dict()
841+
if related:
842+
data["related"] = {
843+
"forward": _gather_forward_refs(ref.record),
844+
"referenced_by": {
845+
kind: [{"id": aid, "name": aname} for aid, aname in refs]
846+
for kind, refs in reverse_refs.items()
847+
},
848+
}
849+
return json.dumps(data, indent=2, default=str)
850+
674851
emit_artifact(
675852
ref,
676853
json_output=json_output,
677854
path_only=path_only,
678855
raw_output=raw_output,
679856
content_type=content_type,
680-
format_fn=lambda r: (
681-
f"Issue: {r.id}\nName: {r.title}\nStatus: {r.status}\nKind: {r.kind}"
682-
),
683-
json_fn=lambda r: json.dumps(r.to_dict(), indent=2, default=str),
857+
format_fn=_format,
858+
json_fn=_json,
684859
)
685860
except ArtifactNotFoundError as e:
686861
typer.echo(f"Error: {e}", err=True)

0 commit comments

Comments
 (0)