throughline 3.2.0__tar.gz → 3.4.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-3.2.0/src/throughline.egg-info → throughline-3.4.0}/PKG-INFO +3 -3
- {throughline-3.2.0 → throughline-3.4.0}/README.md +2 -2
- {throughline-3.2.0 → throughline-3.4.0}/pyproject.toml +1 -1
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/cli.py +119 -76
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/ratification.py +72 -34
- {throughline-3.2.0 → throughline-3.4.0/src/throughline.egg-info}/PKG-INFO +3 -3
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline.egg-info/SOURCES.txt +1 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_engine.py +30 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_ratification_change.py +63 -0
- throughline-3.4.0/tests/test_ratify_several.py +94 -0
- {throughline-3.2.0 → throughline-3.4.0}/LICENSE +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/NOTICE +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/setup.cfg +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/__init__.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/dump.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/filters.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/fingerprint.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/graph.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/grounding.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/identity.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/inject.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/model.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/schema.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/schema_ops.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/storage.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/tomledit.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/uid.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/validate.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline/version.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_amend.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_doctor.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_filters.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_ratification_withdrawal.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_ratifier_correction.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_ratify_status.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_schema_ops.py +0 -0
- {throughline-3.2.0 → throughline-3.4.0}/tests/test_yaml_loader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: throughline
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.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
|
+
166
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
|
-
|
|
42
|
+
29
|
|
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
|
+
166
|
|
12
12
|
<!-- tl:end --> system requirements,
|
|
13
13
|
<!-- tl:count type == 'user_requirement' -->
|
|
14
|
-
|
|
14
|
+
29
|
|
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 = "3.
|
|
7
|
+
version = "3.4.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"
|
|
@@ -629,6 +629,21 @@ def cmd_link(args) -> int:
|
|
|
629
629
|
print(f"retyped {src_uid} {dst_uid}: --{old_type}--> is now --{ltype}-->"
|
|
630
630
|
+ (" (stamped)" if args.stamp else ""))
|
|
631
631
|
return OK
|
|
632
|
+
# The same edge twice is never wanted: with --stamp it is the re-confirm
|
|
633
|
+
# command SR-0034 promises, refreshing the stored fingerprint in place;
|
|
634
|
+
# without it there is nothing to add, and a parallel duplicate would hide
|
|
635
|
+
# a stale stamp behind a fresh one.
|
|
636
|
+
same = [ln for ln in src.links if ln.target == dst_uid and ln.type == ltype]
|
|
637
|
+
if same:
|
|
638
|
+
if not args.stamp:
|
|
639
|
+
return _err(f"{src_uid} --{ltype}--> {dst_uid} already exists "
|
|
640
|
+
f"(add --stamp to refresh its stamp, or --retype to "
|
|
641
|
+
f"change its type)")
|
|
642
|
+
for ln in same:
|
|
643
|
+
ln.stamp = stamp
|
|
644
|
+
write_item(src, project.register_of(src.uid))
|
|
645
|
+
print(f"restamped {src_uid} --{ltype}--> {dst_uid}")
|
|
646
|
+
return OK
|
|
632
647
|
src.links.append(Link(target=dst_uid, type=ltype, stamp=stamp))
|
|
633
648
|
write_item(src, project.register_of(src.uid))
|
|
634
649
|
print(f"linked {src_uid} --{ltype}--> {dst_uid}"
|
|
@@ -1784,91 +1799,115 @@ def _confirm(question: str) -> bool:
|
|
|
1784
1799
|
|
|
1785
1800
|
|
|
1786
1801
|
def cmd_ratify(args) -> int:
|
|
1802
|
+
"""Ratify one or more items (SR-0199). Every item named is checked before any
|
|
1803
|
+
is rendered or signed, so a run that cannot complete writes nothing; the
|
|
1804
|
+
ratifier is asked once; each item is then shown and confirmed on its own,
|
|
1805
|
+
because the signature is per item (UR-0029)."""
|
|
1787
1806
|
try:
|
|
1788
1807
|
project = load_project(args.path)
|
|
1789
1808
|
except ProjectError as e:
|
|
1790
1809
|
return _err(str(e))
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
# Refuse before rendering anything or asking anyone (SR-0195)
|
|
1800
|
-
#
|
|
1801
|
-
#
|
|
1802
|
-
#
|
|
1803
|
-
# below read the same graph.
|
|
1810
|
+
uids = list(args.uids)
|
|
1811
|
+
if not uids:
|
|
1812
|
+
# A proposed item is the usual target, but ratify tolerates any item (the
|
|
1813
|
+
# grounding layer decides what it means), so the picker lists all live items.
|
|
1814
|
+
uid = _resolve_uid(project, None, "ratify", "UID")
|
|
1815
|
+
if uid is None:
|
|
1816
|
+
return USAGE
|
|
1817
|
+
uids = [uid]
|
|
1818
|
+
# Refuse before rendering anything or asking anyone (SR-0195), and for the
|
|
1819
|
+
# whole run before any item in it (SR-0199). The old order asked who was taking
|
|
1820
|
+
# accountability and only then discovered that nothing could be signed, which
|
|
1821
|
+
# taught that the prompt was a formality. The index is built once and handed to
|
|
1822
|
+
# ratify, so the question asked here and the writes below read the same graph.
|
|
1804
1823
|
idx = Index.build(project)
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1824
|
+
items = []
|
|
1825
|
+
for uid in uids:
|
|
1826
|
+
item = project.get(uid)
|
|
1827
|
+
if item is None:
|
|
1828
|
+
return _err(f"{uid} does not exist")
|
|
1829
|
+
obstacle = ratification_obstacle(project.schema, idx, item,
|
|
1830
|
+
replacing=args.replacing)
|
|
1831
|
+
if obstacle is not None:
|
|
1832
|
+
return _err(obstacle if len(uids) == 1
|
|
1833
|
+
else f"{obstacle} — nothing in this run was ratified")
|
|
1834
|
+
items.append(item)
|
|
1813
1835
|
# Resolved once, before anything is shown, so the difference put in front of
|
|
1814
1836
|
# the ratifier and the gate below are the same answer (SR-0165).
|
|
1815
|
-
|
|
1837
|
+
changes = {item.uid: change_since_ratification(project, item) for item in items}
|
|
1816
1838
|
interactive = _interactive()
|
|
1817
|
-
if interactive:
|
|
1818
|
-
|
|
1819
|
-
|
|
1839
|
+
if not interactive:
|
|
1840
|
+
for item in items:
|
|
1841
|
+
if changes[item.uid].stale and not args.accept_change:
|
|
1842
|
+
return _err(
|
|
1843
|
+
f"{item.uid} has changed since "
|
|
1844
|
+
f"{item.attrs.get(RATIFIED_BY_ATTR, 'a human')} ratified it and this "
|
|
1845
|
+
"session cannot show you what changed — pass --accept-change to "
|
|
1846
|
+
"record a signature over a change accepted unseen, or run this on "
|
|
1847
|
+
"a terminal to see it first")
|
|
1820
1848
|
# Offer the identity this repository already signs commits with (SR-0156). It
|
|
1821
1849
|
# is only ever a default: _resolve_value shows it and takes it on assent, and a
|
|
1822
1850
|
# non-interactive session that names no ratifier is refused, not signed for.
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1851
|
+
# Asked once for the run — the person does not change between items — and on a
|
|
1852
|
+
# terminal not until the first item has been shown, so the reader knows what
|
|
1853
|
+
# they are being asked about while they are being asked (UR-0029, SR-0195).
|
|
1854
|
+
by = None
|
|
1855
|
+
if not interactive:
|
|
1856
|
+
by = _resolve_value(args.by, "ratifier", "--by",
|
|
1857
|
+
default=default_ratifier(args.path))
|
|
1858
|
+
if by is None:
|
|
1859
|
+
return USAGE
|
|
1860
|
+
rc = OK
|
|
1861
|
+
for item in items:
|
|
1862
|
+
uid = item.uid
|
|
1863
|
+
change = changes[uid]
|
|
1864
|
+
# Ratifying is taking accountability, so the content comes before the
|
|
1865
|
+
# signature (UR-0029). Non-interactive runs render nothing: there is no
|
|
1866
|
+
# reader to serve, and output nobody reads is noise in CI.
|
|
1834
1867
|
if interactive:
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
"
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1868
|
+
_render_for_ratification(
|
|
1869
|
+
project, item, emit=lambda line: print(line, file=sys.stderr))
|
|
1870
|
+
# Re-ratifying is taking accountability afresh, so what moved since the
|
|
1871
|
+
# last signature goes in the path of this one (SR-0167). Placed after the
|
|
1872
|
+
# item and immediately before the stop, because that is where a reader
|
|
1873
|
+
# is deciding. Painted as git would paint it: colour on a terminal, none
|
|
1874
|
+
# when the reader has asked for none (NO_COLOR, https://no-color.org).
|
|
1875
|
+
if change.stale:
|
|
1876
|
+
for line in render_change(
|
|
1877
|
+
change, ratifier=item.attrs.get(RATIFIED_BY_ATTR),
|
|
1878
|
+
columns=shutil.get_terminal_size((80, 24)).columns,
|
|
1879
|
+
colour=not os.environ.get("NO_COLOR")):
|
|
1880
|
+
print(line, file=sys.stderr)
|
|
1881
|
+
print("", file=sys.stderr)
|
|
1882
|
+
# The stop that makes the rendering more than decoration, asked whether
|
|
1883
|
+
# or not --by was supplied (SR-0195) — a fully specified command is
|
|
1884
|
+
# exactly how a bulk or habitual ratification is run, and display
|
|
1885
|
+
# without a stop is a warning that scrolled past. SR-0120 permits it:
|
|
1886
|
+
# confirming an act is not prompting for a value. Declining writes
|
|
1887
|
+
# nothing for this item and is not an error; the user was asked and
|
|
1888
|
+
# answered, and the run goes on to the next item (SR-0199).
|
|
1889
|
+
if by is None:
|
|
1890
|
+
by = _resolve_value(args.by, "ratifier", "--by",
|
|
1891
|
+
default=default_ratifier(args.path))
|
|
1892
|
+
if by is None:
|
|
1893
|
+
return USAGE
|
|
1894
|
+
question = (f"replace the ratifier recorded on {uid} with {by}?"
|
|
1895
|
+
if args.replacing else f"ratify {uid} as {by}?")
|
|
1896
|
+
if not _confirm(question):
|
|
1897
|
+
print(f"{uid} not ratified", file=sys.stderr)
|
|
1898
|
+
continue
|
|
1899
|
+
try:
|
|
1900
|
+
item = ratify(project, uid, by=by, index=idx,
|
|
1901
|
+
by_id=getattr(args, "by_id", None),
|
|
1902
|
+
replacing=args.replacing)
|
|
1903
|
+
except IdentityError as e:
|
|
1904
|
+
return _err(str(e))
|
|
1905
|
+
except (ProjectError, GroundingError, SchemaError) as e:
|
|
1906
|
+
return _err(str(e))
|
|
1907
|
+
write_item(item, project.register_of(item.uid))
|
|
1908
|
+
identifier = item.attrs.get(RATIFIED_ID_ATTR)
|
|
1909
|
+
print(f"{uid} ratified by {by}" + (f" ({identifier})" if identifier else ""))
|
|
1910
|
+
return rc
|
|
1872
1911
|
|
|
1873
1912
|
|
|
1874
1913
|
def cmd_invalidate(args) -> int:
|
|
@@ -2205,7 +2244,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2205
2244
|
s.add_argument("--type", default=None,
|
|
2206
2245
|
help="link type (omit on a terminal to choose one)")
|
|
2207
2246
|
s.add_argument("--stamp", action="store_true",
|
|
2208
|
-
help="record target fingerprint (suspect tracking)")
|
|
2247
|
+
help="record the target's fingerprint, or refresh it on an edge that already exists (suspect tracking)")
|
|
2209
2248
|
s.add_argument("--retype", action="store_true",
|
|
2210
2249
|
help="change the type of the existing SRC -> DST link in "
|
|
2211
2250
|
"place instead of adding a new one")
|
|
@@ -2329,8 +2368,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2329
2368
|
s.set_defaults(func=cmd_subgraph)
|
|
2330
2369
|
|
|
2331
2370
|
s = sub.add_parser("ratify", help="a human takes accountability for an item")
|
|
2332
|
-
s
|
|
2333
|
-
|
|
2371
|
+
# A list, as withdraw's is (SR-0197): several proposed items are the usual
|
|
2372
|
+
# shape of a machine-authored batch, and each is still shown and confirmed on
|
|
2373
|
+
# its own (SR-0199). A front end that read the single `uid` by name must now
|
|
2374
|
+
# read `uids`.
|
|
2375
|
+
s.add_argument("uids", nargs="*", metavar="UID",
|
|
2376
|
+
help="item(s) to ratify, in order (omit on a terminal to pick one)")
|
|
2334
2377
|
s.add_argument("--by", default=None,
|
|
2335
2378
|
help="ratifier name (omit on a terminal to be prompted; "
|
|
2336
2379
|
"defaults to the identity this repository signs with)")
|
|
@@ -26,7 +26,9 @@ and each of those deserves to be named rather than smoothed over.
|
|
|
26
26
|
Results are returned as structured data, with rendering kept separate
|
|
27
27
|
(:func:`render_change`). Every other surface that must show this — a composing
|
|
28
28
|
consumer, a review cockpit — would otherwise re-parse formatted text, which is a
|
|
29
|
-
second copy of the rule, drifting from the first.
|
|
29
|
+
second copy of the rule, drifting from the first. The same holds one level down:
|
|
30
|
+
a changed prose field's diff is data too (:func:`diff_prose`, SR-0200), and the
|
|
31
|
+
terminal rendering here is one reader of it among others.
|
|
30
32
|
"""
|
|
31
33
|
from __future__ import annotations
|
|
32
34
|
|
|
@@ -400,20 +402,46 @@ def _units(value: str) -> list[str]:
|
|
|
400
402
|
return _SENTENCE_END.split(value) if value else [""]
|
|
401
403
|
|
|
402
404
|
|
|
403
|
-
def
|
|
405
|
+
def is_prose(was: object, now: object) -> bool:
|
|
406
|
+
"""Whether a changed field is shown as a diff (SR-0198): both values are
|
|
407
|
+
strings and at least one holds whitespace. A token — a priority, a flag — is
|
|
408
|
+
shown as before and after on one line instead."""
|
|
404
409
|
return (isinstance(was, str) and isinstance(now, str)
|
|
405
410
|
and any(ch.isspace() for ch in was + now))
|
|
406
411
|
|
|
407
412
|
|
|
408
413
|
#: A word of a unit and whether it is among the words that moved.
|
|
409
|
-
|
|
414
|
+
Word = tuple[str, bool]
|
|
410
415
|
|
|
416
|
+
#: The marks a unit of a prose diff carries, as a git diff marks its lines.
|
|
417
|
+
KEPT, REMOVED, ADDED = " ", "-", "+"
|
|
411
418
|
|
|
412
|
-
|
|
419
|
+
|
|
420
|
+
@dataclass(frozen=True)
|
|
421
|
+
class DiffUnit:
|
|
422
|
+
"""One unit of a prose diff (SR-0200): a sentence, or a line where the value
|
|
423
|
+
has lines, marked :data:`KEPT`, :data:`REMOVED` or :data:`ADDED`, with each
|
|
424
|
+
word and whether it is among the words that moved. Words are marked only
|
|
425
|
+
between a removed sentence and the added one it plainly became; a unit
|
|
426
|
+
deleted, added or rewritten outright carries no marks."""
|
|
427
|
+
mark: str
|
|
428
|
+
words: tuple[Word, ...]
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def text(self) -> str:
|
|
432
|
+
return " ".join(w for w, _moved in self.words)
|
|
433
|
+
|
|
434
|
+
@property
|
|
435
|
+
def marked(self) -> bool:
|
|
436
|
+
"""Whether any word of the unit is among the words that moved."""
|
|
437
|
+
return any(moved for _w, moved in self.words)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _plain(unit: str) -> list[Word]:
|
|
413
441
|
return [(w, False) for w in unit.split()]
|
|
414
442
|
|
|
415
443
|
|
|
416
|
-
def _word_marks(removed: str, added: str) -> tuple[list[
|
|
444
|
+
def _word_marks(removed: str, added: str) -> tuple[list[Word], list[Word]]:
|
|
417
445
|
"""Both sides of a replaced sentence with the words that differ marked."""
|
|
418
446
|
a, b = removed.split(), added.split()
|
|
419
447
|
keep_a, keep_b = [False] * len(a), [False] * len(b)
|
|
@@ -433,7 +461,7 @@ _SAME_SENTENCE = 0.5
|
|
|
433
461
|
|
|
434
462
|
|
|
435
463
|
def _pair_words(removed: list[str],
|
|
436
|
-
added: list[str]) -> tuple[list[list[
|
|
464
|
+
added: list[str]) -> tuple[list[list[Word]], list[list[Word]]]:
|
|
437
465
|
"""The units of one hunk with the words that moved marked, wherever a removed
|
|
438
466
|
sentence has a successor it plainly became. Each removed sentence takes the
|
|
439
467
|
most similar added one still free; a sentence with no counterpart — deleted
|
|
@@ -456,12 +484,36 @@ def _pair_words(removed: list[str],
|
|
|
456
484
|
return marks_r, marks_a
|
|
457
485
|
|
|
458
486
|
|
|
459
|
-
def
|
|
460
|
-
|
|
461
|
-
|
|
487
|
+
def diff_prose(was: str, now: str) -> tuple[DiffUnit, ...]:
|
|
488
|
+
"""The difference between two prose values as data (SR-0200). Kept units stay
|
|
489
|
+
in place, so a removed sentence sits above its replacement inside the
|
|
490
|
+
paragraph it belongs to, and removed units precede added ones within a hunk,
|
|
491
|
+
as they do in a git diff (SR-0198).
|
|
492
|
+
|
|
493
|
+
This is the one place the comparison is made. :func:`render_change` reads it
|
|
494
|
+
to paint a terminal, and a consumer with a screen of its own reads it too,
|
|
495
|
+
so the two can never disagree about what moved."""
|
|
496
|
+
a, b = _units(was), _units(now)
|
|
497
|
+
units: list[DiffUnit] = []
|
|
498
|
+
matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
|
|
499
|
+
for op, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
500
|
+
if op == "equal":
|
|
501
|
+
units.extend(DiffUnit(KEPT, tuple(_plain(u))) for u in a[i1:i2])
|
|
502
|
+
continue
|
|
503
|
+
marks_r, marks_a = _pair_words(a[i1:i2], b[j1:j2])
|
|
504
|
+
units.extend(DiffUnit(REMOVED, tuple(w)) for w in marks_r)
|
|
505
|
+
units.extend(DiffUnit(ADDED, tuple(w)) for w in marks_a)
|
|
506
|
+
return tuple(units)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def wrap_words(words: list[Word] | tuple[Word, ...], *, first: str, rest: str,
|
|
510
|
+
width: int) -> list[list[Word]]:
|
|
511
|
+
"""Greedy word wrap of a unit's words that never breaks a word and keeps each
|
|
512
|
+
word's mark. ``first`` and ``rest`` are the prefixes the caller will draw
|
|
513
|
+
before the first and the later lines; the wrap is measured on the words alone
|
|
462
514
|
so that painting a line afterwards cannot lengthen it past ``width``."""
|
|
463
|
-
lines: list[list[
|
|
464
|
-
line: list[
|
|
515
|
+
lines: list[list[Word]] = []
|
|
516
|
+
line: list[Word] = []
|
|
465
517
|
used = len(first)
|
|
466
518
|
for word, moved in words:
|
|
467
519
|
need = len(word) + (1 if line else 0)
|
|
@@ -478,14 +530,13 @@ def _wrap(words: list[_Word], *, first: str, rest: str,
|
|
|
478
530
|
def _render_prose(field: str, was: str, now: str, *, pad: str,
|
|
479
531
|
columns: int, colour: bool) -> list[str]:
|
|
480
532
|
"""One field's change as its kept, removed and added units, each wrapped to
|
|
481
|
-
the terminal (SR-0198).
|
|
482
|
-
|
|
483
|
-
Removed units precede added ones within a hunk, as they do in a git diff."""
|
|
533
|
+
the terminal (SR-0198). Built from :func:`diff_prose` and nothing else
|
|
534
|
+
(SR-0200): what is painted here is exactly what the data says."""
|
|
484
535
|
lines = [f"{pad}{field}:"]
|
|
485
536
|
indent = pad * 2
|
|
486
537
|
width = max(columns, 40)
|
|
487
538
|
|
|
488
|
-
def painted(wrapped: list[
|
|
539
|
+
def painted(wrapped: list[Word]) -> str:
|
|
489
540
|
# Consecutive moved words share one run, spaces included, so a reworded
|
|
490
541
|
# phrase reads as a phrase and not as a row of blinking words.
|
|
491
542
|
out: list[str] = []
|
|
@@ -506,27 +557,14 @@ def _render_prose(field: str, was: str, now: str, *, pad: str,
|
|
|
506
557
|
out.append(_UNEMPHASIS)
|
|
507
558
|
return "".join(out)
|
|
508
559
|
|
|
509
|
-
|
|
510
|
-
first, rest = f"{indent}{mark} ", f"{indent} "
|
|
511
|
-
paint = _PAINT[mark] if colour else ""
|
|
512
|
-
for n, wrapped in enumerate(
|
|
513
|
-
|
|
560
|
+
for unit in diff_prose(was, now):
|
|
561
|
+
first, rest = f"{indent}{unit.mark} ", f"{indent} "
|
|
562
|
+
paint = _PAINT[unit.mark] if colour else ""
|
|
563
|
+
for n, wrapped in enumerate(wrap_words(unit.words, first=first, rest=rest,
|
|
564
|
+
width=width)):
|
|
514
565
|
head = first if n == 0 else rest
|
|
515
566
|
line = f"{head}{painted(wrapped)}".rstrip()
|
|
516
567
|
lines.append(f"{paint}{line}{_RESET}" if paint else line)
|
|
517
|
-
|
|
518
|
-
a, b = _units(was), _units(now)
|
|
519
|
-
matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
|
|
520
|
-
for op, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
521
|
-
if op == "equal":
|
|
522
|
-
for unit in a[i1:i2]:
|
|
523
|
-
emit(" ", _plain(unit))
|
|
524
|
-
continue
|
|
525
|
-
marks_r, marks_a = _pair_words(a[i1:i2], b[j1:j2])
|
|
526
|
-
for words in marks_r:
|
|
527
|
-
emit("-", words)
|
|
528
|
-
for words in marks_a:
|
|
529
|
-
emit("+", words)
|
|
530
568
|
return lines
|
|
531
569
|
|
|
532
570
|
|
|
@@ -555,7 +593,7 @@ def render_change(change: RatificationChange, *, ratifier: str | None = None,
|
|
|
555
593
|
f"(stamp {change.stamp}, ratified content at {change.revision[:9]}"
|
|
556
594
|
f"{', cached' if change.cached else ''}):"]
|
|
557
595
|
for c in change.changes:
|
|
558
|
-
if
|
|
596
|
+
if is_prose(c.was, c.now):
|
|
559
597
|
lines.extend(_render_prose(c.field, c.was, c.now, pad=pad,
|
|
560
598
|
columns=columns, colour=colour))
|
|
561
599
|
else:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: throughline
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.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
|
+
166
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
|
-
|
|
42
|
+
29
|
|
43
43
|
<!-- tl:end --> user requirements, and
|
|
44
44
|
<!-- tl:count type == 'nfr' -->
|
|
45
45
|
21
|
|
@@ -4376,6 +4376,36 @@ def _linked_pair(tmp_path):
|
|
|
4376
4376
|
return root
|
|
4377
4377
|
|
|
4378
4378
|
|
|
4379
|
+
def test_link_same_edge_twice_is_an_error(tmp_path, capsys):
|
|
4380
|
+
"""Adding an edge that already exists with the same type is refused rather
|
|
4381
|
+
than duplicated (SR-0143 names the single matching edge; SR-0034)."""
|
|
4382
|
+
root = _linked_pair(tmp_path)
|
|
4383
|
+
assert _cli(["-C", str(root), "link", "FR-0002", "FR-0001",
|
|
4384
|
+
"--type", "relates"]) != 0
|
|
4385
|
+
assert "already exists" in capsys.readouterr().err
|
|
4386
|
+
links = [ln for ln in load_project(str(root)).get("FR-0002").links
|
|
4387
|
+
if ln.target == "FR-0001"]
|
|
4388
|
+
assert len(links) == 1
|
|
4389
|
+
|
|
4390
|
+
|
|
4391
|
+
def test_link_stamp_refreshes_existing_edge_in_place(tmp_path):
|
|
4392
|
+
"""`tl link --stamp` on an existing edge is the re-confirm command SR-0034
|
|
4393
|
+
promises: the stamp is refreshed on that edge and no second edge appears."""
|
|
4394
|
+
root = _linked_pair(tmp_path)
|
|
4395
|
+
assert _cli(["-C", str(root), "link", "FR-0002", "FR-0001",
|
|
4396
|
+
"--type", "relates", "--stamp"]) == 0
|
|
4397
|
+
links = [ln for ln in load_project(str(root)).get("FR-0002").links
|
|
4398
|
+
if ln.target == "FR-0001"]
|
|
4399
|
+
assert len(links) == 1 and links[0].stamp
|
|
4400
|
+
first = links[0].stamp
|
|
4401
|
+
assert _cli(["-C", str(root), "amend", "FR-0001", "--text", "changed"]) == 0
|
|
4402
|
+
assert _cli(["-C", str(root), "link", "FR-0002", "FR-0001",
|
|
4403
|
+
"--type", "relates", "--stamp"]) == 0
|
|
4404
|
+
links = [ln for ln in load_project(str(root)).get("FR-0002").links
|
|
4405
|
+
if ln.target == "FR-0001"]
|
|
4406
|
+
assert len(links) == 1 and links[0].stamp and links[0].stamp != first
|
|
4407
|
+
|
|
4408
|
+
|
|
4379
4409
|
def test_link_retype_changes_type_in_place(tmp_path):
|
|
4380
4410
|
"""`tl link --retype` changes an existing edge's type rather than adding a
|
|
4381
4411
|
parallel one, so a semantic-link review needs no YAML hand-editing (SR-0143)."""
|
|
@@ -16,7 +16,10 @@ import pytest
|
|
|
16
16
|
|
|
17
17
|
from throughline.cli import main as cli_main
|
|
18
18
|
from throughline.ratification import (
|
|
19
|
+
ADDED,
|
|
19
20
|
CHANGED,
|
|
21
|
+
KEPT,
|
|
22
|
+
REMOVED,
|
|
20
23
|
REVISION_ATTR,
|
|
21
24
|
UNCHANGED,
|
|
22
25
|
UNRATIFIED,
|
|
@@ -24,6 +27,9 @@ from throughline.ratification import (
|
|
|
24
27
|
change_since_ratification,
|
|
25
28
|
render_change,
|
|
26
29
|
resolve_revision,
|
|
30
|
+
diff_prose,
|
|
31
|
+
is_prose,
|
|
32
|
+
wrap_words,
|
|
27
33
|
)
|
|
28
34
|
from throughline.storage import load_project
|
|
29
35
|
|
|
@@ -332,6 +338,63 @@ def test_a_sentence_replaced_by_two_pairs_with_the_one_it_became(graph):
|
|
|
332
338
|
assert wholly_new.startswith("\033[32m") and "\033[7m" not in wholly_new
|
|
333
339
|
|
|
334
340
|
|
|
341
|
+
# --------------------------------------------- the diff offered as data (SR-0200)
|
|
342
|
+
|
|
343
|
+
def test_the_diff_is_offered_as_units_with_the_moved_words_marked():
|
|
344
|
+
"""A consumer with a screen of its own reads the kept, removed and added units
|
|
345
|
+
in order, each word carrying whether it moved, and derives nothing (SR-0200)."""
|
|
346
|
+
units = diff_prose(PARAGRAPH, REWORDED)
|
|
347
|
+
assert [u.mark for u in units] == [REMOVED, ADDED, KEPT, KEPT]
|
|
348
|
+
removed, added, kept, _last = units
|
|
349
|
+
assert removed.text == "The Tool shall evict a cached widget after 3600 seconds."
|
|
350
|
+
assert added.text == "The Tool shall evict a cached widget when it is an hour old."
|
|
351
|
+
assert [w for w, moved in removed.words if moved] == ["after", "3600", "seconds."]
|
|
352
|
+
assert [w for w, moved in added.words if moved] == ["when", "it", "is", "an", "hour", "old."]
|
|
353
|
+
assert kept.text.startswith("It shall record") and not kept.marked
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_the_terminal_rendering_is_built_from_the_same_data(graph):
|
|
357
|
+
"""What tl ratify paints and what the data says are one account: every unit
|
|
358
|
+
the rendering shows is a unit the data names, in the same order with the same
|
|
359
|
+
words, and the words it highlights are the words the data marks (SR-0200)."""
|
|
360
|
+
_ratified_paragraph(graph)
|
|
361
|
+
project = load_project(graph)
|
|
362
|
+
change = change_since_ratification(project, project.get("REQ-0002"))
|
|
363
|
+
(field,) = change.changes
|
|
364
|
+
units = diff_prose(field.was, field.now)
|
|
365
|
+
|
|
366
|
+
plain = render_change(change, columns=400)[2:] # one line per unit
|
|
367
|
+
assert [ln.strip()[0] if ln.strip()[0] in "-+" else " " for ln in plain] \
|
|
368
|
+
== [u.mark for u in units]
|
|
369
|
+
assert [ln.strip().lstrip("-+ ") for ln in plain] == [u.text for u in units]
|
|
370
|
+
|
|
371
|
+
painted = render_change(change, columns=400, colour=True)[2:]
|
|
372
|
+
for line, unit in zip(painted, units):
|
|
373
|
+
marked_words = " ".join(w for w, moved in unit.words if moved)
|
|
374
|
+
assert _marked(line) == ([marked_words] if marked_words else [])
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_only_prose_is_diffed():
|
|
378
|
+
"""A token — a priority, a flag — is before and after on one line, so the
|
|
379
|
+
consumer asks the same question the renderer does before drawing a diff."""
|
|
380
|
+
assert not is_prose("should", "must")
|
|
381
|
+
assert not is_prose(None, "must")
|
|
382
|
+
assert is_prose("The Tool shall.", "The Tool must.")
|
|
383
|
+
assert is_prose("", "Two words.")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_a_unit_wraps_without_losing_a_word_or_its_mark():
|
|
387
|
+
"""The wrap a consumer draws with keeps every word once, keeps each word's
|
|
388
|
+
mark, and never runs past the width once the prefixes are counted (SR-0200)."""
|
|
389
|
+
(removed, *_rest) = diff_prose(PARAGRAPH, REWORDED)
|
|
390
|
+
lines = wrap_words(removed.words, first=" - ", rest=" ", width=30)
|
|
391
|
+
assert len(lines) > 1
|
|
392
|
+
assert [w for ln in lines for w in ln] == list(removed.words)
|
|
393
|
+
for n, ln in enumerate(lines):
|
|
394
|
+
prefix = " - " if n == 0 else " "
|
|
395
|
+
assert len(prefix) + len(" ".join(w for w, _m in ln)) <= 30
|
|
396
|
+
|
|
397
|
+
|
|
335
398
|
# ------------------------------------------------------ the revision cache (SR-0166)
|
|
336
399
|
|
|
337
400
|
def test_migrate_caches_the_resolved_revision(graph):
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Ratify takes several items in one run (UR-0032, SR-0199): every item is checked
|
|
2
|
+
before any is signed, the ratifier is asked once, and each item is signed on its own.
|
|
3
|
+
These run without a terminal, so the per-item rendering and stop are not exercised
|
|
4
|
+
here; the single-item tests that cover them are unchanged in meaning."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from throughline.cli import main as cli_main
|
|
12
|
+
from throughline.storage import load_project
|
|
13
|
+
|
|
14
|
+
CONFIG_EXTRA = '''
|
|
15
|
+
[types.intent]
|
|
16
|
+
attrs.origin = { type = "enum", values = ["human", "ai", "hybrid"] }
|
|
17
|
+
'''
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cli(argv) -> int:
|
|
21
|
+
return cli_main([str(a) for a in argv])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def graph(tmp_path) -> Path:
|
|
26
|
+
"""An intent and three proposed requirements grounded on it."""
|
|
27
|
+
root = tmp_path / "proj"
|
|
28
|
+
assert _cli(["-C", root, "init", "--no-demo"]) == 0
|
|
29
|
+
cfg = root / "throughline.toml"
|
|
30
|
+
cfg.write_text(cfg.read_text(encoding="utf-8") + CONFIG_EXTRA, encoding="utf-8")
|
|
31
|
+
assert _cli(["-C", root, "new", "INT", "--type", "intent", "--title", "Why",
|
|
32
|
+
"--text", "V.", "--origin", "human", "--no-interactive"]) == 0
|
|
33
|
+
for n in ("R1", "R2", "R3"):
|
|
34
|
+
assert _cli(["-C", root, "new", "REQ", "--title", n, "--text",
|
|
35
|
+
f"The Tool shall do {n}.", "--ground", "INT-0001",
|
|
36
|
+
"--ground-type", "implements", "--origin", "ai",
|
|
37
|
+
"--no-interactive"]) == 0
|
|
38
|
+
return root
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ratifiers(root: Path) -> dict[str, str | None]:
|
|
42
|
+
p = load_project(root)
|
|
43
|
+
return {u: p.get(u).attrs.get("ratified_by") for u in ("REQ-0001", "REQ-0002", "REQ-0003")}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_several_items_are_ratified_in_one_run_in_the_order_given(graph, capsys):
|
|
47
|
+
assert _cli(["-C", graph, "ratify", "REQ-0002", "REQ-0001", "--by", "Ada Lovelace"]) == 0
|
|
48
|
+
out = capsys.readouterr().out.splitlines()
|
|
49
|
+
assert out == ["REQ-0002 ratified by Ada Lovelace", "REQ-0001 ratified by Ada Lovelace"]
|
|
50
|
+
assert _ratifiers(graph) == {"REQ-0001": "Ada Lovelace", "REQ-0002": "Ada Lovelace",
|
|
51
|
+
"REQ-0003": None}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_one_item_still_works_as_before(graph, capsys):
|
|
55
|
+
assert _cli(["-C", graph, "ratify", "REQ-0003", "--by", "Ada Lovelace"]) == 0
|
|
56
|
+
assert capsys.readouterr().out.strip() == "REQ-0003 ratified by Ada Lovelace"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_an_unknown_item_refuses_the_whole_run_before_anything_is_written(graph, capsys):
|
|
60
|
+
rc = _cli(["-C", graph, "ratify", "REQ-0001", "REQ-0009", "REQ-0002", "--by", "Ada"])
|
|
61
|
+
assert rc != 0
|
|
62
|
+
assert "REQ-0009 does not exist" in capsys.readouterr().err
|
|
63
|
+
assert _ratifiers(graph) == {"REQ-0001": None, "REQ-0002": None, "REQ-0003": None}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_an_item_that_cannot_be_ratified_refuses_the_whole_run(graph, capsys):
|
|
67
|
+
"""The obstacle is found for every item before any is signed (SR-0195 for the
|
|
68
|
+
run, not just the first item): here REQ-0002 is already signed by the same
|
|
69
|
+
content, which a plain re-ratification refuses."""
|
|
70
|
+
assert _cli(["-C", graph, "ratify", "REQ-0002", "--by", "Ada Lovelace"]) == 0
|
|
71
|
+
capsys.readouterr()
|
|
72
|
+
rc = _cli(["-C", graph, "ratify", "REQ-0001", "REQ-0002", "REQ-0003", "--by", "Ada Lovelace"])
|
|
73
|
+
assert rc != 0
|
|
74
|
+
err = capsys.readouterr().err
|
|
75
|
+
assert "REQ-0002" in err and "nothing in this run was ratified" in err
|
|
76
|
+
assert _ratifiers(graph) == {"REQ-0001": None, "REQ-0002": "Ada Lovelace", "REQ-0003": None}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_a_stale_item_in_the_batch_refuses_the_run_without_accept_change(graph, capsys):
|
|
80
|
+
assert _cli(["-C", graph, "ratify", "REQ-0002", "--by", "Ada Lovelace"]) == 0
|
|
81
|
+
assert _cli(["-C", graph, "amend", "REQ-0002", "--text", "The Tool shall do R2 twice."]) == 0
|
|
82
|
+
capsys.readouterr()
|
|
83
|
+
rc = _cli(["-C", graph, "ratify", "REQ-0001", "REQ-0002", "--by", "Ada Lovelace"])
|
|
84
|
+
assert rc != 0
|
|
85
|
+
assert "--accept-change" in capsys.readouterr().err
|
|
86
|
+
assert _ratifiers(graph)["REQ-0001"] is None
|
|
87
|
+
assert _cli(["-C", graph, "ratify", "REQ-0001", "REQ-0002", "--by", "Ada Lovelace",
|
|
88
|
+
"--accept-change"]) == 0
|
|
89
|
+
assert _ratifiers(graph)["REQ-0001"] == "Ada Lovelace"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_no_item_off_a_terminal_is_a_usage_error(graph, capsys):
|
|
93
|
+
assert _cli(["-C", graph, "ratify", "--by", "Ada"]) == 2
|
|
94
|
+
assert "no item given to ratify" in capsys.readouterr().err
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|