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.
- {throughline-2.0.0/src/throughline.egg-info → throughline-2.2.0}/PKG-INFO +2 -2
- {throughline-2.0.0 → throughline-2.2.0}/README.md +1 -1
- {throughline-2.0.0 → throughline-2.2.0}/pyproject.toml +1 -1
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/cli.py +134 -3
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/graph.py +82 -10
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/model.py +40 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/storage.py +101 -20
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/validate.py +17 -35
- {throughline-2.0.0 → throughline-2.2.0/src/throughline.egg-info}/PKG-INFO +2 -2
- {throughline-2.0.0 → throughline-2.2.0}/tests/test_engine.py +396 -1
- {throughline-2.0.0 → throughline-2.2.0}/LICENSE +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/NOTICE +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/setup.cfg +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/__init__.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/dump.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/filters.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/fingerprint.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/grounding.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/identity.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/inject.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/schema.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/schema_ops.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/tomledit.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/uid.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline/version.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/SOURCES.txt +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/tests/test_amend.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/tests/test_doctor.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/tests/test_filters.py +0 -0
- {throughline-2.0.0 → throughline-2.2.0}/tests/test_ratify_status.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
367
|
-
role bindings come first because
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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,
|
|
376
|
-
values a vocabulary should hold, so passing
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
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).
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|