1111from 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)
3941)
4042from supekku .scripts .lib .formatters .spec_formatters import format_spec_details
4143from supekku .scripts .lib .formatters .standard_formatters import format_standard_details
44+ from supekku .scripts .lib .formatters .relation_formatters import format_related_section
4245from 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+
44119app = 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 } \n Name: { r .title } \n Status: { r .status } \n Kind: { 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 } \n Name: { r .title } \n Status: { r .status } \n Kind: { 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