throughline 2.2.1__tar.gz → 2.3.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 (36) hide show
  1. {throughline-2.2.1/src/throughline.egg-info → throughline-2.3.0}/PKG-INFO +3 -3
  2. {throughline-2.2.1 → throughline-2.3.0}/README.md +2 -2
  3. {throughline-2.2.1 → throughline-2.3.0}/pyproject.toml +1 -1
  4. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/cli.py +101 -1
  5. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/grounding.py +35 -19
  6. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/validate.py +17 -12
  7. {throughline-2.2.1 → throughline-2.3.0/src/throughline.egg-info}/PKG-INFO +3 -3
  8. {throughline-2.2.1 → throughline-2.3.0}/tests/test_doctor.py +114 -0
  9. {throughline-2.2.1 → throughline-2.3.0}/tests/test_engine.py +151 -2
  10. {throughline-2.2.1 → throughline-2.3.0}/LICENSE +0 -0
  11. {throughline-2.2.1 → throughline-2.3.0}/NOTICE +0 -0
  12. {throughline-2.2.1 → throughline-2.3.0}/setup.cfg +0 -0
  13. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/__init__.py +0 -0
  14. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/dump.py +0 -0
  15. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/filters.py +0 -0
  16. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/fingerprint.py +0 -0
  17. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/graph.py +0 -0
  18. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/identity.py +0 -0
  19. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/inject.py +0 -0
  20. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/model.py +0 -0
  21. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/schema.py +0 -0
  22. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/schema_ops.py +0 -0
  23. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/storage.py +0 -0
  24. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/tomledit.py +0 -0
  25. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/uid.py +0 -0
  26. {throughline-2.2.1 → throughline-2.3.0}/src/throughline/version.py +0 -0
  27. {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/SOURCES.txt +0 -0
  28. {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/dependency_links.txt +0 -0
  29. {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/entry_points.txt +0 -0
  30. {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/requires.txt +0 -0
  31. {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/top_level.txt +0 -0
  32. {throughline-2.2.1 → throughline-2.3.0}/tests/test_amend.py +0 -0
  33. {throughline-2.2.1 → throughline-2.3.0}/tests/test_filters.py +0 -0
  34. {throughline-2.2.1 → throughline-2.3.0}/tests/test_ratify_status.py +0 -0
  35. {throughline-2.2.1 → throughline-2.3.0}/tests/test_schema_ops.py +0 -0
  36. {throughline-2.2.1 → throughline-2.3.0}/tests/test_yaml_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 2.2.1
3
+ Version: 2.3.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,10 +36,10 @@ 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
- 159
39
+ 161
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
- 25
42
+ 26
43
43
  <!-- tl:end --> user requirements, and
44
44
  <!-- tl:count type == 'nfr' -->
45
45
  21
@@ -8,10 +8,10 @@ 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
- 159
11
+ 161
12
12
  <!-- tl:end --> system requirements,
13
13
  <!-- tl:count type == 'user_requirement' -->
14
- 25
14
+ 26
15
15
  <!-- tl:end --> user requirements, and
16
16
  <!-- tl:count type == 'nfr' -->
17
17
  21
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "throughline"
7
- version = "2.2.1"
7
+ version = "2.3.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"
@@ -20,6 +20,7 @@ from .graph import Index
20
20
  from .grounding import (
21
21
  GroundingError,
22
22
  invalidate,
23
+ ratification_obstacle,
23
24
  ratify,
24
25
  reaches_root,
25
26
  set_status,
@@ -1695,6 +1696,75 @@ def cmd_subgraph(args) -> int:
1695
1696
  return OK
1696
1697
 
1697
1698
 
1699
+ def _render_for_ratification(project, item, *, emit) -> None:
1700
+ """Put the item in front of the person about to sign it (SR-0195, UR-0029).
1701
+
1702
+ Not :func:`throughline.inject.render_item`: that renders Markdown for a
1703
+ published document, and states every attribute alike. A ratifier is being asked
1704
+ to judge, so this separates the normative attributes — the ones whose change
1705
+ breaks the signature — from the rest, and names each grounding target so the
1706
+ 'why' reads as a sentence instead of a bare UID the reader has to go and look
1707
+ up. Emitted on stderr like every other piece of guidance (SR-0120), leaving
1708
+ stdout to the one line that says what was ratified.
1709
+ """
1710
+ schema = project.schema
1711
+ emit("")
1712
+ emit(f"{item.uid} [{item.type}/{item.status}] {item.title}".rstrip())
1713
+ for label, body in (("", item.text), ("rationale", item.rationale)):
1714
+ if not body:
1715
+ continue
1716
+ emit("")
1717
+ if label:
1718
+ emit(f"{label}:")
1719
+ for line in body.splitlines():
1720
+ emit(f" {line}" if line else "")
1721
+ grounding = [ln for ln in (_ground_line(project, link, schema)
1722
+ for link in item.links) if ln]
1723
+ emit("")
1724
+ if grounding:
1725
+ emit("grounded by:")
1726
+ for line in grounding:
1727
+ emit(f" {line}")
1728
+ else:
1729
+ # A root needs no grounding and says so; anything else could not have got
1730
+ # this far, since ratification_obstacle refuses an ungrounded item.
1731
+ emit("grounded by: nothing — this is a root and justifies itself")
1732
+ normative = [n for n in schema.normative_attrs(item.type) if n in item.attrs]
1733
+ if normative:
1734
+ emit("")
1735
+ emit("normative attributes (a change here breaks this signature): "
1736
+ + " · ".join(f"{n}={item.attrs[n]}" for n in normative))
1737
+ emit("")
1738
+
1739
+
1740
+ def _ground_line(project, link, schema) -> str | None:
1741
+ """One grounding link rendered with its target's title, or None for a link that
1742
+ confers no grounding — those are context, not justification, and listing them
1743
+ here would present a 'relates' neighbour as a reason the item exists."""
1744
+ if link.type not in schema.ground_link_types:
1745
+ return None
1746
+ target = project.get(link.target)
1747
+ if target is None:
1748
+ return f"{link.type} {link.target} (unresolved)"
1749
+ return f"{link.type} {link.target} [{target.type}/{target.status}] {target.title}".rstrip()
1750
+
1751
+
1752
+ def _confirm(question: str) -> bool:
1753
+ """Ask a yes/no question on an interactive terminal, defaulting to no (SR-0195).
1754
+
1755
+ A confirmation is not a prompt for a value, so it has no flag behind it and
1756
+ SR-0120's rule that a fully specified command is never prompted for one does
1757
+ not reach it: the whole point is to stop a command that already says everything
1758
+ it needs to say. Silence, EOF and anything unrecognised all decline — the
1759
+ default must never be the irreversible answer.
1760
+ """
1761
+ try:
1762
+ raw = input(f"{question} [y/N]: ").strip().lower()
1763
+ except EOFError:
1764
+ return False
1765
+ return raw in ("y", "yes")
1766
+
1767
+
1698
1768
  def cmd_ratify(args) -> int:
1699
1769
  try:
1700
1770
  project = load_project(args.path)
@@ -1705,6 +1775,26 @@ def cmd_ratify(args) -> int:
1705
1775
  uid = _resolve_uid(project, args.uid, "ratify", "UID")
1706
1776
  if uid is None:
1707
1777
  return USAGE
1778
+ item = project.get(uid)
1779
+ if item is None:
1780
+ return _err(f"{uid} does not exist")
1781
+ # Refuse before rendering anything or asking anyone (SR-0195). The old order
1782
+ # asked who was taking accountability and only then discovered that nothing
1783
+ # could be signed, which taught that the prompt was a formality. The index is
1784
+ # built once and handed to ratify, so the question asked here and the write
1785
+ # below read the same graph.
1786
+ idx = Index.build(project)
1787
+ obstacle = ratification_obstacle(project.schema, idx, item)
1788
+ if obstacle is not None:
1789
+ return _err(obstacle)
1790
+ # Ratifying is taking accountability, so the content comes before the signature
1791
+ # (UR-0029) — and before the identity prompt, so the reader knows what they are
1792
+ # being asked about while they are being asked. Non-interactive runs render
1793
+ # nothing: there is no reader to serve, and output nobody reads is noise in CI.
1794
+ interactive = _interactive()
1795
+ if interactive:
1796
+ _render_for_ratification(
1797
+ project, item, emit=lambda line: print(line, file=sys.stderr))
1708
1798
  # Offer the identity this repository already signs commits with (SR-0156). It
1709
1799
  # is only ever a default: _resolve_value shows it and takes it on assent, and a
1710
1800
  # non-interactive session that names no ratifier is refused, not signed for.
@@ -1712,8 +1802,18 @@ def cmd_ratify(args) -> int:
1712
1802
  default=default_ratifier(args.path))
1713
1803
  if by is None:
1714
1804
  return USAGE
1805
+ # The stop that makes the rendering more than decoration, asked whether or not
1806
+ # --by was supplied (SR-0195) — a fully specified command is exactly how a bulk
1807
+ # or habitual ratification is run, and display without a stop is a warning that
1808
+ # scrolled past. SR-0120 permits it: confirming an act is not prompting for a
1809
+ # value. Declining writes nothing and is not an error; the user was asked and
1810
+ # answered.
1811
+ if interactive and not _confirm(f"ratify {uid} as {by}?"):
1812
+ print("not ratified", file=sys.stderr)
1813
+ return OK
1715
1814
  try:
1716
- item = ratify(project, uid, by=by, by_id=getattr(args, "by_id", None))
1815
+ item = ratify(project, uid, by=by, index=idx,
1816
+ by_id=getattr(args, "by_id", None))
1717
1817
  except IdentityError as e:
1718
1818
  return _err(str(e))
1719
1819
  except (ProjectError, GroundingError, SchemaError) as e:
@@ -67,6 +67,33 @@ def ratification_refusal(schema, idx: Index, item: Item) -> str | None:
67
67
  return None
68
68
 
69
69
 
70
+ def ratification_obstacle(schema, idx: Index, item: Item) -> str | None:
71
+ """Why :func:`ratify` would refuse ``item`` as the graph now stands, or ``None``
72
+ when it would proceed — the whole precondition set, asked without writing
73
+ anything (SR-0195).
74
+
75
+ ratify must refuse an item it cannot accept *before* a front end renders it or
76
+ asks anyone to confirm it, so "may this be signed?" has to be answerable ahead
77
+ of the act. :func:`ratify` answers it through this same function rather than
78
+ repeating the conditions, so the refusal a user is shown early is by
79
+ construction the refusal the write would have raised. It is a superset of
80
+ :func:`ratification_refusal`, which stays the narrower "must never be signed"
81
+ predicate the migration repair binds against (SR-0152) — an unstamped record
82
+ is exactly what that repair exists to complete, so it must not be told that an
83
+ already-ratified item has nothing to accept."""
84
+ refusal = ratification_refusal(schema, idx, item)
85
+ if refusal is not None:
86
+ return refusal
87
+ already = (item.status == schema.status_role("ratified")
88
+ if schema.ratify_moves_status
89
+ else item.attrs.get(RATIFIED_BY_ATTR) is not None)
90
+ if already and item.attrs.get("ratified_fingerprint") == fingerprint(item, schema):
91
+ return (f"{item.uid} is already ratified by "
92
+ f"{item.attrs.get('ratified_by', 'a human')} and its content has "
93
+ "not changed since — there is nothing to accept")
94
+ return None
95
+
96
+
70
97
  def ratify(project, uid: str, by: str, *, index: Index | None = None,
71
98
  by_id: str | None = None) -> Item:
72
99
  """A human takes accountability. Refused for ambiguous or ungrounded items —
@@ -86,26 +113,15 @@ def ratify(project, uid: str, by: str, *, index: Index | None = None,
86
113
  raise GroundingError(f"{uid} does not exist")
87
114
  schema = project.schema
88
115
  idx = index if index is not None else Index.build(project)
89
- refusal = ratification_refusal(schema, idx, item)
90
- if refusal is not None:
91
- raise GroundingError(refusal)
92
- # Ratifying an already-ratified item whose content has not moved accepts
93
- # nothing, and would replace the record of who accepted it leaving no trace
94
- # that it changed (SR-0148). An item ratified before the stamp existed has
95
- # none to compare against, so that first call is allowed through and stamps it.
116
+ # Every reason this may be refused, including that an already-ratified item
117
+ # whose content has not moved accepts nothing and would replace the record of
118
+ # who accepted it leaving no trace that it changed (SR-0148). An item ratified
119
+ # before the stamp existed has none to compare against, so that first call is
120
+ # allowed through and stamps it.
121
+ obstacle = ratification_obstacle(schema, idx, item)
122
+ if obstacle is not None:
123
+ raise GroundingError(obstacle)
96
124
  current = fingerprint(item, schema)
97
- # "Already ratified" is read from whatever this project uses as the durable
98
- # proof (SR-0172). Where ratification advances the item, that is the status, as
99
- # it always has been. Where it does not, the status says nothing about sign-off
100
- # and the record itself is the only honest witness.
101
- already = (item.status == schema.status_role("ratified")
102
- if schema.ratify_moves_status
103
- else item.attrs.get(RATIFIED_BY_ATTR) is not None)
104
- if already and item.attrs.get("ratified_fingerprint") == current:
105
- raise GroundingError(
106
- f"{uid} is already ratified by "
107
- f"{item.attrs.get('ratified_by', 'a human')} and its content has not "
108
- "changed since — there is nothing to accept")
109
125
  # Advancing is the default, and is transition-validated — an item that cannot
110
126
  # legally reach the ratified status is refused rather than moved illegally. A
111
127
  # project that binds the ratified role to a workflow state turns this off, and
@@ -42,6 +42,7 @@ _DEFAULT_SEVERITY = {
42
42
  "uid-grammar": ERROR, "uid-collision": ERROR, "prefix-collision": ERROR,
43
43
  "malformed-link": ERROR,
44
44
  "schema": ERROR, "unknown-key": ERROR, "empty-graph": ERROR,
45
+ "empty-registers": WARNING,
45
46
  "dangling-link": ERROR, "deleted-link-target": ERROR, "refines-cycle": ERROR,
46
47
  "namespace-unresolved": ERROR,
47
48
  "grounding-cycle": ERROR, "orphan": ERROR, "unserved-root": ERROR,
@@ -142,21 +143,25 @@ def validate(project, strict: bool = False,
142
143
  f"the route, or `tl schema transition allow {status} {suspect}` "
143
144
  "adds this one")
144
145
 
145
- # A run that discovered nothing is not a sound graph (SR-0146). Items live only
146
- # beneath a register manifest, so a project whose manifests are missing, misnamed
147
- # or unmigrated loads zero items — and every rule below then passes vacuously,
148
- # reporting "sound" while nothing at all was validated.
146
+ # A run that discovered nothing validated nothing. Two causes, two rules:
147
+ # no register manifest at all is a misconfiguration — manifests missing,
148
+ # misnamed or unmigrated — and every rule below would pass vacuously,
149
+ # reporting "sound" while nothing was validated, so it is an error
150
+ # (SR-0146). Registers that exist but hold no items are the state a graph
151
+ # is in between choosing its registers and authoring its first item, so
152
+ # that is a warning, which --strict still raises to an error (SR-0194).
149
153
  if next(project.items(), None) is None:
150
154
  if not project.registers:
151
- why = ("no register was found beneath this project — items are only "
152
- "loaded from a folder holding a .register.yml manifest; run "
153
- "`tl register new` to create one, or `tl migrate` if this "
154
- "project predates the current format")
155
+ add("empty-graph", "", str(project.path),
156
+ "no items were discovered, so this check validated nothing: no "
157
+ "register was found beneath this project — items are only loaded "
158
+ "from a folder holding a .register.yml manifest; run `tl register "
159
+ "new` to create one, or `tl migrate` if this project predates the "
160
+ "current format")
155
161
  else:
156
- why = ("its registers hold no items — run `tl new <PREFIX>` to author "
157
- "one")
158
- add("empty-graph", "", str(project.path),
159
- f"no items were discovered, so this check validated nothing: {why}")
162
+ add("empty-registers", "", str(project.path),
163
+ "no items were discovered, so this check validated nothing: its "
164
+ "registers hold no items yet — run `tl new <PREFIX>` to author one")
160
165
 
161
166
  # Malformed structure tolerated at load time (SR-0134): a link entry that is
162
167
  # not a mapping or is missing its target would once have crashed the loader
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 2.2.1
3
+ Version: 2.3.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,10 +36,10 @@ 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
- 159
39
+ 161
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
- 25
42
+ 26
43
43
  <!-- tl:end --> user requirements, and
44
44
  <!-- tl:count type == 'nfr' -->
45
45
  21
@@ -148,6 +148,7 @@ def test_a_cli_in_its_own_venv_is_inspected_not_skipped(tmp_path, monkeypatch):
148
148
  script = _fake_cli_venv(tmp_path, "pipx-tl")
149
149
  monkeypatch.setattr(doctor, "TOOLCHAIN", ("throughline",))
150
150
  monkeypatch.setattr(doctor, "CLI_FOR", {"throughline": "tl"})
151
+ monkeypatch.setattr(doctor, "_toolchain_clis", lambda: ["tl"])
151
152
  monkeypatch.setattr(doctor.shutil, "which", lambda _c: str(script))
152
153
  monkeypatch.setattr(Path, "is_file", lambda self: self.name == "pyproject.toml")
153
154
  monkeypatch.setattr(
@@ -163,11 +164,73 @@ def test_a_cli_in_its_own_venv_is_inspected_not_skipped(tmp_path, monkeypatch):
163
164
  assert "does NOT convert an existing venv" in result.remediation
164
165
 
165
166
 
167
+ def test_a_published_plain_name_passes_when_a_suffixed_one_runs_the_tree(
168
+ tmp_path, monkeypatch
169
+ ):
170
+ """The deliberate two-install arrangement (UR-0021).
171
+
172
+ `tl` is the release, kept on the plain name so estate work is gated against a
173
+ published build; `tl-local` runs the working tree. Nothing is standing in for
174
+ anything, so the check must report which command runs which and pass — failing
175
+ here would ban the arrangement outright.
176
+ """
177
+ released = _fake_cli_venv(tmp_path, "pipx-tl")
178
+ local = _fake_cli_venv(tmp_path, "pipx-tl-local")
179
+ monkeypatch.setattr(doctor, "TOOLCHAIN", ("throughline",))
180
+ monkeypatch.setattr(doctor, "CLI_FOR", {"throughline": "tl"})
181
+ monkeypatch.setattr(doctor, "_toolchain_clis", lambda: ["tl", "tl-local"])
182
+ monkeypatch.setattr(
183
+ doctor.shutil,
184
+ "which",
185
+ lambda c: str(local) if c == "tl-local" else str(released),
186
+ )
187
+ monkeypatch.setattr(Path, "is_file", lambda self: self.name == "pyproject.toml")
188
+ monkeypatch.setattr(
189
+ doctor,
190
+ "_kinds_in",
191
+ lambda p: {"throughline": ["editable", str(doctor.REPO_ROOT)]}
192
+ if "pipx-tl-local" in str(p)
193
+ else {"throughline": ["published", "2.2.1"]},
194
+ )
195
+
196
+ result = doctor.check_cli_toolchain_chained()
197
+
198
+ assert result.ok, result.detail
199
+ assert "tl \u2192 throughline: published 2.2.1" in result.detail
200
+ assert "tl-local \u2192 throughline: editable" in result.detail
201
+
202
+
203
+ def test_no_command_runs_the_tree_is_the_only_failure(tmp_path, monkeypatch):
204
+ """Both names published means the contributor's edits run nowhere — the failure
205
+ the check still exists to catch."""
206
+ released = _fake_cli_venv(tmp_path, "pipx-tl")
207
+ local = _fake_cli_venv(tmp_path, "pipx-tl-local")
208
+ monkeypatch.setattr(doctor, "TOOLCHAIN", ("throughline",))
209
+ monkeypatch.setattr(doctor, "CLI_FOR", {"throughline": "tl"})
210
+ monkeypatch.setattr(doctor, "_toolchain_clis", lambda: ["tl", "tl-local"])
211
+ monkeypatch.setattr(
212
+ doctor.shutil,
213
+ "which",
214
+ lambda c: str(local) if c == "tl-local" else str(released),
215
+ )
216
+ monkeypatch.setattr(Path, "is_file", lambda self: self.name == "pyproject.toml")
217
+ monkeypatch.setattr(
218
+ doctor, "_kinds_in", lambda _p: {"throughline": ["published", "2.2.1"]}
219
+ )
220
+
221
+ result = doctor.check_cli_toolchain_chained()
222
+
223
+ assert not result.ok
224
+ assert "No command on PATH runs your working tree" in result.remediation
225
+ assert "pipx --suffix" in result.remediation
226
+
227
+
166
228
  def test_the_current_environment_is_not_reported_twice(monkeypatch):
167
229
  """The in-process check already judges it; naming it again as a CLI environment
168
230
  would report one divergence as two."""
169
231
  monkeypatch.setattr(doctor, "TOOLCHAIN", ("throughline",))
170
232
  monkeypatch.setattr(doctor, "CLI_FOR", {"throughline": "tl"})
233
+ monkeypatch.setattr(doctor, "_toolchain_clis", lambda: ["tl"])
171
234
  monkeypatch.setattr(
172
235
  doctor.shutil, "which", lambda _c: str(Path(sys.prefix) / "bin" / "tl")
173
236
  )
@@ -179,6 +242,57 @@ def test_the_current_environment_is_not_reported_twice(monkeypatch):
179
242
  assert result.detail == "no separate CLI environments"
180
243
 
181
244
 
245
+ def test_any_suffix_is_discovered_not_just_a_blessed_one(tmp_path, monkeypatch):
246
+ """Which suffix a second install uses is the contributor's choice.
247
+
248
+ Naming one here would publish a private convention as project policy and quietly
249
+ fail everyone who picked a different word, so the extra names are read off PATH.
250
+ A separator is required, or `tlsomething` would be mistaken for the toolchain.
251
+ """
252
+ bindir = tmp_path / "bin"
253
+ bindir.mkdir()
254
+ for name in ("tl", "tl-wibble", "tl_dev", "tlsomething", "unrelated"):
255
+ (bindir / name).write_text("#!/bin/sh\n")
256
+ monkeypatch.setattr(doctor.os, "get_exec_path", lambda: [str(bindir)])
257
+
258
+ found = doctor._toolchain_clis()
259
+
260
+ assert "tl-wibble" in found, "an arbitrary suffix must be discovered"
261
+ assert "tl_dev" in found
262
+ assert "tlsomething" not in found, "a separator is required, not a bare prefix"
263
+ assert "unrelated" not in found
264
+
265
+
266
+ def test_a_command_sharing_a_prefix_but_not_the_toolchain_is_ignored(
267
+ tmp_path, monkeypatch
268
+ ):
269
+ """Discovery is deliberately loose, so the environment has to be the filter —
270
+ otherwise an unrelated `tl-*` tool would be reported as toolchain divergence."""
271
+ ours = _fake_cli_venv(tmp_path, "pipx-tl")
272
+ stranger = _fake_cli_venv(tmp_path, "pipx-tl-unrelated")
273
+ monkeypatch.setattr(doctor, "TOOLCHAIN", ("throughline",))
274
+ monkeypatch.setattr(doctor, "CLI_FOR", {"throughline": "tl"})
275
+ monkeypatch.setattr(doctor, "_toolchain_clis", lambda: ["tl", "tl-unrelated"])
276
+ monkeypatch.setattr(
277
+ doctor.shutil,
278
+ "which",
279
+ lambda c: str(stranger) if c == "tl-unrelated" else str(ours),
280
+ )
281
+ monkeypatch.setattr(Path, "is_file", lambda self: self.name == "pyproject.toml")
282
+ monkeypatch.setattr(
283
+ doctor,
284
+ "_kinds_in",
285
+ lambda p: {"throughline": ["absent", ""]}
286
+ if "pipx-tl-unrelated" in str(p)
287
+ else {"throughline": ["editable", str(doctor.REPO_ROOT)]},
288
+ )
289
+
290
+ result = doctor.check_cli_toolchain_chained()
291
+
292
+ assert result.ok, result.detail
293
+ assert "tl-unrelated" not in result.detail
294
+
295
+
182
296
  def test_probe_mode_reports_this_interpreter_as_json(capsys):
183
297
  """The probe is how one implementation of the rule is run in another environment
184
298
  — so it must stay machine-readable and stdout-clean."""
@@ -368,6 +368,26 @@ def test_empty_graph_fails_rather_than_passing_vacuously(tmp_path):
368
368
  assert _errors(findings)
369
369
  assert "register" in next(f.message for f in findings if f.rule == "empty-graph")
370
370
 
371
+ def test_registers_with_no_items_warn_rather_than_fail(tmp_path):
372
+ """Registers laid down but nothing authored yet is a legitimate state, not a
373
+ misconfiguration (SR-0194): it warns, so `check` exits 0, and it fails only
374
+ under --strict, as every warning does. A project with no manifest at all is
375
+ still SR-0146's error, tested above."""
376
+ root = tmp_path / "proj"
377
+ assert _cli(["-C", str(root), "init", "--name", "t", "--bare"]) == 0
378
+ assert _cli(["-C", str(root), "register", "new", "INT", "vision"]) == 0
379
+ p = load_project(str(root))
380
+ assert next(p.items(), None) is None
381
+ findings = validate(p, strict=False)
382
+ severities = {f.rule: f.severity for f in findings}
383
+ assert severities.get("empty-registers") == "warning"
384
+ assert "empty-graph" not in severities
385
+ assert not _errors(findings)
386
+ assert "tl new" in next(f.message for f in findings if f.rule == "empty-registers")
387
+ assert _errors(validate(p, strict=True))
388
+ assert _cli(["-C", str(root), "check", "--quiet"]) == 0
389
+ assert _cli(["-C", str(root), "check", "--strict", "--quiet"]) == 1
390
+
371
391
  def test_unknown_top_level_key_is_reported(tmp_path):
372
392
  """A key that is neither a core field nor `attrs` is read by nothing, so a
373
393
  misplaced one fails silently — most damagingly `origin`, which at the top level
@@ -2906,12 +2926,141 @@ def test_ratify_wizard_prompts_for_ratifier(tmp_path, monkeypatch):
2906
2926
  "--title", "feat", "--status", "proposed", "--ground", "INT-0001",
2907
2927
  "--no-interactive"]) == 0
2908
2928
  # single type after picking is 'requirement'; but INT-0001 is also live, so the
2909
- # type stage runs: [intent, requirement] -> pick requirement(#2), item #1, then by.
2910
- _make_tty(monkeypatch, ["2", "1", "henry"])
2929
+ # type stage runs: [intent, requirement] -> pick requirement(#2), item #1, then
2930
+ # by, then the SR-0195 confirmation.
2931
+ _make_tty(monkeypatch, ["2", "1", "henry", "y"])
2911
2932
  assert _cli(["-C", str(root), "ratify"]) == 0
2912
2933
  assert load_project(root).get("FR-0001").attrs["ratified_by"] == "henry"
2913
2934
 
2914
2935
 
2936
+ # ------------------------------------ ratify shows what is being signed (SR-0195)
2937
+
2938
+ def _ratifiable(tmp_path):
2939
+ """A scaffold holding one grounded, proposed requirement with both text and a
2940
+ rationale — the shape a ratifier is actually asked to judge."""
2941
+ root = _scaffold(tmp_path)
2942
+ assert _cli(["-C", str(root), "new", "FR", "--type", "requirement",
2943
+ "--title", "Widget cache expires", "--status", "proposed",
2944
+ "--text", "The Tool shall evict a cached widget after an hour.",
2945
+ "--ground", "INT-0001", "--no-interactive"]) == 0
2946
+ assert _cli(["-C", str(root), "amend", "FR-0001",
2947
+ "--rationale", "Stale widgets were served for days."]) == 0
2948
+ return root
2949
+
2950
+
2951
+ def _tty_marking_prompts(monkeypatch, answers):
2952
+ """An interactive terminal whose every prompt leaves a marker in the stream the
2953
+ rendering also writes to, so a test can assert that the content came first
2954
+ rather than merely that both appeared."""
2955
+ import throughline.cli as _climod
2956
+ monkeypatch.setattr(_climod, "_interactive", lambda: True)
2957
+ supply = iter(answers)
2958
+
2959
+ def _input(*_a, **_k):
2960
+ print("<<asked>>", file=sys.stderr)
2961
+ return next(supply)
2962
+
2963
+ monkeypatch.setattr("builtins.input", _input)
2964
+
2965
+
2966
+ def test_ratify_renders_the_item_before_it_asks_anything(tmp_path, monkeypatch, capsys):
2967
+ """The content a signature is being taken for goes in front of the signer, and
2968
+ before any prompt (SR-0195, UR-0029) — text, rationale and each grounding target
2969
+ named, not left as a UID to go and look up."""
2970
+ root = _ratifiable(tmp_path)
2971
+ _tty_marking_prompts(monkeypatch, ["henry", "y"])
2972
+ assert _cli(["-C", str(root), "ratify", "FR-0001"]) == 0
2973
+ err = capsys.readouterr().err
2974
+ for shown in ("Widget cache expires",
2975
+ "The Tool shall evict a cached widget after an hour.",
2976
+ "Stale widgets were served for days.",
2977
+ "derives_from INT-0001"):
2978
+ assert shown in err, shown
2979
+ assert "why" in err # the grounding target's own title
2980
+ assert err.index("Stale widgets") < err.index("<<asked>>")
2981
+
2982
+
2983
+ def test_ratify_renders_and_confirms_even_when_by_was_supplied(tmp_path, monkeypatch,
2984
+ capsys):
2985
+ """A fully specified command is exactly how a habitual ratification is run, so it
2986
+ is the one that must not skip the content or the stop (SR-0195). SR-0120 permits
2987
+ the prompt: confirming an act is not prompting for a value."""
2988
+ root = _ratifiable(tmp_path)
2989
+ _tty_marking_prompts(monkeypatch, ["y"])
2990
+ assert _cli(["-C", str(root), "ratify", "FR-0001", "--by", "henry"]) == 0
2991
+ err = capsys.readouterr().err
2992
+ assert "The Tool shall evict a cached widget after an hour." in err
2993
+ assert "<<asked>>" in err # the confirmation still happened
2994
+ assert load_project(root).get("FR-0001").attrs["ratified_by"] == "henry"
2995
+
2996
+
2997
+ def test_declining_the_confirmation_writes_no_record(tmp_path, monkeypatch):
2998
+ """The stop is a real stop: a declined confirmation records nothing. It is not an
2999
+ error either — the user was asked and answered (SR-0195)."""
3000
+ root = _ratifiable(tmp_path)
3001
+ _make_tty(monkeypatch, ["henry", "n"])
3002
+ assert _cli(["-C", str(root), "ratify", "FR-0001"]) == 0
3003
+ item = load_project(root).get("FR-0001")
3004
+ assert "ratified_by" not in item.attrs
3005
+ assert item.status == "proposed"
3006
+
3007
+
3008
+ def test_silence_declines_the_confirmation(tmp_path, monkeypatch):
3009
+ """The default must never be the irreversible answer (SR-0195): an empty answer
3010
+ declines rather than signing."""
3011
+ root = _ratifiable(tmp_path)
3012
+ _make_tty(monkeypatch, ["henry", ""])
3013
+ assert _cli(["-C", str(root), "ratify", "FR-0001"]) == 0
3014
+ assert "ratified_by" not in load_project(root).get("FR-0001").attrs
3015
+
3016
+
3017
+ def test_ratify_refuses_before_rendering_or_asking(tmp_path, monkeypatch, capsys):
3018
+ """An item that cannot be signed is refused before anything is rendered and
3019
+ before anyone is asked to name themselves (SR-0195). The old order asked who was
3020
+ taking accountability and only then discovered nothing could be signed."""
3021
+ root = _scaffold(tmp_path)
3022
+ assert _cli(["-C", str(root), "new", "FR", "--type", "requirement",
3023
+ "--title", "ungrounded", "--status", "proposed",
3024
+ "--no-interactive"]) == 0
3025
+ _tty_marking_prompts(monkeypatch, []) # any prompt would raise StopIteration
3026
+ assert _cli(["-C", str(root), "ratify", "FR-0001"]) == 2
3027
+ err = capsys.readouterr().err
3028
+ assert "not grounded to a root" in err
3029
+ assert "<<asked>>" not in err # nobody was asked
3030
+ assert "ungrounded" not in err # and the item was never rendered
3031
+
3032
+
3033
+ def test_an_already_ratified_item_is_refused_before_rendering(tmp_path, monkeypatch,
3034
+ capsys):
3035
+ """The same ordering for the other refusal (SR-0195, SR-0148): rendering an item
3036
+ and taking a signature, only to then say there was nothing to accept, is the bug
3037
+ this ordering closes. ratify asks one precondition function, so the refusal shown
3038
+ early is the refusal the write would have raised."""
3039
+ root = _ratifiable(tmp_path)
3040
+ assert _cli(["-C", str(root), "ratify", "FR-0001", "--by", "alice"]) == 0
3041
+ capsys.readouterr() # discard the first, legitimate run
3042
+ _tty_marking_prompts(monkeypatch, []) # any prompt would raise StopIteration
3043
+ assert _cli(["-C", str(root), "ratify", "FR-0001"]) == 2
3044
+ err = capsys.readouterr().err
3045
+ assert "already ratified by alice" in err
3046
+ assert "<<asked>>" not in err
3047
+ assert "Widget cache expires" not in err
3048
+ assert load_project(root).get("FR-0001").attrs["ratified_by"] == "alice"
3049
+
3050
+
3051
+ def test_non_interactive_ratify_renders_nothing_and_confirms_nothing(tmp_path, capsys):
3052
+ """Without a terminal there is no reader to serve and nothing that may block, so
3053
+ neither the rendering nor the confirmation applies and SR-0120's automation
3054
+ guarantee is untouched (SR-0195)."""
3055
+ root = _ratifiable(tmp_path)
3056
+ assert _cli(["-C", str(root), "ratify", "FR-0001", "--by", "henry"]) == 0
3057
+ out = capsys.readouterr()
3058
+ assert "The Tool shall evict a cached widget after an hour." not in out.err
3059
+ assert "Stale widgets were served for days." not in out.err
3060
+ assert "FR-0001 ratified by henry" in out.out
3061
+ assert load_project(root).get("FR-0001").attrs["ratified_by"] == "henry"
3062
+
3063
+
2915
3064
  def test_register_new_refuses_duplicate_prefix(tmp_path):
2916
3065
  """A prefix owns a UID namespace and must be unique (SR-0101). `tl register
2917
3066
  new` refuses a prefix another register already declares, exits with a usage
File without changes
File without changes
File without changes