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.
- {throughline-2.2.1/src/throughline.egg-info → throughline-2.3.0}/PKG-INFO +3 -3
- {throughline-2.2.1 → throughline-2.3.0}/README.md +2 -2
- {throughline-2.2.1 → throughline-2.3.0}/pyproject.toml +1 -1
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/cli.py +101 -1
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/grounding.py +35 -19
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/validate.py +17 -12
- {throughline-2.2.1 → throughline-2.3.0/src/throughline.egg-info}/PKG-INFO +3 -3
- {throughline-2.2.1 → throughline-2.3.0}/tests/test_doctor.py +114 -0
- {throughline-2.2.1 → throughline-2.3.0}/tests/test_engine.py +151 -2
- {throughline-2.2.1 → throughline-2.3.0}/LICENSE +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/NOTICE +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/setup.cfg +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/__init__.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/dump.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/filters.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/fingerprint.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/graph.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/identity.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/inject.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/model.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/schema.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/schema_ops.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/storage.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/tomledit.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/uid.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline/version.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/SOURCES.txt +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/tests/test_amend.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/tests/test_filters.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/tests/test_ratify_status.py +0 -0
- {throughline-2.2.1 → throughline-2.3.0}/tests/test_schema_ops.py +0 -0
- {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.
|
|
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
|
-
|
|
39
|
+
161
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
|
-
|
|
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
|
-
|
|
11
|
+
161
|
|
12
12
|
<!-- tl:end --> system requirements,
|
|
13
13
|
<!-- tl:count type == 'user_requirement' -->
|
|
14
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
146
|
-
#
|
|
147
|
-
# or unmigrated
|
|
148
|
-
# reporting "sound" while nothing
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
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
|
-
|
|
39
|
+
161
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
|
-
|
|
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
|
|
2910
|
-
|
|
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
|
|
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
|