throughline 2.0.0__tar.gz → 2.2.0__tar.gz

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 (35) hide show
  1. {throughline-2.0.0/src/throughline.egg-info → throughline-2.2.0}/PKG-INFO +2 -2
  2. {throughline-2.0.0 → throughline-2.2.0}/README.md +1 -1
  3. {throughline-2.0.0 → throughline-2.2.0}/pyproject.toml +1 -1
  4. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/cli.py +134 -3
  5. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/graph.py +82 -10
  6. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/model.py +40 -0
  7. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/storage.py +101 -20
  8. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/validate.py +17 -35
  9. {throughline-2.0.0 → throughline-2.2.0/src/throughline.egg-info}/PKG-INFO +2 -2
  10. {throughline-2.0.0 → throughline-2.2.0}/tests/test_engine.py +396 -1
  11. {throughline-2.0.0 → throughline-2.2.0}/LICENSE +0 -0
  12. {throughline-2.0.0 → throughline-2.2.0}/NOTICE +0 -0
  13. {throughline-2.0.0 → throughline-2.2.0}/setup.cfg +0 -0
  14. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/__init__.py +0 -0
  15. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/dump.py +0 -0
  16. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/filters.py +0 -0
  17. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/fingerprint.py +0 -0
  18. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/grounding.py +0 -0
  19. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/identity.py +0 -0
  20. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/inject.py +0 -0
  21. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/schema.py +0 -0
  22. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/schema_ops.py +0 -0
  23. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/tomledit.py +0 -0
  24. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/uid.py +0 -0
  25. {throughline-2.0.0 → throughline-2.2.0}/src/throughline/version.py +0 -0
  26. {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/SOURCES.txt +0 -0
  27. {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/dependency_links.txt +0 -0
  28. {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/entry_points.txt +0 -0
  29. {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/requires.txt +0 -0
  30. {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/top_level.txt +0 -0
  31. {throughline-2.0.0 → throughline-2.2.0}/tests/test_amend.py +0 -0
  32. {throughline-2.0.0 → throughline-2.2.0}/tests/test_doctor.py +0 -0
  33. {throughline-2.0.0 → throughline-2.2.0}/tests/test_filters.py +0 -0
  34. {throughline-2.0.0 → throughline-2.2.0}/tests/test_ratify_status.py +0 -0
  35. {throughline-2.0.0 → throughline-2.2.0}/tests/test_schema_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check.
5
5
  Author-email: Henry J Grech-Cini <henry.grechcini@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -36,7 +36,7 @@ version control; a `check` command validates the whole graph and gates CI.
36
36
 
37
37
  **Dogfooded:** throughline's own spec is itself a throughline project —
38
38
  <!-- tl:count type == 'system_requirement' -->
39
- 153
39
+ 156
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
42
  25
@@ -8,7 +8,7 @@ version control; a `check` command validates the whole graph and gates CI.
8
8
 
9
9
  **Dogfooded:** throughline's own spec is itself a throughline project —
10
10
  <!-- tl:count type == 'system_requirement' -->
11
- 153
11
+ 156
12
12
  <!-- tl:end --> system requirements,
13
13
  <!-- tl:count type == 'user_requirement' -->
14
14
  25
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "throughline"
7
- version = "2.0.0"
7
+ version = "2.2.0"
8
8
  description = "A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -165,7 +165,8 @@ def cmd_migrate(args) -> int:
165
165
  print(f"migrated project from format version {result.start} to {result.end}")
166
166
  elif result.repaired is None:
167
167
  print(f"already at format version {result.end}"
168
- + ("" if result.bound or result.declared else " — nothing to migrate"))
168
+ + ("" if result.bound or result.declared or result.routed
169
+ else " — nothing to migrate"))
169
170
  else:
170
171
  # Already at this major, but missing configuration the major requires —
171
172
  # repaired in place. Name every binding written so the change is never
@@ -186,6 +187,17 @@ def cmd_migrate(args) -> int:
186
187
  "in use; narrow them deliberately with `tl schema`:")
187
188
  for key, values in result.declared.items():
188
189
  print(f" {key} = {', '.join(values)}")
190
+ # Routes to suspicion the repair restored (SR-0188). Named per status, and kept
191
+ # apart from the vocabularies above, because this is the one part of the repair
192
+ # that widens what the lifecycle permits — an operator who did not want a
193
+ # particular status able to move must be able to see which ones changed.
194
+ if result.routed:
195
+ print("restored this lifecycle's route to suspicion — an item in each "
196
+ "status below could never be marked suspect, so withdrawing what it "
197
+ "grounds in left it unflagged; narrow it back with `tl schema "
198
+ "transition deny`:")
199
+ for status, suspect in result.routed.items():
200
+ print(f" {status} -> {suspect}")
189
201
  # Records the repair completed. Named for the same reason the config bindings
190
202
  # are, and more so: this wrote to an accountability record, so an operator who
191
203
  # disagrees with a stamp must be able to see which item carries it (SR-0152).
@@ -1324,6 +1336,7 @@ _CTX_COMMAND_USAGE = {
1324
1336
  "ratify": "tl ratify <UID> --by <who> [--by-id <scheme:value>]",
1325
1337
  "trace": "tl trace <UID> [--direction in|out]",
1326
1338
  "blast": "tl blast <UID>",
1339
+ "subgraph": "tl subgraph <UID> [--depth N] [--link-type KIND] [--format json]",
1327
1340
  "shape": "tl shape [--format json]",
1328
1341
  "dump": "tl dump [-o FILE]",
1329
1342
  "diagram": "tl diagram [types|transitions|both]",
@@ -1337,7 +1350,7 @@ _CTX_COMMAND_USAGE = {
1337
1350
  "migrate": "tl migrate",
1338
1351
  "review": "tl review",
1339
1352
  "init": "tl init [--demo]",
1340
- "context": "tl context",
1353
+ "context": "tl context [<UID>]",
1341
1354
  }
1342
1355
 
1343
1356
  # Commands whose importance is not evident from a one-line help string, and which
@@ -1476,12 +1489,42 @@ def _context_markdown(project) -> str:
1476
1489
  return "\n\n".join(sections) + "\n"
1477
1490
 
1478
1491
 
1492
+ def context_item_section(project, view, *, uid_display=None) -> str:
1493
+ """The `<UID>` section of the brief, as markdown (SR-0190).
1494
+
1495
+ Both `tl context <UID>` and tl-compose's union-aware version render through
1496
+ here, so the wording an agent is trained on cannot differ between the two
1497
+ tools — only the graph the neighbourhood was computed over does.
1498
+ """
1499
+ show = uid_display or (lambda u: u)
1500
+ lines: list[str] = []
1501
+ render_subgraph(project, view, uid_display=uid_display, emit=lines.append)
1502
+ body = "\n".join(lines)
1503
+ return (
1504
+ f"## The item you were given: {show(view.start)}\n\n"
1505
+ "Its neighbourhood — what it rests on, what rests on it, and the links "
1506
+ "joining them. Anything listed under *depended on by* may be affected by "
1507
+ "your change.\n\n"
1508
+ f"```\n{body}\n```\n")
1509
+
1510
+
1479
1511
  def cmd_context(args) -> int:
1480
1512
  try:
1481
1513
  project = load_project(args.path)
1482
1514
  except ProjectError as e:
1483
1515
  return _err(str(e))
1484
- sys.stdout.write(_context_markdown(project))
1516
+ uid = getattr(args, "uid", None)
1517
+ # With no UID the output stays byte-for-byte what it has always been (SR-0190),
1518
+ # so every existing caller and script sees no change.
1519
+ if uid is None:
1520
+ sys.stdout.write(_context_markdown(project))
1521
+ sys.stdout.flush()
1522
+ return OK
1523
+ if project.get(uid) is None:
1524
+ return _err(f"{uid} does not exist")
1525
+ view = Index.build(project).subgraph(uid)
1526
+ sys.stdout.write(f"{_context_markdown(project)}\n"
1527
+ f"{context_item_section(project, view)}")
1485
1528
  sys.stdout.flush()
1486
1529
  return OK
1487
1530
 
@@ -1576,6 +1619,81 @@ def cmd_blast(args) -> int:
1576
1619
  return OK
1577
1620
 
1578
1621
 
1622
+ def render_subgraph(project, view, *, uid_display=None, emit=print) -> None:
1623
+ """Render a Neighbourhood as text (SR-0189).
1624
+
1625
+ The one rendering both ``tl subgraph`` and ``tl context <UID>`` use, so the
1626
+ two cannot drift (SR-0190). ``uid_display`` is the same seam ``render_trace``
1627
+ offers: tl-compose passes the namespace-qualified form so a borrowed clause
1628
+ reads ``asvs:SR-0272`` rather than its mangled union UID.
1629
+ """
1630
+ show = uid_display or (lambda u: u)
1631
+
1632
+ def label(uid: str) -> str:
1633
+ item = project.get(uid)
1634
+ if item is None:
1635
+ return f"{show(uid)} (unresolved)"
1636
+ return f"{show(uid)} [{item.type}/{item.status}] {item.title}".rstrip()
1637
+
1638
+ emit(label(view.start))
1639
+ for heading, group in (("rests on", view.upstream),
1640
+ ("depended on by", view.downstream)):
1641
+ emit("")
1642
+ if group:
1643
+ emit(f"{heading} ({len(group)}):")
1644
+ for uid in group:
1645
+ emit(f" {label(uid)}")
1646
+ else:
1647
+ emit(f"{heading}: none")
1648
+ emit("")
1649
+ if view.edges:
1650
+ emit(f"links within this set ({len(view.edges)}):")
1651
+ width = max(len(show(s)) for s, _lt, _t in view.edges)
1652
+ for src, ltype, tgt in view.edges:
1653
+ emit(f" {show(src).ljust(width)} -{ltype}-> {show(tgt)}")
1654
+ else:
1655
+ emit("links within this set: none")
1656
+
1657
+
1658
+ def _subgraph_json(project, view, uid_display=None) -> dict:
1659
+ show = uid_display or (lambda u: u)
1660
+
1661
+ def node(uid: str) -> dict:
1662
+ item = project.get(uid)
1663
+ if item is None:
1664
+ return {"uid": show(uid), "resolved": False}
1665
+ return {"uid": show(uid), "resolved": True, "type": item.type,
1666
+ "status": item.status, "title": item.title}
1667
+
1668
+ return {
1669
+ "start": show(view.start),
1670
+ "upstream": [show(u) for u in view.upstream],
1671
+ "downstream": [show(u) for u in view.downstream],
1672
+ "nodes": [node(u) for u in view.nodes],
1673
+ "edges": [{"source": show(s), "type": lt, "target": show(t)}
1674
+ for s, lt, t in view.edges],
1675
+ }
1676
+
1677
+
1678
+ def cmd_subgraph(args) -> int:
1679
+ try:
1680
+ project = load_project(args.path)
1681
+ except ProjectError as e:
1682
+ return _err(str(e))
1683
+ uid = _resolve_uid(project, args.uid, "show the neighbourhood of", "UID")
1684
+ if uid is None:
1685
+ return USAGE
1686
+ if project.get(uid) is None:
1687
+ return _err(f"{uid} does not exist")
1688
+ types = set(args.link_type) if args.link_type else None
1689
+ view = Index.build(project).subgraph(uid, types, args.depth or 0)
1690
+ if args.format == "json":
1691
+ print(json.dumps(_subgraph_json(project, view), indent=2))
1692
+ else:
1693
+ render_subgraph(project, view)
1694
+ return OK
1695
+
1696
+
1579
1697
  def cmd_ratify(args) -> int:
1580
1698
  try:
1581
1699
  project = load_project(args.path)
@@ -1970,6 +2088,8 @@ def build_parser() -> argparse.ArgumentParser:
1970
2088
  s = sub.add_parser(
1971
2089
  "context",
1972
2090
  help="emit an agent-facing Markdown brief (IDD + this project's model)")
2091
+ s.add_argument("uid", nargs="?", default=None,
2092
+ help="append this item's neighbourhood to the brief")
1973
2093
  s.set_defaults(func=cmd_context)
1974
2094
 
1975
2095
  s = sub.add_parser("trace", help="print the link tree from a UID")
@@ -1985,6 +2105,17 @@ def build_parser() -> argparse.ArgumentParser:
1985
2105
  s.add_argument("--format", choices=["text", "json"], default="text")
1986
2106
  s.set_defaults(func=cmd_blast)
1987
2107
 
2108
+ s = sub.add_parser(
2109
+ "subgraph",
2110
+ help="show a UID's neighbourhood — both directions plus the links between")
2111
+ s.add_argument("uid", nargs="?", default=None,
2112
+ help="UID to inspect (omit on a terminal to pick one)")
2113
+ s.add_argument("--format", choices=["text", "json"], default="text")
2114
+ s.add_argument("--depth", type=int, default=0, help="0 = unbounded")
2115
+ s.add_argument("--link-type", action="append", default=None, metavar="KIND",
2116
+ help="restrict the walk to this link type (repeatable)")
2117
+ s.set_defaults(func=cmd_subgraph)
2118
+
1988
2119
  s = sub.add_parser("ratify", help="a human takes accountability for an item")
1989
2120
  s.add_argument("uid", nargs="?", default=None,
1990
2121
  help="UID to ratify (omit on a terminal to pick one)")
@@ -10,6 +10,23 @@ from __future__ import annotations
10
10
  from dataclasses import dataclass, field
11
11
 
12
12
 
13
+ @dataclass(frozen=True)
14
+ class Neighbourhood:
15
+ """The induced subgraph around one item (SR-0189).
16
+
17
+ ``upstream`` and ``downstream`` are the transitive sets reachable by outgoing
18
+ and incoming links respectively; ``nodes`` is the start plus both, deduplicated
19
+ (a cycle can put one item in each). ``edges`` is every link whose *source and
20
+ target both fall inside* ``nodes`` — the part neither ``trace`` nor ``blast``
21
+ reports, and the reason this is a distinct view rather than a flag on either.
22
+ """
23
+ start: str
24
+ upstream: tuple[str, ...] = ()
25
+ downstream: tuple[str, ...] = ()
26
+ nodes: tuple[str, ...] = ()
27
+ edges: tuple[tuple[str, str, str], ...] = () # (source, link type, target)
28
+
29
+
13
30
  @dataclass
14
31
  class Index:
15
32
  items: dict = field(default_factory=dict) # uid -> Item
@@ -70,20 +87,75 @@ class Index:
70
87
  stack.extend(t for t, _k in self.out_links(cur, link_types))
71
88
  return False
72
89
 
73
- def impact(self, uid: str, link_types: set[str] | None = None) -> list[str]:
74
- """Transitive set reachable via *incoming* links — what depends on this
75
- (SR-0035 / blast radius)."""
90
+ def closure(self, uid: str, direction: str, link_types: set[str] | None = None,
91
+ max_depth: int = 0, expand=None) -> list[str]:
92
+ """Transitive set reachable from ``uid`` over one direction, in
93
+ breadth-first discovery order and excluding ``uid`` itself.
94
+
95
+ ``direction`` is ``"out"`` (what this rests on) or ``"in"`` (what rests on
96
+ this). ``max_depth`` of 0 means unlimited. The single walk behind both
97
+ ``impact`` and ``subgraph``, so a blast radius and a neighbourhood can
98
+ never disagree about what points at an item (SR-0189).
99
+
100
+ ``expand`` is the boundary seam ``render_trace`` also offers: a node it
101
+ rejects is still reported, but is not walked through. tl-compose passes
102
+ one so a neighbourhood touching a borrowed clause does not swallow the
103
+ whole standard behind it. ``uid`` itself is always walked — a caller who
104
+ named it is standing on it deliberately."""
105
+ step = self.out_links if direction == "out" else self.in_links
76
106
  hit: list[str] = []
77
107
  seen = {uid}
78
- stack = [uid]
79
- while stack:
80
- for src, _k in self.in_links(stack.pop(), link_types):
81
- if src not in seen:
82
- seen.add(src)
83
- hit.append(src)
84
- stack.append(src)
108
+ frontier = [uid]
109
+ depth = 0
110
+ while frontier and (not max_depth or depth < max_depth):
111
+ nxt: list[str] = []
112
+ for cur in frontier:
113
+ if expand is not None and cur != uid and not expand(cur):
114
+ continue
115
+ for other, _k in step(cur, link_types):
116
+ if other not in seen:
117
+ seen.add(other)
118
+ hit.append(other)
119
+ nxt.append(other)
120
+ frontier = nxt
121
+ depth += 1
85
122
  return hit
86
123
 
124
+ def impact(self, uid: str, link_types: set[str] | None = None) -> list[str]:
125
+ """Transitive set reachable via *incoming* links — what depends on this
126
+ (SR-0035 / blast radius)."""
127
+ return self.closure(uid, "in", link_types)
128
+
129
+ def subgraph(self, uid: str, link_types: set[str] | None = None,
130
+ max_depth: int = 0, expand=None) -> Neighbourhood:
131
+ """The induced subgraph around ``uid`` (SR-0189) — both directed closures
132
+ plus every link joining two members of the resulting node set.
133
+
134
+ A target that is not a local item (dangling, or namespace-qualified and
135
+ resolvable only under tl-compose) stays in the node set as a leaf; it has
136
+ no outgoing links here, so the walk stops at it rather than failing.
137
+
138
+ ``expand`` bounds only what is *walked through*; a link joining two nodes
139
+ already in the set is still reported, whichever of them it starts at, or
140
+ the drawing would contradict the set it claims to induce."""
141
+ upstream = self.closure(uid, "out", link_types, max_depth, expand)
142
+ downstream = self.closure(uid, "in", link_types, max_depth, expand)
143
+ nodes = [uid, *upstream]
144
+ within = set(nodes)
145
+ for d in downstream: # a cycle can put one item in both
146
+ if d not in within:
147
+ within.add(d)
148
+ nodes.append(d)
149
+ edges = sorted({
150
+ (src, ltype, tgt)
151
+ for src in nodes
152
+ for tgt, ltype in self.out_links(src, link_types)
153
+ if tgt in within
154
+ })
155
+ return Neighbourhood(start=uid, upstream=tuple(upstream),
156
+ downstream=tuple(downstream), nodes=tuple(nodes),
157
+ edges=tuple(edges))
158
+
87
159
  def refines_cycle(self, cycle_link_types: set[str]) -> list[str] | None:
88
160
  WHITE, GREY, BLACK = 0, 1, 2
89
161
  colour = {u: WHITE for u in self.items}
@@ -244,6 +244,46 @@ class Project:
244
244
  used |= {frm} | set(to)
245
245
  return used
246
246
 
247
+ def suspect_unreachable_statuses(self) -> list[str]:
248
+ """The live statuses this project's lifecycle gives no route to the
249
+ suspect status, sorted (SR-0174, SR-0188).
250
+
251
+ Suspicion propagation is how the withdrawal of an item's footing becomes
252
+ visible, and it is carried entirely by the transition table: a status with
253
+ no declared move to suspect silently opts every item sitting in it out of
254
+ the mechanism. Empty when there is nothing to answer for — a project that
255
+ declares no transitions is unconstrained, and one that binds no suspect
256
+ role has no such mechanism to disable.
257
+
258
+ Judged over the statuses an item can actually hold (SR-0177): a composing
259
+ project declares the statuses its *borrowed* items carry so the union
260
+ validates, and nothing local ever enters them; reporting those buries the
261
+ real gaps under noise the project cannot act on. Occupancy is taken
262
+ alongside reachability so a status set by hand, or left behind by a
263
+ lifecycle that has since changed, is still judged rather than excused —
264
+ but counted over locally-authored items only (SR-0178), because a tool that
265
+ composes by merging borrowed items would otherwise reinstate through
266
+ occupancy exactly what reachability excluded, and a borrowed status answers
267
+ to its own graph's table, which this project cannot edit.
268
+
269
+ Defined here, once, because two callers act on it and they must never
270
+ disagree: the gate reports each of these as a finding, and the migration
271
+ repair adds the missing route (SR-0188). Were the two notions of "live" to
272
+ drift, migration would either churn a project's configuration over
273
+ statuses the gate ignores, or leave the ones it reports."""
274
+ schema = self.schema
275
+ if schema.transitions is None or not (schema.status_roles or {}).get("suspect"):
276
+ return []
277
+ suspect = schema.status_roles["suspect"]
278
+ dead = schema.dead_statuses()
279
+ occupied = {i.status for i in self.items() if i._authored_uid is None}
280
+ reachable = schema.reachable_statuses()
281
+ declared = schema.statuses or (set(schema.transitions) | occupied)
282
+ live = declared if reachable is None else (reachable | occupied) & declared
283
+ return sorted(s for s in live
284
+ if s != suspect and s not in dead
285
+ and not schema.allows_transition(s, suspect))
286
+
247
287
  def relied_on_link_types(self) -> set[str]:
248
288
  """The link types this project would strand by not declaring them — see
249
289
  :meth:`relied_on_statuses`. Its configuration names them in three further
@@ -27,7 +27,7 @@ from .graph import Index
27
27
  from .grounding import ratification_refusal
28
28
  from .model import Item, Link, Project, Register
29
29
  from .schema import SchemaError
30
- from .tomledit import TomlDocument
30
+ from .tomledit import TomlDocument, TomlEditError
31
31
  from .version import distribution_version
32
32
 
33
33
  if sys.version_info >= (3, 11):
@@ -350,34 +350,107 @@ def _backfill_vocabularies(root: Path) -> dict[str, list[str]]:
350
350
  return written
351
351
 
352
352
 
353
+ def _backfill_suspect_routes(root: Path) -> dict[str, str]:
354
+ """Add a move to the suspect status from every live status the lifecycle
355
+ leaves unable to reach it (SR-0188). Returns ``status -> suspect`` for each
356
+ row widened, and is empty when suspicion already reaches everywhere it must.
357
+
358
+ SR-0175 corrected the lifecycle the Tool *ships*, and left an existing project
359
+ with the table it already had — so every project scaffolded before it carries
360
+ a suspect role that is inert for exactly the statuses `tl new` births and parks
361
+ items in. The gate reports that (SR-0174) and, until this, offered no remedy but
362
+ editing each status by hand. This is what bounds it to the one command that
363
+ already repairs the rest of the major's configuration.
364
+
365
+ Which statuses are at issue is asked of
366
+ :meth:`~throughline.model.Project.suspect_unreachable_statuses` — the same
367
+ predicate the gate reports from — so the repair writes exactly what the gate
368
+ complains about, no more and no less. Reimplementing "live" here would let the
369
+ two drift into churning every project's configuration over statuses the gate
370
+ ignores, or leaving the ones it reports.
371
+
372
+ A route is *added* to the row rather than the row rewritten, so the moves
373
+ already declared keep their order and any comment grouping them (SR-0183). A
374
+ document this editor will not touch on a guess is refused whole, before
375
+ anything is written, and named with the command that makes the change by hand
376
+ — a corrupted `throughline.toml` invalidates the entire project, so a repair
377
+ that cannot be made safely must not be made at all.
378
+
379
+ Idempotent: a widened row permits the move and so is no longer reported.
380
+ """
381
+ project = load_project(root)
382
+ gaps = project.suspect_unreachable_statuses()
383
+ if not gaps:
384
+ return {}
385
+ suspect = project.schema.status_roles["suspect"]
386
+ declared = project.config.get("transitions") or {}
387
+ cfg_file = root / CONFIG_NAME
388
+ doc = TomlDocument(cfg_file.read_text(encoding="utf-8"))
389
+ if not doc.has_table("transitions"):
390
+ # The schema saw a `transitions` key, so the project declares one — but not
391
+ # as a [transitions] header, and writing one here would define the table
392
+ # twice and stop the file parsing at all. Refuse rather than corrupt it.
393
+ raise ProjectError(
394
+ f"{cfg_file} declares its transitions somewhere other than a "
395
+ "[transitions] table, which `tl migrate` will not edit on a guess; "
396
+ f"allow the moves to '{suspect}' by hand, or one at a time with "
397
+ f"`tl schema transition allow <status> {suspect} --because …`")
398
+ for status in gaps:
399
+ because = (
400
+ f"`tl migrate` added the move to '{suspect}' (SR-0188): this "
401
+ f"lifecycle gave '{status}' no route to it, so an item resting in "
402
+ f"'{status}' could never be marked suspect and invalidating anything "
403
+ "it grounds in would have left it unflagged. Narrow it back "
404
+ f"deliberately with `tl schema transition deny {status} {suspect}`.")
405
+ try:
406
+ if status in declared:
407
+ doc.add_to_array("transitions", status, [suspect], because=because)
408
+ else:
409
+ doc.set_key("transitions", status, [suspect], because=because)
410
+ except TomlEditError as e:
411
+ raise ProjectError(
412
+ f"cannot add the '{status}' -> '{suspect}' transition to "
413
+ f"{cfg_file} safely: {e}. Add it by hand, or with `tl schema "
414
+ f"transition allow {status} {suspect} --because …`") from e
415
+ cfg_file.write_text(doc.text(), encoding="utf-8")
416
+ return {status: suspect for status in gaps}
417
+
418
+
353
419
  class RepairResult(NamedTuple):
354
420
  """What a major's repair wrote: the configuration bindings it backfilled
355
- (SR-0137), the vocabularies it declared (SR-0185), and the ratification
356
- records it bound (SR-0152)."""
421
+ (SR-0137), the routes to suspicion it restored (SR-0188), the vocabularies it
422
+ declared (SR-0185), and the ratification records it bound (SR-0152)."""
357
423
  config: dict[str, str] | None
424
+ routes: dict[str, str]
358
425
  vocabularies: dict[str, list[str]]
359
426
  stamps: dict[str, str]
360
427
 
361
428
 
362
429
  def _repair_status_roles_major(root: Path, index: Index | None) -> RepairResult:
363
430
  """The repair for the major that requires [status.roles] (SR-0137, SR-0152,
364
- SR-0185).
365
-
366
- Ordered, not merely grouped, and the order is load-bearing twice over. The
367
- role bindings come first because the status vocabulary is written from what
368
- the configuration relies on, and a role bound to a status is exactly that —
369
- written second, the vocabulary would omit the very statuses the roles name and
370
- leave the schema refusing to build. The record backfill comes last because it
371
- resolves the project's schema, so everything that schema requires has to be on
372
- disk before it runs. Each part is idempotent, so the sequence is.
431
+ SR-0185, SR-0188).
432
+
433
+ Ordered, not merely grouped, and the order is load-bearing three times over.
434
+ The role bindings come first because everything after them reads a status
435
+ through a role — the suspect route has no target until the suspect role is
436
+ bound — and because the status vocabulary is written from what the
437
+ configuration relies on, so a role written second would be omitted from the
438
+ very vocabulary that has to contain it and leave the schema refusing to build.
439
+ The suspect routes come before that vocabulary for the same reason from the
440
+ other side: they widen the transitions table, and the vocabulary is derived
441
+ from the table's endpoints, so declaring it first could leave it short of a
442
+ status the table now names. The record backfill comes last because it resolves
443
+ the project's schema, so everything that schema requires has to be on disk
444
+ before it runs. Each part is idempotent, so the sequence is.
373
445
 
374
446
  ``index`` reaches only the record backfill (SR-0153): a grounding view has
375
- nothing to say about which declared status plays which role, or about which
376
- values a vocabulary should hold, so passing it to either configuration half
377
- would imply an influence it does not have."""
447
+ nothing to say about which declared status plays which role, which moves a
448
+ lifecycle should permit, or which values a vocabulary should hold, so passing
449
+ it to any configuration half would imply an influence it does not have."""
378
450
  roles = _backfill_status_roles(root)
451
+ routes = _backfill_suspect_routes(root)
379
452
  vocabularies = _backfill_vocabularies(root)
380
- return RepairResult(roles, vocabularies,
453
+ return RepairResult(roles, routes, vocabularies,
381
454
  _backfill_ratification_stamps(root, index=index))
382
455
 
383
456
 
@@ -432,10 +505,17 @@ class MigrationResult(NamedTuple):
432
505
  (SR-0185), and is empty when the project already declared them. Separate again
433
506
  for the same reason: this one decides what the project's items will be
434
507
  validated against from now on, so it is the part an author is most likely to
435
- want to narrow, and it cannot be left to be inferred from a count."""
508
+ want to narrow, and it cannot be left to be inferred from a count.
509
+
510
+ ``routed`` maps each status the repair gave a route to suspicion to the suspect
511
+ status it can now reach (SR-0188), and is empty when suspicion already reached
512
+ them all. Separate for the third time, and for the sharpest version of the same
513
+ reason: this one widens what the lifecycle permits, which is the only part of
514
+ the repair that lets an item make a move the project previously forbade."""
436
515
  start: int
437
516
  end: int
438
517
  repaired: dict[str, str] | None
518
+ routed: dict[str, str]
439
519
  declared: dict[str, list[str]]
440
520
  bound: dict[str, str]
441
521
 
@@ -483,9 +563,10 @@ def migrate_project(path: str | Path, *,
483
563
  if current != start:
484
564
  _rewrite_format_version(cfg_file, current)
485
565
  repair = _REPAIRS.get(current)
486
- result = repair(root, index) if repair is not None else RepairResult(None, {}, {})
487
- return MigrationResult(start, current, result.config, result.vocabularies,
488
- result.stamps)
566
+ result = (repair(root, index) if repair is not None
567
+ else RepairResult(None, {}, {}, {}))
568
+ return MigrationResult(start, current, result.config, result.routes,
569
+ result.vocabularies, result.stamps)
489
570
 
490
571
 
491
572
  # ------------------------------------------------------------------- YAML dump
@@ -119,42 +119,24 @@ def validate(project, strict: bool = False,
119
119
  "`tl migrate` declares exactly that, or `tl schema "
120
120
  f"{cmd} declare …` declares the vocabulary you mean")
121
121
 
122
- # A lifecycle with no route to suspicion (SR-0174). Suspicion propagation is
123
- # how the withdrawal of an item's footing becomes visible, and it is carried
124
- # entirely by the transition table: a status with no declared move to suspect
125
- # silently opts every item sitting in it out of the mechanism, and `invalidate`
126
- # can only report the gap once it is too late to matter. Reported per status
127
- # because the partial case is the one that deceives — a project whose ratified
128
- # items cascade while its proposed ones do not looks healthy from every angle
129
- # except the one that counts. A project that declares no transitions is
130
- # unconstrained and has nothing to answer for; one that binds no suspect role
131
- # has no such mechanism to disable.
132
- if schema.transitions is not None and (schema.status_roles or {}).get("suspect"):
122
+ # A lifecycle with no route to suspicion (SR-0174). Which statuses those are is
123
+ # decided by Project.suspect_unreachable_statuses, because `tl migrate` repairs
124
+ # exactly the ones reported here (SR-0188) and a second reading of "live" here
125
+ # would let the gate and the repair drift apart. Reported per status because the
126
+ # partial case is the one that deceives — a project whose ratified items cascade
127
+ # while its proposed ones do not looks healthy from every angle except the one
128
+ # that counts. `invalidate` can only report the gap once it is too late to matter.
129
+ # The finding names its remedy, as the undeclared-vocabulary one above does and
130
+ # for the same reason (SR-0188): `tl migrate` writes exactly what is reported
131
+ # here, so a reader is never left to close the gap status by status by hand.
132
+ for status in project.suspect_unreachable_statuses():
133
133
  suspect = schema.status_roles["suspect"]
134
- dead = schema.dead_statuses()
135
- # Only statuses an item can actually hold are judged (SR-0177). A composing
136
- # project declares the statuses its borrowed items carry so the union
137
- # validates, and nothing local ever enters them; reporting those as gaps
138
- # buries the real ones under noise the project cannot act on. Occupancy is
139
- # taken alongside reachability so a status set by hand, or left behind by a
140
- # lifecycle that has since changed, is still judged rather than excused —
141
- # but counted over locally-authored items only (SR-0178). A tool that composes
142
- # by merging borrowed items, rather than only their vocabulary, otherwise
143
- # reinstates through occupancy exactly what reachability excluded; and the
144
- # borrowed status answers to its own graph's transition table, which this
145
- # project cannot edit and that graph's own check already judges.
146
- occupied = {i.status for i in project.items() if i._authored_uid is None}
147
- reachable = schema.reachable_statuses()
148
- declared = schema.statuses or (set(schema.transitions) | occupied)
149
- live = declared if reachable is None else (reachable | occupied) & declared
150
- for status in sorted(live):
151
- if status == suspect or status in dead:
152
- continue
153
- if not schema.allows_transition(status, suspect):
154
- add("suspect-unreachable", "", str(project.path / CONFIG_NAME),
155
- f"no declared transition moves '{status}' to '{suspect}', so an "
156
- f"item in '{status}' can never be marked suspect — invalidating "
157
- "anything it grounds in will leave it unflagged")
134
+ add("suspect-unreachable", "", str(project.path / CONFIG_NAME),
135
+ f"no declared transition moves '{status}' to '{suspect}', so an "
136
+ f"item in '{status}' can never be marked suspect — invalidating "
137
+ "anything it grounds in will leave it unflagged; `tl migrate` adds "
138
+ f"the route, or `tl schema transition allow {status} {suspect}` "
139
+ "adds this one")
158
140
 
159
141
  # A run that discovered nothing is not a sound graph (SR-0146). Items live only
160
142
  # beneath a register manifest, so a project whose manifests are missing, misnamed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check.
5
5
  Author-email: Henry J Grech-Cini <henry.grechcini@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -36,7 +36,7 @@ version control; a `check` command validates the whole graph and gates CI.
36
36
 
37
37
  **Dogfooded:** throughline's own spec is itself a throughline project —
38
38
  <!-- tl:count type == 'system_requirement' -->
39
- 153
39
+ 156
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
42
  25
@@ -42,7 +42,7 @@ from throughline.storage import (
42
42
  migrate_project,
43
43
  read_project,
44
44
  )
45
- from throughline.tomledit import TomlDocument
45
+ from throughline.tomledit import TomlDocument, render_value
46
46
  from throughline.uid import UidError
47
47
 
48
48
  REPO = Path(__file__).resolve().parent.parent
@@ -3539,6 +3539,207 @@ def test_migrate_names_the_vocabularies_it_declared_on_the_cli(tmp_path, capsys)
3539
3539
  assert "nothing to migrate" not in out
3540
3540
 
3541
3541
 
3542
+ # --------------------- migration restores a route to suspicion (SR-0188) --- #
3543
+
3544
+ def _pre_sr0175_lifecycle(root: Path) -> None:
3545
+ """Put the transitions table back into the shape every project scaffolded before
3546
+ SR-0175 carries: the statuses `tl new` births and parks items in have no move to
3547
+ 'suspect', so suspicion propagation is inert for exactly them."""
3548
+ cfg = root / "throughline.toml"
3549
+ doc = TomlDocument(cfg.read_text(encoding="utf-8"))
3550
+ for status in ("proposed", "draft", "deferred"):
3551
+ targets = tomllib.loads(cfg.read_text(encoding="utf-8"))["transitions"][status]
3552
+ doc.set_key("transitions", status, [t for t in targets if t != "suspect"])
3553
+ cfg.write_text(doc.text(), encoding="utf-8")
3554
+
3555
+
3556
+ def test_migrate_restores_a_route_to_suspicion_a_lifecycle_never_had(tmp_path):
3557
+ """SR-0188. SR-0175 corrected only the table the Tool *ships*, so a project
3558
+ scaffolded before it keeps an inert suspect role for the statuses items are born
3559
+ and parked in. The gate reports that and, until this, offered no remedy but
3560
+ editing each status by hand — so the same single command has to close it."""
3561
+ root = tmp_path / "proj"
3562
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3563
+ _pre_sr0175_lifecycle(root)
3564
+ stranded = {"proposed", "draft", "deferred"}
3565
+ assert set(load_project(root).suspect_unreachable_statuses()) == stranded
3566
+
3567
+ result = migrate_project(root)
3568
+
3569
+ assert set(result.routed) == stranded
3570
+ assert set(result.routed.values()) == {"suspect"}
3571
+ schema = load_project(root).schema
3572
+ assert all(schema.allows_transition(s, "suspect") for s in stranded)
3573
+ assert not [f for f in validate(load_project(root))
3574
+ if f.rule == "suspect-unreachable"]
3575
+
3576
+
3577
+ def test_migrate_suspect_route_repair_is_idempotent(tmp_path):
3578
+ """SR-0188 with SR-0137. The repair runs on every `tl migrate`, sound project or
3579
+ not, so a lifecycle that already permits the move must be left byte-identical —
3580
+ otherwise every run would re-comment and re-widen the same rows."""
3581
+ root = tmp_path / "proj"
3582
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3583
+ _pre_sr0175_lifecycle(root)
3584
+ assert migrate_project(root).routed
3585
+ cfg = root / "throughline.toml"
3586
+ before = cfg.read_text(encoding="utf-8")
3587
+
3588
+ assert migrate_project(root).routed == {}
3589
+
3590
+ assert cfg.read_text(encoding="utf-8") == before
3591
+
3592
+
3593
+ def test_migrate_leaves_a_lifecycle_that_already_reaches_suspicion_alone(tmp_path):
3594
+ """SR-0188. The table `tl init` ships has been correct since SR-0175, so the
3595
+ repair must write nothing at all to a project scaffolded by it — a repair that
3596
+ churned every current project's configuration would be its own defect."""
3597
+ root = tmp_path / "proj"
3598
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3599
+ cfg = root / "throughline.toml"
3600
+ before = cfg.read_text(encoding="utf-8")
3601
+
3602
+ assert migrate_project(root).routed == {}
3603
+
3604
+ assert cfg.read_text(encoding="utf-8") == before
3605
+
3606
+
3607
+ def test_migrate_repairs_exactly_the_statuses_the_gate_reports(tmp_path):
3608
+ """SR-0188. Both halves ask Project.suspect_unreachable_statuses, so what the
3609
+ repair widens and what the gate complains about cannot drift apart: a second
3610
+ reading of "live" here would churn a project's configuration over statuses the
3611
+ gate ignores, or leave the ones it reports."""
3612
+ root = tmp_path / "proj"
3613
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3614
+ _pre_sr0175_lifecycle(root)
3615
+ project = load_project(root)
3616
+ reported = {f.message.split("'")[1]
3617
+ for f in validate(project) if f.rule == "suspect-unreachable"}
3618
+
3619
+ assert reported == set(project.suspect_unreachable_statuses())
3620
+ assert set(migrate_project(root).routed) == reported
3621
+
3622
+
3623
+ def test_migrate_adds_the_route_without_disturbing_the_moves_already_declared(tmp_path):
3624
+ """SR-0188 through SR-0183/NFR-0009. The route is *added* to the row rather than
3625
+ the row rewritten, so an author's own ordering, layout and comments survive being
3626
+ brought up to what the major requires."""
3627
+ root = tmp_path / "proj"
3628
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3629
+ _pre_sr0175_lifecycle(root)
3630
+ cfg = root / "throughline.toml"
3631
+ cfg.write_text("# Why this project is shaped as it is.\n"
3632
+ + cfg.read_text(encoding="utf-8"), encoding="utf-8")
3633
+ before = tomllib.loads(cfg.read_text(encoding="utf-8"))["transitions"]
3634
+
3635
+ migrate_project(root)
3636
+ out = cfg.read_text(encoding="utf-8")
3637
+
3638
+ assert out.startswith("# Why this project is shaped as it is.\n")
3639
+ after = tomllib.loads(out)["transitions"]
3640
+ for status, targets in before.items():
3641
+ extra = {"suspect"} if status in ("proposed", "draft", "deferred") else set()
3642
+ assert after[status] == targets + sorted(extra) # appended, nothing reordered
3643
+ assert "SR-0188" in out, "the config records that migration made the change"
3644
+
3645
+
3646
+ def test_migrate_gives_a_status_with_no_row_at_all_a_route_to_suspicion(tmp_path):
3647
+ """SR-0188. A status an item sits in but the table never mentions has no outgoing
3648
+ move of any kind, which is the same disabled mechanism by another route — so it
3649
+ gets a row rather than being passed over for lack of one to widen."""
3650
+ root = tmp_path / "proj"
3651
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3652
+ cfg = root / "throughline.toml"
3653
+ doc = TomlDocument(cfg.read_text(encoding="utf-8"))
3654
+ doc.remove_key("transitions", "deferred")
3655
+ cfg.write_text(doc.text(), encoding="utf-8")
3656
+
3657
+ assert migrate_project(root).routed == {"deferred": "suspect"}
3658
+
3659
+ assert tomllib.loads(cfg.read_text(encoding="utf-8")
3660
+ )["transitions"]["deferred"] == ["suspect"]
3661
+
3662
+
3663
+ def test_migrate_writes_no_route_where_there_is_no_mechanism_to_disable(tmp_path):
3664
+ """SR-0188. A project that declares no transitions is unconstrained, and one that
3665
+ binds no suspect role has no suspicion to propagate. The repair reads a declared
3666
+ lifecycle; it does not invent one, any more than the gate does."""
3667
+ root = tmp_path / "proj"
3668
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3669
+ cfg = root / "throughline.toml"
3670
+ doc = TomlDocument(cfg.read_text(encoding="utf-8"))
3671
+ doc.remove_table("transitions")
3672
+ cfg.write_text(doc.text(), encoding="utf-8")
3673
+ before = cfg.read_text(encoding="utf-8")
3674
+
3675
+ assert migrate_project(root).routed == {}
3676
+
3677
+ assert cfg.read_text(encoding="utf-8") == before
3678
+
3679
+
3680
+ def test_migrate_names_the_routes_it_restored_on_the_cli(tmp_path, capsys):
3681
+ """SR-0188 with SR-0137. This is the one part of the repair that *widens* what
3682
+ the lifecycle permits, so an operator who did not want a status able to move must
3683
+ be able to see which rows changed rather than infer it from a count."""
3684
+ root = tmp_path / "proj"
3685
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3686
+ _pre_sr0175_lifecycle(root)
3687
+ capsys.readouterr()
3688
+
3689
+ assert _cli(["-C", str(root), "migrate"]) == 0
3690
+ out = capsys.readouterr().out
3691
+
3692
+ assert "draft -> suspect" in out
3693
+ assert "nothing to migrate" not in out
3694
+
3695
+
3696
+ def test_migrate_refuses_to_guess_at_a_transitions_table_it_cannot_edit(tmp_path):
3697
+ """SR-0188. A corrupted throughline.toml invalidates the whole project, so a
3698
+ repair that cannot be made safely must not be made at all — it is refused whole,
3699
+ before anything is written, and named with the command that does it by hand."""
3700
+ root = tmp_path / "proj"
3701
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3702
+ cfg = root / "throughline.toml"
3703
+ doc = TomlDocument(cfg.read_text(encoding="utf-8"))
3704
+ table = tomllib.loads(cfg.read_text(encoding="utf-8"))["transitions"]
3705
+ doc.remove_table("transitions")
3706
+ # The same lifecycle, declared as one inline table rather than a [transitions]
3707
+ # header — a shape the editor cannot widen and must not append a second table to.
3708
+ # Written above every header, so it is the top-level key the schema reads.
3709
+ text = ("transitions = " + render_value({k: [t for t in v if t != "suspect"]
3710
+ for k, v in table.items()})
3711
+ + "\n" + doc.text())
3712
+ cfg.write_text(text, encoding="utf-8")
3713
+ assert load_project(root).suspect_unreachable_statuses()
3714
+
3715
+ with pytest.raises(ProjectError, match="tl schema transition allow"):
3716
+ migrate_project(root)
3717
+
3718
+ assert cfg.read_text(encoding="utf-8") == text, "refused whole, nothing written"
3719
+
3720
+
3721
+ def test_migrate_keeps_a_comment_an_author_left_beside_one_move(tmp_path):
3722
+ """SR-0188 through SR-0183. Adding to the row is what makes the author's note on
3723
+ an individual move survivable — re-rendering the whole value would have nowhere
3724
+ to put it back, and the editor would rightly refuse rather than drop it, leaving
3725
+ the gap open on a project that had done nothing wrong."""
3726
+ root = tmp_path / "proj"
3727
+ assert _cli(["-C", str(root), "init", "--bare"]) == 0
3728
+ cfg = root / "throughline.toml"
3729
+ doc = TomlDocument(cfg.read_text(encoding="utf-8"))
3730
+ doc.remove_key("transitions", "deferred")
3731
+ cfg.write_text(doc.text().replace(
3732
+ "[transitions]",
3733
+ '[transitions]\ndeferred = [\n "draft", # back onto the active front\n]'),
3734
+ encoding="utf-8")
3735
+
3736
+ assert migrate_project(root).routed == {"deferred": "suspect"}
3737
+ out = cfg.read_text(encoding="utf-8")
3738
+
3739
+ assert "# back onto the active front" in out
3740
+ assert tomllib.loads(out)["transitions"]["deferred"] == ["draft", "suspect"]
3741
+
3742
+
3542
3743
  def test_migrate_refuses_newer_project(tmp_path):
3543
3744
  """`tl migrate` refuses a project newer than this tl — there is nothing to
3544
3745
  migrate down to; the user must upgrade tl (NFR-0010)."""
@@ -4220,3 +4421,197 @@ def test_editability_is_read_from_recorded_metadata_not_guessed(monkeypatch):
4220
4421
  # Absent or unparseable metadata is not evidence of a working tree.
4221
4422
  assert not version_mod._editable_from_direct_url(_Dist(None))
4222
4423
  assert not version_mod._editable_from_direct_url(_Dist("{not json"))
4424
+
4425
+
4426
+ # ------------------------------------------------------------------- SR-0189/0190
4427
+ # `tl subgraph` — the induced neighbourhood around one UID, and `tl context <UID>`.
4428
+
4429
+ def _neighbourhood_fixture(tmp_path) -> Path:
4430
+ """FR-0001 with one thing above it, two below, and a link that crosses between
4431
+ the two halves.
4432
+
4433
+ INT-0001 <-derives_from- FR-0001 <-derives_from- FR-0002 <-derives_from- FR-0003
4434
+ ^------------------------relates-----------------'
4435
+
4436
+ The `relates` edge joins a *downstream* node to an *upstream* one, so it is the
4437
+ edge no single-direction walk can report.
4438
+ """
4439
+ root = _scaffold(tmp_path) # INT-0001 + FR register
4440
+ for title, ground in [("a", "INT-0001"), ("b", "FR-0001"), ("c", "FR-0002")]:
4441
+ assert _cli(["-C", str(root), "new", "FR", "--type", "requirement",
4442
+ "--title", title, "--ground", ground]) == 0
4443
+ assert _cli(["-C", str(root), "link", "FR-0002", "INT-0001",
4444
+ "--type", "relates"]) == 0
4445
+ return root
4446
+
4447
+
4448
+ def test_subgraph_reports_the_link_neither_trace_nor_blast_can_show(tmp_path, capsys):
4449
+ """The whole justification for a third view (SR-0189). `trace` walks outward and
4450
+ `blast` collects inward, so a link *between* two members of the neighbourhood —
4451
+ here FR-0002 (below) pointing at INT-0001 (above) — appears in neither. The
4452
+ induced subgraph is the only view that closes that gap."""
4453
+ root = _neighbourhood_fixture(tmp_path)
4454
+ crossing = "FR-0002 -relates-> INT-0001"
4455
+
4456
+ capsys.readouterr()
4457
+ assert _cli(["-C", str(root), "trace", "FR-0001"]) == 0
4458
+ assert "FR-0002" not in capsys.readouterr().out
4459
+ assert _cli(["-C", str(root), "blast", "FR-0001"]) == 0
4460
+ blast = capsys.readouterr().out
4461
+ assert "FR-0002" in blast and "relates" not in blast
4462
+
4463
+ assert _cli(["-C", str(root), "subgraph", "FR-0001"]) == 0
4464
+ out = capsys.readouterr().out
4465
+ assert crossing in out
4466
+ # Both directions are reported, from the one start item.
4467
+ assert "rests on (1):" in out and "INT-0001" in out
4468
+ assert "depended on by (2):" in out and "FR-0003" in out
4469
+
4470
+
4471
+ def test_subgraph_bounds_the_walk_by_depth_but_not_the_links(tmp_path, capsys):
4472
+ """`--depth` limits how far the node set reaches, so FR-0003 (two hops below)
4473
+ drops out. Edges stay *induced*: every link joining two surviving nodes is still
4474
+ reported, because hiding one would misrepresent the set that is shown."""
4475
+ root = _neighbourhood_fixture(tmp_path)
4476
+ capsys.readouterr()
4477
+ assert _cli(["-C", str(root), "subgraph", "FR-0001", "--depth", "1"]) == 0
4478
+ out = capsys.readouterr().out
4479
+ assert "FR-0003" not in out
4480
+ assert "depended on by (1):" in out and "FR-0002" in out
4481
+ assert "FR-0002 -relates-> INT-0001" in out
4482
+
4483
+
4484
+ def test_subgraph_restricts_the_walk_to_chosen_link_types(tmp_path, capsys):
4485
+ """`--link-type` scopes both the closures and the edges, so asking only for
4486
+ derives_from drops the relates edge while the derivation chain survives."""
4487
+ root = _neighbourhood_fixture(tmp_path)
4488
+ capsys.readouterr()
4489
+ assert _cli(["-C", str(root), "subgraph", "FR-0001",
4490
+ "--link-type", "derives_from"]) == 0
4491
+ out = capsys.readouterr().out
4492
+ assert "relates" not in out
4493
+ assert "FR-0003 -derives_from-> FR-0002" in out
4494
+
4495
+
4496
+ def test_subgraph_renders_an_unresolved_target_as_a_leaf(tmp_path, capsys):
4497
+ """A namespace-qualified or dangling target has no local item to describe. It
4498
+ stays in the node set as an `(unresolved)` leaf — the walk stops there rather
4499
+ than aborting, matching how `tl trace` already behaves (SR-0051)."""
4500
+ root = _scaffold(tmp_path)
4501
+ assert _cli(["-C", str(root), "new", "FR", "--type", "requirement",
4502
+ "--title", "cites an external clause", "--ground", "INT-0001"]) == 0
4503
+ item = root / "features" / "FR-0001.yml"
4504
+ item.write_text(item.read_text(encoding="utf-8")
4505
+ + "- target: \"asvs:SR-0003\"\n type: relates\n",
4506
+ encoding="utf-8")
4507
+ capsys.readouterr()
4508
+ assert _cli(["-C", str(root), "subgraph", "FR-0001"]) == 0
4509
+ out = capsys.readouterr().out
4510
+ assert "asvs:SR-0003 (unresolved)" in out
4511
+ assert "FR-0001 -relates-> asvs:SR-0003" in out
4512
+
4513
+
4514
+ def test_subgraph_json_names_every_node_and_edge(tmp_path, capsys):
4515
+ """The machine-readable form an agent consumes: both closures, a node record
4516
+ carrying resolvability, and the edges as source/type/target triples."""
4517
+ import json
4518
+ root = _neighbourhood_fixture(tmp_path)
4519
+ capsys.readouterr()
4520
+ assert _cli(["-C", str(root), "subgraph", "FR-0001", "--format", "json"]) == 0
4521
+ doc = json.loads(capsys.readouterr().out)
4522
+ assert doc["start"] == "FR-0001"
4523
+ assert doc["upstream"] == ["INT-0001"]
4524
+ assert doc["downstream"] == ["FR-0002", "FR-0003"]
4525
+ assert {n["uid"] for n in doc["nodes"]} == {
4526
+ "FR-0001", "FR-0002", "FR-0003", "INT-0001"}
4527
+ assert all(n["resolved"] for n in doc["nodes"])
4528
+ assert {"source": "FR-0002", "type": "relates", "target": "INT-0001"} in doc["edges"]
4529
+
4530
+
4531
+ def test_subgraph_rejects_a_uid_that_does_not_exist(tmp_path, capsys):
4532
+ """Naming an absent item is a usage error (exit 2), not an empty neighbourhood
4533
+ that reads as "nothing depends on this"."""
4534
+ root = _scaffold(tmp_path)
4535
+ assert _cli(["-C", str(root), "subgraph", "NOPE-0001"]) == 2
4536
+ assert "NOPE-0001 does not exist" in capsys.readouterr().err
4537
+
4538
+
4539
+ def test_render_subgraph_applies_the_uid_display_seam(tmp_path, capsys):
4540
+ """The same seam `render_trace` exposes, so tl-compose can show a borrowed
4541
+ clause under its namespace rather than its mangled union UID (SR-0189)."""
4542
+ from throughline.cli import render_subgraph
4543
+ from throughline.graph import Index
4544
+ from throughline.storage import load_project
4545
+ root = _neighbourhood_fixture(tmp_path)
4546
+ project = load_project(str(root))
4547
+ view = Index.build(project).subgraph("FR-0001")
4548
+ capsys.readouterr()
4549
+ render_subgraph(project, view, uid_display=lambda u: f"ns:{u}")
4550
+ out = capsys.readouterr().out
4551
+ assert "ns:FR-0001" in out and "ns:INT-0001" in out
4552
+ assert "ns:FR-0002 -relates-> ns:INT-0001" in out
4553
+
4554
+
4555
+ def test_context_without_a_uid_is_unaffected(tmp_path, capsys):
4556
+ """`tl context` with no argument keeps doing exactly what it did: the project
4557
+ brief, and nothing item-specific bolted on (SR-0190)."""
4558
+ root = _neighbourhood_fixture(tmp_path)
4559
+ capsys.readouterr()
4560
+ assert _cli(["-C", str(root), "context"]) == 0
4561
+ out = capsys.readouterr().out
4562
+ assert "## The item you were given" not in out
4563
+ assert "rests on" not in out
4564
+
4565
+
4566
+ def test_context_with_a_uid_appends_the_neighbourhood(tmp_path, capsys):
4567
+ """`tl context <UID>` is the brief *plus* that item's neighbourhood, so an agent
4568
+ handed one UID gets the project rules and the item's surroundings in a single
4569
+ call. The added section reuses the subgraph renderer, so the two cannot drift
4570
+ (SR-0190)."""
4571
+ root = _neighbourhood_fixture(tmp_path)
4572
+ capsys.readouterr()
4573
+ assert _cli(["-C", str(root), "context"]) == 0
4574
+ bare = capsys.readouterr().out
4575
+ assert _cli(["-C", str(root), "context", "FR-0001"]) == 0
4576
+ out = capsys.readouterr().out
4577
+ assert out.startswith(bare) # the brief is untouched
4578
+ assert "## The item you were given: FR-0001" in out
4579
+ assert "FR-0002 -relates-> INT-0001" in out
4580
+
4581
+
4582
+ def test_context_rejects_a_uid_that_does_not_exist(tmp_path, capsys):
4583
+ """Same contract as `subgraph`: an unknown UID fails loudly rather than
4584
+ printing the brief and silently omitting the section that was asked for."""
4585
+ root = _scaffold(tmp_path)
4586
+ assert _cli(["-C", str(root), "context", "NOPE-0001"]) == 2
4587
+ assert "NOPE-0001 does not exist" in capsys.readouterr().err
4588
+
4589
+
4590
+ def test_subgraph_expand_bounds_the_walk_without_hiding_edges(tmp_path):
4591
+ """The boundary seam tl-compose builds on. A node `expand` rejects is still
4592
+ reported and its links to other members are still drawn — it is simply not
4593
+ walked *through*, so one link into a borrowed standard cannot drag the whole
4594
+ standard in. The named start item is always walked, whatever `expand` says,
4595
+ because a caller who named it is standing on it deliberately."""
4596
+ from throughline.graph import Index
4597
+ from throughline.storage import load_project
4598
+ root = _neighbourhood_fixture(tmp_path) # INT-0001 <- FR-0001 <- FR-0002 <- FR-0003
4599
+ index = Index.build(load_project(str(root)))
4600
+
4601
+ # Treat everything below FR-0001 as "foreign": FR-0002 is reported but not
4602
+ # traversed, so FR-0003 behind it never enters the set.
4603
+ view = index.subgraph("FR-0001", expand=lambda u: u in {"FR-0001", "INT-0001"})
4604
+ assert view.downstream == ("FR-0002",)
4605
+ assert "FR-0003" not in view.nodes
4606
+ # FR-0002's own links to members of the set survive the boundary.
4607
+ assert ("FR-0002", "relates", "INT-0001") in view.edges
4608
+ assert ("FR-0002", "derives_from", "FR-0001") in view.edges
4609
+ assert all("FR-0003" not in edge for edge in view.edges)
4610
+
4611
+ # The start is exempt: naming a node `expand` rejects still walks it, one step.
4612
+ named = index.subgraph("FR-0002", expand=lambda u: False)
4613
+ assert set(named.upstream) == {"FR-0001", "INT-0001"} # both are one hop out
4614
+ assert named.downstream == ("FR-0003",)
4615
+ # ...and no further: FR-0001's own parent is already INT-0001, so the proof the
4616
+ # walk stopped is that nothing two hops past the start appears via FR-0003.
4617
+ assert set(named.nodes) == {"FR-0002", "FR-0001", "INT-0001", "FR-0003"}
File without changes
File without changes
File without changes