throughline 3.6.2__tar.gz → 3.7.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.6.2/src/throughline.egg-info → throughline-3.7.0}/PKG-INFO +2 -2
- {throughline-3.6.2 → throughline-3.7.0}/README.md +1 -1
- {throughline-3.6.2 → throughline-3.7.0}/pyproject.toml +1 -1
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/cli.py +47 -66
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/graph.py +15 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/grounding.py +39 -0
- throughline-3.7.0/src/throughline/links.py +151 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/storage.py +163 -73
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/validate.py +8 -10
- {throughline-3.6.2 → throughline-3.7.0/src/throughline.egg-info}/PKG-INFO +2 -2
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/SOURCES.txt +3 -0
- throughline-3.7.0/tests/test_baseline.py +163 -0
- throughline-3.7.0/tests/test_link_guard.py +124 -0
- {throughline-3.6.2 → throughline-3.7.0}/LICENSE +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/NOTICE +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/setup.cfg +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/__init__.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/dump.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/filters.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/fingerprint.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/identity.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/inject.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/model.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/ratification.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/schema.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/schema_ops.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/tomledit.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/uid.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline/version.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_amend.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_doctor.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_engine.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_filters.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_normative_by_type.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratification_change.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratification_withdrawal.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratifier_correction.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratify_several.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratify_status.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_schema_ops.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_spec_lists.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_tombstone_record.py +0 -0
- {throughline-3.6.2 → throughline-3.7.0}/tests/test_unset_attrs.py +0 -0
- {throughline-3.6.2 → throughline-3.7.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.7.0
|
|
4
4
|
Summary: A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check.
|
|
5
5
|
Author-email: Henry J Grech-Cini <henry.grechcini@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -36,7 +36,7 @@ version control; a `check` command validates the whole graph and gates CI.
|
|
|
36
36
|
|
|
37
37
|
**Dogfooded:** throughline's own spec is itself a throughline project —
|
|
38
38
|
<!-- tl:count type == 'system_requirement' -->
|
|
39
|
-
|
|
39
|
+
178
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
42
|
30
|
|
@@ -8,7 +8,7 @@ version control; a `check` command validates the whole graph and gates CI.
|
|
|
8
8
|
|
|
9
9
|
**Dogfooded:** throughline's own spec is itself a throughline project —
|
|
10
10
|
<!-- tl:count type == 'system_requirement' -->
|
|
11
|
-
|
|
11
|
+
178
|
|
12
12
|
<!-- tl:end --> system requirements,
|
|
13
13
|
<!-- tl:count type == 'user_requirement' -->
|
|
14
14
|
30
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "throughline"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.7.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 pathlib import Path
|
|
|
20
20
|
from .dump import build_dump
|
|
21
21
|
from .fingerprint import fingerprint
|
|
22
22
|
from .graph import Index
|
|
23
|
+
from .links import LinkError, add_link, remove_link, retype_link
|
|
23
24
|
from .grounding import (
|
|
24
25
|
GroundingError,
|
|
25
26
|
attribute_removal_refusal,
|
|
@@ -48,7 +49,8 @@ from .storage import (
|
|
|
48
49
|
CONFIG_NAME,
|
|
49
50
|
MANIFEST_NAME,
|
|
50
51
|
ProjectError,
|
|
51
|
-
|
|
52
|
+
baseline_note,
|
|
53
|
+
read_baseline,
|
|
52
54
|
init_project,
|
|
53
55
|
load_project,
|
|
54
56
|
load_project_at_ref,
|
|
@@ -695,48 +697,23 @@ def cmd_link(args) -> int:
|
|
|
695
697
|
ltype = _resolve_value(args.type, "link type", "--type", options=link_types)
|
|
696
698
|
if ltype is None:
|
|
697
699
|
return USAGE
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
f"unwanted one with `tl unlink` first")
|
|
712
|
-
old_type = existing[0].type
|
|
713
|
-
for ln in existing:
|
|
714
|
-
ln.type = ltype
|
|
715
|
-
if args.stamp:
|
|
716
|
-
ln.stamp = stamp
|
|
717
|
-
write_item(src, project.register_of(src.uid))
|
|
718
|
-
print(f"retyped {src_uid} {dst_uid}: --{old_type}--> is now --{ltype}-->"
|
|
719
|
-
+ (" (stamped)" if args.stamp else ""))
|
|
720
|
-
return OK
|
|
721
|
-
# The same edge twice is never wanted: with --stamp it is the re-confirm
|
|
722
|
-
# command SR-0034 promises, refreshing the stored fingerprint in place;
|
|
723
|
-
# without it there is nothing to add, and a parallel duplicate would hide
|
|
724
|
-
# a stale stamp behind a fresh one.
|
|
725
|
-
same = [ln for ln in src.links if ln.target == dst_uid and ln.type == ltype]
|
|
726
|
-
if same:
|
|
727
|
-
if not args.stamp:
|
|
728
|
-
return _err(f"{src_uid} --{ltype}--> {dst_uid} already exists "
|
|
729
|
-
f"(add --stamp to refresh its stamp, or --retype to "
|
|
730
|
-
f"change its type)")
|
|
731
|
-
for ln in same:
|
|
732
|
-
ln.stamp = stamp
|
|
733
|
-
write_item(src, project.register_of(src.uid))
|
|
700
|
+
try:
|
|
701
|
+
if getattr(args, "retype", False):
|
|
702
|
+
# Retype changes an existing edge in place rather than adding a parallel
|
|
703
|
+
# one (SR-0143), refused where it would leave the graph ungrounded
|
|
704
|
+
# (SR-0211).
|
|
705
|
+
old_type = retype_link(project, src_uid, dst_uid, ltype, stamp=args.stamp)
|
|
706
|
+
print(f"retyped {src_uid} {dst_uid}: --{old_type}--> is now --{ltype}-->"
|
|
707
|
+
+ (" (stamped)" if args.stamp else ""))
|
|
708
|
+
return OK
|
|
709
|
+
outcome = add_link(project, src_uid, dst_uid, ltype, stamp=args.stamp)
|
|
710
|
+
except LinkError as e:
|
|
711
|
+
return _err(str(e))
|
|
712
|
+
if outcome == "restamped":
|
|
734
713
|
print(f"restamped {src_uid} --{ltype}--> {dst_uid}")
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
print(f"linked {src_uid} --{ltype}--> {dst_uid}"
|
|
739
|
-
+ (" (stamped)" if stamp else ""))
|
|
714
|
+
else:
|
|
715
|
+
print(f"linked {src_uid} --{ltype}--> {dst_uid}"
|
|
716
|
+
+ (" (stamped)" if args.stamp else ""))
|
|
740
717
|
return OK
|
|
741
718
|
|
|
742
719
|
|
|
@@ -754,25 +731,13 @@ def cmd_unlink(args) -> int:
|
|
|
754
731
|
dst_uid = _resolve_uid(project, args.dst, "unlink to (destination)", "DST")
|
|
755
732
|
if dst_uid is None:
|
|
756
733
|
return USAGE
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
return
|
|
763
|
-
|
|
764
|
-
matched = [ln for ln in src.links if _matches(ln)]
|
|
765
|
-
if not matched:
|
|
766
|
-
what = f" of type '{args.type}'" if args.type else ""
|
|
767
|
-
return _err(f"no link {src_uid} -> {dst_uid}{what} to remove")
|
|
768
|
-
present_types = {ln.type for ln in matched}
|
|
769
|
-
if args.type is None and len(present_types) > 1:
|
|
770
|
-
return _err(f"multiple link types {src_uid} -> {dst_uid} "
|
|
771
|
-
f"({', '.join(sorted(present_types))}); pass --type to "
|
|
772
|
-
f"choose which to remove")
|
|
773
|
-
src.links = [ln for ln in src.links if not _matches(ln)]
|
|
774
|
-
write_item(src, project.register_of(src.uid))
|
|
775
|
-
for ltype in sorted(present_types):
|
|
734
|
+
# Removing a link is refused where it would leave the graph ungrounded
|
|
735
|
+
# (SR-0211); the operation is shared with composing tools (SR-0212).
|
|
736
|
+
try:
|
|
737
|
+
removed = remove_link(project, src_uid, dst_uid, args.type)
|
|
738
|
+
except LinkError as e:
|
|
739
|
+
return _err(str(e))
|
|
740
|
+
for ltype in removed:
|
|
776
741
|
print(f"unlinked {src_uid} --{ltype}--> {dst_uid}")
|
|
777
742
|
return OK
|
|
778
743
|
|
|
@@ -1011,14 +976,23 @@ def cmd_check(args) -> int:
|
|
|
1011
976
|
project = load_project(args.path)
|
|
1012
977
|
except ProjectError as e:
|
|
1013
978
|
return _err(str(e))
|
|
1014
|
-
baseline
|
|
1015
|
-
|
|
1016
|
-
|
|
979
|
+
# The baseline serves transition legality (SR-0083) and tombstone permanence
|
|
980
|
+
# (SR-0093), so it is read whether or not transitions are declared. A host
|
|
981
|
+
# without git supplies it as a directory (SR-0210); where it cannot be read,
|
|
982
|
+
# the result says which rules did not run (SR-0209).
|
|
983
|
+
try:
|
|
984
|
+
baseline = read_baseline(project, ref=args.base,
|
|
985
|
+
base_dir=getattr(args, "base_dir", None))
|
|
986
|
+
except ProjectError as e:
|
|
987
|
+
return _err(str(e))
|
|
988
|
+
note = baseline_note(project.schema, baseline)
|
|
1017
989
|
published = referenced_uids(project) # None unless [docs] paths configured
|
|
1018
|
-
findings = validate(project, strict=args.strict, baseline=baseline,
|
|
990
|
+
findings = validate(project, strict=args.strict, baseline=baseline.statuses,
|
|
1019
991
|
published=published)
|
|
1020
992
|
if args.format == "json":
|
|
1021
993
|
print(json.dumps([f.to_dict() for f in findings], indent=2))
|
|
994
|
+
if note:
|
|
995
|
+
print(note, file=sys.stderr)
|
|
1022
996
|
return FINDINGS if any(f.severity == ERROR for f in findings) else OK
|
|
1023
997
|
|
|
1024
998
|
for f in sorted(findings, key=lambda x: (x.severity != ERROR, x.uid)):
|
|
@@ -1029,6 +1003,10 @@ def cmd_check(args) -> int:
|
|
|
1029
1003
|
if not args.quiet:
|
|
1030
1004
|
for line in _check_summary(project):
|
|
1031
1005
|
print(line, file=sys.stderr)
|
|
1006
|
+
# Not a finding, so neither the findings nor the exit status move; printed
|
|
1007
|
+
# even when quiet, because it qualifies the result a quiet run still reports.
|
|
1008
|
+
if note:
|
|
1009
|
+
print(f"\n{note}", file=sys.stderr)
|
|
1032
1010
|
tally = f"\n{errs} error(s), {warns} warning(s)"
|
|
1033
1011
|
if not args.quiet and errs == 0:
|
|
1034
1012
|
tally += " — graph is sound" + (" (strict)" if args.strict else "")
|
|
@@ -1500,7 +1478,7 @@ _CTX_COMMAND_USAGE = {
|
|
|
1500
1478
|
"[--unset K]",
|
|
1501
1479
|
"link": "tl link <SRC> <DST> --type <kind>",
|
|
1502
1480
|
"unlink": "tl unlink <SRC> <DST> [--type <kind>]",
|
|
1503
|
-
"check": "tl check [--strict] [--format json]",
|
|
1481
|
+
"check": "tl check [--strict] [--format json] [--base REF | --base-dir DIR]",
|
|
1504
1482
|
"ratify": "tl ratify <UID> --by <who> [--by-id <scheme:value>]",
|
|
1505
1483
|
"trace": "tl trace <UID> [--direction in|out]",
|
|
1506
1484
|
"blast": "tl blast <UID>",
|
|
@@ -2442,6 +2420,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2442
2420
|
s.add_argument("--base", default="HEAD", metavar="REF",
|
|
2443
2421
|
help="git ref for the status-transition baseline "
|
|
2444
2422
|
"(default HEAD; empty to disable)")
|
|
2423
|
+
s.add_argument("--base-dir", default=None, metavar="DIR",
|
|
2424
|
+
help="read the baseline from DIR, a copy of the project as it "
|
|
2425
|
+
"stood, instead of from git (for a host without git)")
|
|
2445
2426
|
s.add_argument("--format", choices=["text", "json"], default="text")
|
|
2446
2427
|
s.add_argument("--quiet", "-q", action="store_true",
|
|
2447
2428
|
help="suppress the graph summary (findings + tally only)")
|
|
@@ -44,6 +44,21 @@ class Index:
|
|
|
44
44
|
idx.backward.setdefault(link.target, []).append((item.uid, link.type))
|
|
45
45
|
return idx
|
|
46
46
|
|
|
47
|
+
def with_edges(self, uid: str, *, remove=(), add=()) -> "Index":
|
|
48
|
+
"""This index with ``uid``'s outgoing edges changed, leaving this one as it
|
|
49
|
+
was. ``remove`` and ``add`` are ``(target, type)`` pairs; every occurrence of
|
|
50
|
+
a removed pair goes. It is how a link operation sees the graph after the
|
|
51
|
+
change it is about to make (SR-0211)."""
|
|
52
|
+
gone = set(remove)
|
|
53
|
+
forward = dict(self.forward)
|
|
54
|
+
backward = dict(self.backward)
|
|
55
|
+
forward[uid] = [e for e in self.forward.get(uid, []) if e not in gone] + list(add)
|
|
56
|
+
for target in {t for t, _k in gone} | {t for t, _k in add}:
|
|
57
|
+
kept = [(s, k) for s, k in self.backward.get(target, [])
|
|
58
|
+
if not (s == uid and (target, k) in gone)]
|
|
59
|
+
backward[target] = kept + [(uid, k) for t, k in add if t == target]
|
|
60
|
+
return Index(items=self.items, forward=forward, backward=backward)
|
|
61
|
+
|
|
47
62
|
def out_links(self, uid: str, types: set[str] | None = None) -> list[tuple[str, str]]:
|
|
48
63
|
return [(t, k) for t, k in self.forward.get(uid, [])
|
|
49
64
|
if types is None or k in types]
|
|
@@ -36,6 +36,45 @@ def reaches_root(idx: Index, schema, uid: str) -> bool:
|
|
|
36
36
|
return idx.reaches(uid, schema.is_root, schema.ground_link_types)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def grounding_gap(schema, idx: Index, item: Item) -> str | None:
|
|
40
|
+
"""Why ``item`` is not grounded, in the words the gate's `orphan` finding uses,
|
|
41
|
+
or ``None`` when it is. A root is grounded by definition. Shared by the gate
|
|
42
|
+
and by the link operations, so a change is refused by exactly the rule that
|
|
43
|
+
would have reported it (SR-0211)."""
|
|
44
|
+
if schema.is_root(item):
|
|
45
|
+
return None
|
|
46
|
+
if not idx.out_links(item.uid, schema.ground_link_types):
|
|
47
|
+
return f"{item.type} has no grounding link — nothing justifies it"
|
|
48
|
+
if not reaches_root(idx, schema, item.uid):
|
|
49
|
+
return "grounding chain never reaches a root"
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_unserved(schema, idx: Index, item: Item) -> bool:
|
|
54
|
+
"""True when ``item`` is a delivery root that nothing grounds into, the
|
|
55
|
+
gate's `unserved-root` finding."""
|
|
56
|
+
return (item.type in schema.delivery_roots
|
|
57
|
+
and not idx.in_links(item.uid, schema.ground_link_types))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ungrounded_by(schema, items, before: Index, after: Index) -> tuple[list[str], list[str]]:
|
|
61
|
+
"""The items a change from ``before`` to ``after`` would leave reaching no root,
|
|
62
|
+
and the delivery roots it would leave served by nothing (SR-0211). Measured as
|
|
63
|
+
a difference, so an item already ungrounded before the change is not blamed on
|
|
64
|
+
it and a graph red elsewhere does not block an unrelated change."""
|
|
65
|
+
orphaned: list[str] = []
|
|
66
|
+
unserved: list[str] = []
|
|
67
|
+
for item in items:
|
|
68
|
+
if item.is_deleted:
|
|
69
|
+
continue
|
|
70
|
+
if grounding_gap(schema, before, item) is None \
|
|
71
|
+
and grounding_gap(schema, after, item) is not None:
|
|
72
|
+
orphaned.append(item.uid)
|
|
73
|
+
if not is_unserved(schema, before, item) and is_unserved(schema, after, item):
|
|
74
|
+
unserved.append(item.uid)
|
|
75
|
+
return sorted(orphaned), sorted(unserved)
|
|
76
|
+
|
|
77
|
+
|
|
39
78
|
# ------------------------------------------------------------------ operations
|
|
40
79
|
|
|
41
80
|
class GroundingError(ValueError):
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Copyright (c) 2026 Henry J Grech-Cini
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Adding, restamping, retyping and removing a link (SR-0143, SR-0211, SR-0212).
|
|
4
|
+
|
|
5
|
+
These are the operations behind `tl link` and `tl unlink`, offered here so a tool
|
|
6
|
+
that grounds over a wider graph than the one it writes, such as tl-compose over a
|
|
7
|
+
union, performs them through the Tool rather than through a copy that falls behind.
|
|
8
|
+
Such a tool passes a :class:`GroundingView`; everything else is the operation's own.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Callable, NamedTuple
|
|
13
|
+
|
|
14
|
+
from .fingerprint import fingerprint
|
|
15
|
+
from .graph import Index
|
|
16
|
+
from .grounding import ungrounded_by
|
|
17
|
+
from .model import Item, Link
|
|
18
|
+
from .storage import write_item
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LinkError(ValueError):
|
|
22
|
+
"""A link operation refused, saying why. Nothing was written."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GroundingView(NamedTuple):
|
|
26
|
+
"""The graph a link operation judges against (SR-0212): a grounding index, and
|
|
27
|
+
how a target written on an item is found in it. Left out, it is the project's
|
|
28
|
+
own graph."""
|
|
29
|
+
index: Index
|
|
30
|
+
find: Callable[[str], Item | None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def own_view(project) -> GroundingView:
|
|
34
|
+
return GroundingView(Index.build(project), project.get)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _source(project, src_uid: str) -> Item:
|
|
38
|
+
src = project.get(src_uid)
|
|
39
|
+
if src is None:
|
|
40
|
+
raise LinkError(f"source {src_uid} does not exist")
|
|
41
|
+
return src
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _target(view: GroundingView, target: str) -> Item:
|
|
45
|
+
dst = view.find(target)
|
|
46
|
+
if dst is None:
|
|
47
|
+
raise LinkError(f"target {target} does not exist")
|
|
48
|
+
return dst
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _refuse_ungrounding(project, view: GroundingView, src_uid: str, change: str, *,
|
|
52
|
+
remove, add=()) -> None:
|
|
53
|
+
"""Refuse ``change`` when it would leave the graph ungrounded (SR-0211)."""
|
|
54
|
+
after = view.index.with_edges(src_uid, remove=remove, add=add)
|
|
55
|
+
orphaned, unserved = ungrounded_by(project.schema, list(project.items()),
|
|
56
|
+
view.index, after)
|
|
57
|
+
if not orphaned and not unserved:
|
|
58
|
+
return
|
|
59
|
+
harm = []
|
|
60
|
+
if orphaned:
|
|
61
|
+
harm.append(f"leave {', '.join(orphaned)} reaching no root")
|
|
62
|
+
if unserved:
|
|
63
|
+
harm.append(f"leave {', '.join(unserved)} served by nothing")
|
|
64
|
+
raise LinkError(f"{change} would {' and '.join(harm)} — nothing was changed; "
|
|
65
|
+
"link the replacement first, then remove this one")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def add_link(project, src_uid: str, target: str, link_type: str, *,
|
|
69
|
+
stamp: bool = False, view: GroundingView | None = None) -> str:
|
|
70
|
+
"""Add ``src --link_type--> target``. Where that edge already exists, refresh
|
|
71
|
+
its stamp in place when ``stamp`` is asked for, and refuse otherwise, since a
|
|
72
|
+
parallel duplicate would hide a stale stamp behind a fresh one (SR-0034).
|
|
73
|
+
Returns ``"linked"`` or ``"restamped"``."""
|
|
74
|
+
view = view or own_view(project)
|
|
75
|
+
src = _source(project, src_uid)
|
|
76
|
+
dst = _target(view, target)
|
|
77
|
+
value = fingerprint(dst, project.schema) if stamp else None
|
|
78
|
+
same = [ln for ln in src.links if ln.target == target and ln.type == link_type]
|
|
79
|
+
if same:
|
|
80
|
+
if not stamp:
|
|
81
|
+
raise LinkError(f"{src_uid} --{link_type}--> {target} already exists "
|
|
82
|
+
"(add --stamp to refresh its stamp, or --retype to "
|
|
83
|
+
"change its type)")
|
|
84
|
+
for ln in same:
|
|
85
|
+
ln.stamp = value
|
|
86
|
+
write_item(src, project.register_of(src.uid))
|
|
87
|
+
return "restamped"
|
|
88
|
+
src.links.append(Link(target=target, type=link_type, stamp=value))
|
|
89
|
+
write_item(src, project.register_of(src.uid))
|
|
90
|
+
return "linked"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def retype_link(project, src_uid: str, target: str, link_type: str, *,
|
|
94
|
+
stamp: bool = False, view: GroundingView | None = None) -> str:
|
|
95
|
+
"""Change the type of the edge joining ``src`` to ``target`` in place
|
|
96
|
+
(SR-0143), refused where the new type would leave the graph ungrounded
|
|
97
|
+
(SR-0211). Returns the type it had."""
|
|
98
|
+
view = view or own_view(project)
|
|
99
|
+
src = _source(project, src_uid)
|
|
100
|
+
dst = _target(view, target)
|
|
101
|
+
existing = [ln for ln in src.links if ln.target == target]
|
|
102
|
+
if not existing:
|
|
103
|
+
raise LinkError(f"no existing link {src_uid} -> {target} to retype "
|
|
104
|
+
"(drop --retype to add a new one)")
|
|
105
|
+
types = {ln.type for ln in existing}
|
|
106
|
+
if len(types) > 1:
|
|
107
|
+
raise LinkError(f"multiple link types {src_uid} -> {target} "
|
|
108
|
+
f"({', '.join(sorted(types))}); remove the unwanted one "
|
|
109
|
+
"with `tl unlink` first")
|
|
110
|
+
old = existing[0].type
|
|
111
|
+
_refuse_ungrounding(project, view, src_uid,
|
|
112
|
+
f"retyping {src_uid} --{old}--> {target} to {link_type}",
|
|
113
|
+
remove={(dst.uid, old)}, add=[(dst.uid, link_type)])
|
|
114
|
+
value = fingerprint(dst, project.schema) if stamp else None
|
|
115
|
+
for ln in existing:
|
|
116
|
+
ln.type = link_type
|
|
117
|
+
if stamp:
|
|
118
|
+
ln.stamp = value
|
|
119
|
+
write_item(src, project.register_of(src.uid))
|
|
120
|
+
return old
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def remove_link(project, src_uid: str, target: str, link_type: str | None = None, *,
|
|
124
|
+
view: GroundingView | None = None) -> list[str]:
|
|
125
|
+
"""Remove the edge joining ``src`` to ``target`` (SR-0143), naming its type when
|
|
126
|
+
several join them, refused where the removal would leave the graph ungrounded
|
|
127
|
+
(SR-0211). Returns the types removed."""
|
|
128
|
+
view = view or own_view(project)
|
|
129
|
+
src = _source(project, src_uid)
|
|
130
|
+
|
|
131
|
+
def matches(ln) -> bool:
|
|
132
|
+
return ln.target == target and (link_type is None or ln.type == link_type)
|
|
133
|
+
|
|
134
|
+
matched = [ln for ln in src.links if matches(ln)]
|
|
135
|
+
if not matched:
|
|
136
|
+
what = f" of type '{link_type}'" if link_type else ""
|
|
137
|
+
raise LinkError(f"no link {src_uid} -> {target}{what} to remove")
|
|
138
|
+
types = sorted({ln.type for ln in matched})
|
|
139
|
+
if link_type is None and len(types) > 1:
|
|
140
|
+
raise LinkError(f"multiple link types {src_uid} -> {target} "
|
|
141
|
+
f"({', '.join(types)}); pass --type to choose which to remove")
|
|
142
|
+
dst = view.find(target)
|
|
143
|
+
# A target the view cannot find grounds nothing, so removing a link to it can
|
|
144
|
+
# leave nothing ungrounded.
|
|
145
|
+
if dst is not None:
|
|
146
|
+
_refuse_ungrounding(project, view, src_uid,
|
|
147
|
+
f"removing {src_uid} --{types[0]}--> {target}",
|
|
148
|
+
remove={(dst.uid, k) for k in types})
|
|
149
|
+
src.links = [ln for ln in src.links if not matches(ln)]
|
|
150
|
+
write_item(src, project.register_of(src.uid))
|
|
151
|
+
return types
|
|
@@ -844,92 +844,182 @@ def read_project(path: str | Path) -> Project:
|
|
|
844
844
|
|
|
845
845
|
# -------------------------------------------------------------- git baseline
|
|
846
846
|
|
|
847
|
-
|
|
848
|
-
"""
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
Two consumers read this: transition legality (SR-0083), which only looks up
|
|
852
|
-
items still present in the working tree, and tombstone permanence (SR-0093),
|
|
853
|
-
which needs items that existed at ``ref`` but are *gone* now — a UID whose
|
|
854
|
-
file was erased by a bad merge or a stray ``git rm``. So the map covers both
|
|
855
|
-
the current items' prior status and any file present at ``ref`` but absent
|
|
856
|
-
today.
|
|
857
|
-
|
|
858
|
-
Returns ``None`` — both checks then silently no-op — when the project is not
|
|
859
|
-
inside a git work tree or ``ref`` cannot be resolved. Items absent at ``ref``
|
|
860
|
-
(newly added) are simply omitted: creation is not a transition.
|
|
861
|
-
"""
|
|
862
|
-
root = Path(project.path).resolve()
|
|
863
|
-
try:
|
|
864
|
-
top = subprocess.run(
|
|
865
|
-
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
|
|
866
|
-
capture_output=True, text=True, check=True).stdout.strip()
|
|
867
|
-
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
|
868
|
-
return None
|
|
869
|
-
top = Path(top)
|
|
847
|
+
class Baseline(NamedTuple):
|
|
848
|
+
"""The status each item had at the baseline check measures the working tree
|
|
849
|
+
against (SR-0083, SR-0093), or why it could not be read (SR-0209).
|
|
870
850
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
851
|
+
``statuses`` maps ``uid -> status`` and is ``None`` exactly when
|
|
852
|
+
``unavailable`` says why. A baseline that was read but holds nothing, as in a
|
|
853
|
+
repository with no commits yet, is an empty map: every item is new."""
|
|
854
|
+
statuses: dict[str, str] | None
|
|
855
|
+
unavailable: str | None = None
|
|
876
856
|
|
|
877
|
-
|
|
857
|
+
|
|
858
|
+
class _GitTree:
|
|
859
|
+
"""The project's files as they stood at a git revision, read through one
|
|
860
|
+
batched `git cat-file` rather than a process per file."""
|
|
861
|
+
|
|
862
|
+
def __init__(self, top: Path, ref: str, prefix: str):
|
|
863
|
+
self.top, self.ref, self.prefix = top, ref, prefix
|
|
864
|
+
|
|
865
|
+
def files(self) -> list[str]:
|
|
866
|
+
out = subprocess.run(
|
|
867
|
+
["git", "-C", str(self.top), "ls-tree", "-r", "-z", "--name-only",
|
|
868
|
+
self.ref, "--", self.prefix or "."],
|
|
869
|
+
capture_output=True, check=True).stdout
|
|
870
|
+
names = [n.decode("utf-8", "surrogateescape") for n in out.split(b"\0") if n]
|
|
871
|
+
return [n[len(self.prefix):] for n in names if n.startswith(self.prefix)]
|
|
872
|
+
|
|
873
|
+
def read(self, rels: list[str]) -> dict[str, str]:
|
|
874
|
+
if not rels:
|
|
875
|
+
return {}
|
|
876
|
+
request = "".join(f"{self.ref}:{self.prefix}{rel}\n" for rel in rels)
|
|
877
|
+
out = subprocess.run(
|
|
878
|
+
["git", "-C", str(self.top), "cat-file", "--batch"],
|
|
879
|
+
input=request.encode("utf-8", "surrogateescape"),
|
|
880
|
+
capture_output=True, check=True).stdout
|
|
881
|
+
found: dict[str, str] = {}
|
|
882
|
+
at = 0
|
|
883
|
+
for rel in rels:
|
|
884
|
+
nl = out.index(b"\n", at)
|
|
885
|
+
header = out[at:nl].decode("utf-8", "surrogateescape")
|
|
886
|
+
at = nl + 1
|
|
887
|
+
if header.endswith((" missing", " ambiguous")):
|
|
888
|
+
continue
|
|
889
|
+
_sha, kind, size = header.rsplit(" ", 2)
|
|
890
|
+
body = out[at:at + int(size)]
|
|
891
|
+
at += int(size) + 1
|
|
892
|
+
if kind == "blob":
|
|
893
|
+
found[rel] = body.decode("utf-8", "replace")
|
|
894
|
+
return found
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class _DirectoryTree:
|
|
898
|
+
"""The project's files as they stood, supplied by a host as a directory laid
|
|
899
|
+
out as the working tree is (SR-0210)."""
|
|
900
|
+
|
|
901
|
+
def __init__(self, root: Path):
|
|
902
|
+
self.root = root
|
|
903
|
+
|
|
904
|
+
def files(self) -> list[str]:
|
|
905
|
+
return sorted(p.relative_to(self.root).as_posix()
|
|
906
|
+
for p in self.root.rglob("*.yml") if p.is_file())
|
|
907
|
+
|
|
908
|
+
def read(self, rels: list[str]) -> dict[str, str]:
|
|
909
|
+
found: dict[str, str] = {}
|
|
910
|
+
for rel in rels:
|
|
911
|
+
path = self.root / rel
|
|
912
|
+
if path.is_file():
|
|
913
|
+
found[rel] = path.read_text(encoding="utf-8", errors="replace")
|
|
914
|
+
return found
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _statuses_in(project: Project, tree) -> dict[str, str]:
|
|
918
|
+
"""Map ``uid -> status`` from the project's files as ``tree`` holds them.
|
|
919
|
+
|
|
920
|
+
The one reading of a baseline, whichever source supplies the files. Two rules
|
|
921
|
+
consume it: transition legality (SR-0083), which looks up items still present,
|
|
922
|
+
and tombstone permanence (SR-0093), which needs items present at the baseline
|
|
923
|
+
but gone now, a UID whose file was erased by a bad merge or a stray `git rm`.
|
|
924
|
+
A current item's status is read from its own path first; a file at a path no
|
|
925
|
+
current item occupies is keyed by the UID it names, so an item moved between
|
|
926
|
+
registers keeps the status it had."""
|
|
927
|
+
root = Path(project.path).resolve()
|
|
928
|
+
present: dict[str, str] = {}
|
|
878
929
|
for item in project.items():
|
|
879
930
|
if item._path is None:
|
|
880
931
|
continue
|
|
881
932
|
try:
|
|
882
|
-
|
|
933
|
+
present[item._path.resolve().relative_to(root).as_posix()] = item.uid
|
|
883
934
|
except ValueError:
|
|
884
935
|
continue
|
|
936
|
+
rels = [r for r in tree.files() if Path(r).name not in _ALL_MANIFEST_NAMES]
|
|
937
|
+
out: dict[str, str] = {}
|
|
938
|
+
absent: list[tuple[str, str]] = []
|
|
939
|
+
for rel, text in tree.read(rels).items():
|
|
885
940
|
try:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
continue
|
|
891
|
-
data = _load_yaml(blob) or {}
|
|
941
|
+
data = _load_yaml(text) or {}
|
|
942
|
+
except yaml.YAMLError:
|
|
943
|
+
continue
|
|
944
|
+
if not isinstance(data, dict):
|
|
945
|
+
continue
|
|
892
946
|
status = data.get("status")
|
|
893
|
-
if isinstance(status, str):
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
947
|
+
if not isinstance(status, str):
|
|
948
|
+
continue
|
|
949
|
+
if rel in present:
|
|
950
|
+
out[present[rel]] = status
|
|
951
|
+
elif isinstance(data.get("uid"), str):
|
|
952
|
+
absent.append((data["uid"], status))
|
|
953
|
+
for uid, status in absent:
|
|
954
|
+
out.setdefault(uid, status)
|
|
955
|
+
return out
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def read_baseline(project: Project, *, ref: str | None = "HEAD",
|
|
959
|
+
base_dir: str | Path | None = None) -> Baseline:
|
|
960
|
+
"""Read the baseline from ``base_dir``, a copy of the project as it stood
|
|
961
|
+
(SR-0210), or else from git revision ``ref``. A falsy ``ref`` means the caller
|
|
962
|
+
disabled the baseline. Where it cannot be read, the result says why rather than
|
|
963
|
+
passing for a baseline in which nothing moved (SR-0209).
|
|
964
|
+
|
|
965
|
+
Raises ``ProjectError`` when ``base_dir`` names no directory: a baseline the
|
|
966
|
+
caller supplied and mistyped fails the command, rather than reading as absent."""
|
|
967
|
+
if base_dir is not None:
|
|
968
|
+
root = Path(base_dir)
|
|
969
|
+
if not root.is_dir():
|
|
970
|
+
raise ProjectError(f"baseline directory {root} does not exist")
|
|
971
|
+
return Baseline(_statuses_in(project, _DirectoryTree(root.resolve())))
|
|
972
|
+
if not ref:
|
|
973
|
+
return Baseline(None, "the baseline was disabled")
|
|
974
|
+
root = Path(project.path).resolve()
|
|
905
975
|
try:
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
976
|
+
top = Path(subprocess.run(
|
|
977
|
+
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
|
|
978
|
+
capture_output=True, text=True, check=True).stdout.strip())
|
|
979
|
+
except subprocess.CalledProcessError:
|
|
980
|
+
return Baseline(None, "the project is not in a git work tree")
|
|
981
|
+
except (FileNotFoundError, OSError):
|
|
982
|
+
return Baseline(None, "git cannot be run")
|
|
910
983
|
try:
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
984
|
+
if subprocess.run(["git", "-C", str(top), "rev-parse", "--verify", "--quiet",
|
|
985
|
+
f"{ref}^{{commit}}"], capture_output=True).returncode != 0:
|
|
986
|
+
# With no commits at all there is nothing earlier to compare, so every
|
|
987
|
+
# item is new; otherwise the revision named really is missing.
|
|
988
|
+
commits = subprocess.run(["git", "-C", str(top), "rev-list", "-n", "1", "--all"],
|
|
989
|
+
capture_output=True, text=True).stdout.strip()
|
|
990
|
+
if not commits:
|
|
991
|
+
return Baseline({})
|
|
992
|
+
return Baseline(None, f"the revision '{ref}' cannot be resolved")
|
|
993
|
+
# Scope to this project's own subtree: a project may sit beside another in
|
|
994
|
+
# one repository, and a sibling's tombstone is not this project's record.
|
|
922
995
|
try:
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
996
|
+
proj_rel = root.relative_to(top)
|
|
997
|
+
prefix = "" if proj_rel == Path(".") else proj_rel.as_posix() + "/"
|
|
998
|
+
except ValueError:
|
|
999
|
+
prefix = ""
|
|
1000
|
+
return Baseline(_statuses_in(project, _GitTree(top, ref, prefix)))
|
|
1001
|
+
except (FileNotFoundError, OSError):
|
|
1002
|
+
return Baseline(None, "git cannot be run")
|
|
1003
|
+
except subprocess.CalledProcessError:
|
|
1004
|
+
return Baseline(None, f"the revision '{ref}' cannot be read")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def baseline_note(schema, baseline: Baseline) -> str | None:
|
|
1008
|
+
"""The line check prints beside its result when the baseline rules did not run
|
|
1009
|
+
(SR-0209): which rules, and why. ``None`` when they ran. Transition legality is
|
|
1010
|
+
named only where the project declares transitions, since without a table it
|
|
1011
|
+
has nothing to check."""
|
|
1012
|
+
if baseline.unavailable is None:
|
|
1013
|
+
return None
|
|
1014
|
+
rules = (["transition legality"] if schema.transitions is not None else [])
|
|
1015
|
+
rules.append("tombstone permanence")
|
|
1016
|
+
return f"not checked: {' and '.join(rules)} — {baseline.unavailable}"
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def baseline_statuses(project: Project, ref: str = "HEAD") -> dict[str, str] | None:
|
|
1020
|
+
"""``uid -> status`` at git ``ref``, or ``None`` where it cannot be read. Kept
|
|
1021
|
+
for callers of earlier releases; :func:`read_baseline` also says why."""
|
|
1022
|
+
return read_baseline(project, ref=ref).statuses
|
|
933
1023
|
|
|
934
1024
|
|
|
935
1025
|
def load_project_at_ref(path: str | Path, ref: str) -> tuple[Project, str]:
|
|
@@ -14,7 +14,7 @@ from dataclasses import dataclass
|
|
|
14
14
|
from .fingerprint import fingerprint
|
|
15
15
|
from .filters import FilterError, safe_eval
|
|
16
16
|
from .graph import Index
|
|
17
|
-
from .grounding import
|
|
17
|
+
from .grounding import grounding_gap, is_unserved
|
|
18
18
|
from .identity import WITHDRAWN_BY_ATTR, WITHDRAWN_RATIFIER_ATTR, WITHDRAWN_REASON_ATTR
|
|
19
19
|
from .schema import COVERAGE_NEEDS_RE, ERROR, OFF, WARNING
|
|
20
20
|
from .storage import CONFIG_NAME, FORMAT_VERSION, STATUS_ROLES_MAJOR
|
|
@@ -272,17 +272,15 @@ def validate(project, strict: bool = False,
|
|
|
272
272
|
elif target.is_deleted:
|
|
273
273
|
add("deleted-link-target", item.uid, f, f"link target '{link.target}' is deleted")
|
|
274
274
|
|
|
275
|
-
# Grounding coverage — the anti-avalanche core (upward).
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
add("orphan", item.uid, f,
|
|
282
|
-
"grounding chain never reaches a root")
|
|
275
|
+
# Grounding coverage — the anti-avalanche core (upward). The predicate is
|
|
276
|
+
# shared with the link operations, which refuse a change this would report
|
|
277
|
+
# (SR-0211).
|
|
278
|
+
gap = grounding_gap(schema, idx, item)
|
|
279
|
+
if gap is not None:
|
|
280
|
+
add("orphan", item.uid, f, gap)
|
|
283
281
|
|
|
284
282
|
# Downward coverage — every delivery root must be served (mirror).
|
|
285
|
-
if
|
|
283
|
+
if is_unserved(schema, idx, item):
|
|
286
284
|
add("unserved-root", item.uid, f,
|
|
287
285
|
f"{item.type} has nothing deriving from / mitigating it — unserved")
|
|
288
286
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: throughline
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.7.0
|
|
4
4
|
Summary: A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check.
|
|
5
5
|
Author-email: Henry J Grech-Cini <henry.grechcini@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -36,7 +36,7 @@ version control; a `check` command validates the whole graph and gates CI.
|
|
|
36
36
|
|
|
37
37
|
**Dogfooded:** throughline's own spec is itself a throughline project —
|
|
38
38
|
<!-- tl:count type == 'system_requirement' -->
|
|
39
|
-
|
|
39
|
+
178
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
42
|
30
|
|
@@ -11,6 +11,7 @@ src/throughline/graph.py
|
|
|
11
11
|
src/throughline/grounding.py
|
|
12
12
|
src/throughline/identity.py
|
|
13
13
|
src/throughline/inject.py
|
|
14
|
+
src/throughline/links.py
|
|
14
15
|
src/throughline/model.py
|
|
15
16
|
src/throughline/ratification.py
|
|
16
17
|
src/throughline/schema.py
|
|
@@ -27,9 +28,11 @@ src/throughline.egg-info/entry_points.txt
|
|
|
27
28
|
src/throughline.egg-info/requires.txt
|
|
28
29
|
src/throughline.egg-info/top_level.txt
|
|
29
30
|
tests/test_amend.py
|
|
31
|
+
tests/test_baseline.py
|
|
30
32
|
tests/test_doctor.py
|
|
31
33
|
tests/test_engine.py
|
|
32
34
|
tests/test_filters.py
|
|
35
|
+
tests/test_link_guard.py
|
|
33
36
|
tests/test_normative_by_type.py
|
|
34
37
|
tests/test_ratification_change.py
|
|
35
38
|
tests/test_ratification_withdrawal.py
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Copyright (c) 2026 Henry J Grech-Cini
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The baseline check measures the working tree against (SR-0083, SR-0093): read
|
|
4
|
+
whether or not transitions are declared, from git or from a directory a host
|
|
5
|
+
supplies (SR-0210), and never silently absent (SR-0209).
|
|
6
|
+
|
|
7
|
+
Where git was missing, `tl check` printed an unqualified pass over two rules it
|
|
8
|
+
had not run (issue #5), and a project declaring no transitions skipped tombstone
|
|
9
|
+
permanence even inside git.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from throughline.cli import main as cli_main
|
|
22
|
+
from throughline.storage import load_project, read_baseline
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cli(argv) -> int:
|
|
26
|
+
return cli_main([str(a) for a in argv])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _git(root: Path, *args: str) -> str:
|
|
30
|
+
return subprocess.run(["git", "-C", str(root), *args], capture_output=True,
|
|
31
|
+
text=True, check=True).stdout
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _commit(root: Path) -> None:
|
|
35
|
+
_git(root, "add", "-A")
|
|
36
|
+
_git(root, "commit", "-qm", "baseline")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _repo(root: Path, *, transitions: bool = True) -> Path:
|
|
40
|
+
assert _cli(["-C", root, "init", "--no-demo"]) == 0
|
|
41
|
+
if not transitions:
|
|
42
|
+
cfg = root / "throughline.toml"
|
|
43
|
+
text = re.sub(r"\n\[transitions\]\n(?:(?!\[).*\n)*", "\n",
|
|
44
|
+
cfg.read_text(encoding="utf-8"))
|
|
45
|
+
cfg.write_text(text, encoding="utf-8")
|
|
46
|
+
assert load_project(root).schema.transitions is None
|
|
47
|
+
_git(root, "init", "-q")
|
|
48
|
+
_git(root, "config", "user.email", "t@t")
|
|
49
|
+
_git(root, "config", "user.name", "t")
|
|
50
|
+
assert _cli(["-C", root, "new", "INT", "--type", "intent", "--title", "Why",
|
|
51
|
+
"--no-interactive"]) == 0
|
|
52
|
+
for title in ("Moved", "Retired"):
|
|
53
|
+
assert _cli(["-C", root, "new", "REQ", "--title", title, "--text", "t.",
|
|
54
|
+
"--ground", "INT-0001", "--no-interactive"]) == 0
|
|
55
|
+
if transitions:
|
|
56
|
+
assert _cli(["-C", root, "status", "REQ-0001", "approved"]) == 0
|
|
57
|
+
assert _cli(["-C", root, "status", "REQ-0001", "implemented"]) == 0
|
|
58
|
+
assert _cli(["-C", root, "delete", "REQ-0002", "--reason", "gone"]) == 0
|
|
59
|
+
_commit(root)
|
|
60
|
+
return root
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _drift(root: Path) -> None:
|
|
64
|
+
"""An illegal status move and an erased tombstone, not yet committed."""
|
|
65
|
+
moved = root / "requirements" / "REQ-0001.yml"
|
|
66
|
+
moved.write_text(moved.read_text(encoding="utf-8").replace(
|
|
67
|
+
"status: implemented", "status: draft"), encoding="utf-8")
|
|
68
|
+
(root / "requirements" / "REQ-0002.yml").unlink()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _copy_without_git(src: Path, dst: Path) -> Path:
|
|
72
|
+
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(".git"))
|
|
73
|
+
return dst
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def repo(tmp_path) -> Path:
|
|
78
|
+
return _repo(tmp_path / "repo")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_git_baseline_runs_both_rules_and_says_nothing_more(repo, capsys):
|
|
82
|
+
_drift(repo)
|
|
83
|
+
assert _cli(["-C", repo, "check"]) == 1
|
|
84
|
+
out = capsys.readouterr()
|
|
85
|
+
assert "bad-transition" in out.out and "tombstone-deleted" in out.out
|
|
86
|
+
assert "not checked" not in out.err
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_outside_git_the_result_names_the_rules_that_did_not_run(repo, tmp_path, capsys):
|
|
90
|
+
work = _copy_without_git(repo, tmp_path / "work")
|
|
91
|
+
_drift(work)
|
|
92
|
+
rc = _cli(["-C", work, "check"])
|
|
93
|
+
out = capsys.readouterr()
|
|
94
|
+
assert "bad-transition" not in out.out and "tombstone-deleted" not in out.out
|
|
95
|
+
assert ("not checked: transition legality and tombstone permanence — "
|
|
96
|
+
"the project is not in a git work tree") in out.err
|
|
97
|
+
# Not a finding: the exit status is what the findings alone make it.
|
|
98
|
+
assert rc == _cli(["-C", work, "check", "--base", ""])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_json_stays_a_list_of_findings_and_the_note_goes_to_stderr(repo, tmp_path, capsys):
|
|
102
|
+
work = _copy_without_git(repo, tmp_path / "work")
|
|
103
|
+
capsys.readouterr()
|
|
104
|
+
_cli(["-C", work, "check", "--format", "json"])
|
|
105
|
+
out = capsys.readouterr()
|
|
106
|
+
assert isinstance(json.loads(out.out), list)
|
|
107
|
+
assert "not checked:" in out.err
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_a_disabled_or_unresolvable_baseline_is_named(repo, capsys):
|
|
111
|
+
_cli(["-C", repo, "check", "--base", ""])
|
|
112
|
+
assert "the baseline was disabled" in capsys.readouterr().err
|
|
113
|
+
_cli(["-C", repo, "check", "--base", "no-such-ref"])
|
|
114
|
+
assert "the revision 'no-such-ref' cannot be resolved" in capsys.readouterr().err
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_a_repository_with_no_commits_is_a_baseline_where_everything_is_new(tmp_path, capsys):
|
|
118
|
+
root = tmp_path / "fresh"
|
|
119
|
+
assert _cli(["-C", root, "init", "--no-demo"]) == 0
|
|
120
|
+
_git(root, "init", "-q")
|
|
121
|
+
assert read_baseline(load_project(root)).statuses == {}
|
|
122
|
+
capsys.readouterr()
|
|
123
|
+
_cli(["-C", root, "check"])
|
|
124
|
+
assert "not checked" not in capsys.readouterr().err
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_a_baseline_directory_runs_both_rules_outside_git(repo, tmp_path, capsys):
|
|
128
|
+
at_base = _copy_without_git(repo, tmp_path / "at-base")
|
|
129
|
+
work = _copy_without_git(repo, tmp_path / "work")
|
|
130
|
+
_drift(work)
|
|
131
|
+
assert _cli(["-C", work, "check", "--base-dir", at_base]) == 1
|
|
132
|
+
out = capsys.readouterr()
|
|
133
|
+
assert "bad-transition" in out.out and "tombstone-deleted" in out.out
|
|
134
|
+
assert "not checked" not in out.err
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_a_baseline_directory_that_does_not_exist_fails_the_command(repo, tmp_path, capsys):
|
|
138
|
+
assert _cli(["-C", repo, "check", "--base-dir", tmp_path / "missing"]) == 2
|
|
139
|
+
assert "does not exist" in capsys.readouterr().err
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_git_and_a_directory_read_the_same_baseline(repo, tmp_path):
|
|
143
|
+
"""One reading of a baseline, whichever source supplies the files."""
|
|
144
|
+
at_base = _copy_without_git(repo, tmp_path / "at-base")
|
|
145
|
+
_drift(repo)
|
|
146
|
+
project = load_project(repo)
|
|
147
|
+
from_git = read_baseline(project, ref="HEAD")
|
|
148
|
+
from_dir = read_baseline(project, base_dir=at_base)
|
|
149
|
+
assert from_git.statuses == from_dir.statuses
|
|
150
|
+
assert from_git.statuses == {"INT-0001": "draft", "REQ-0001": "implemented",
|
|
151
|
+
"REQ-0002": "deleted"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_tombstone_permanence_runs_without_a_transitions_table(tmp_path, capsys):
|
|
155
|
+
"""SR-0093 sets no condition on transitions; the gate used to."""
|
|
156
|
+
root = _repo(tmp_path / "repo", transitions=False)
|
|
157
|
+
(root / "requirements" / "REQ-0002.yml").unlink()
|
|
158
|
+
assert _cli(["-C", root, "check"]) == 1
|
|
159
|
+
assert "tombstone-deleted" in capsys.readouterr().out
|
|
160
|
+
work = _copy_without_git(root, tmp_path / "work")
|
|
161
|
+
_cli(["-C", work, "check"])
|
|
162
|
+
assert ("not checked: tombstone permanence — the project is not in a git "
|
|
163
|
+
"work tree") in capsys.readouterr().err
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Copyright (c) 2026 Henry J Grech-Cini
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Removing or retyping a link never leaves the graph ungrounded (SR-0211), and the
|
|
4
|
+
link operations accept a wider graph to judge against (SR-0212).
|
|
5
|
+
|
|
6
|
+
Removing an item's last grounding link used to succeed and leave `tl check` red
|
|
7
|
+
(issue #3), and a composing tool kept its own copy of adding a link, which drifted.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from throughline.cli import main as cli_main
|
|
16
|
+
from throughline.graph import Index
|
|
17
|
+
from throughline.links import GroundingView, LinkError, add_link, remove_link
|
|
18
|
+
from throughline.storage import load_project
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cli(argv) -> int:
|
|
22
|
+
return cli_main([str(a) for a in argv])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def graph(tmp_path) -> Path:
|
|
27
|
+
"""INT-0001 is served by REQ-0001 and REQ-0003; INT-0002 by REQ-0003 alone.
|
|
28
|
+
REQ-0002 is grounded only through REQ-0001."""
|
|
29
|
+
root = tmp_path / "proj"
|
|
30
|
+
assert _cli(["-C", root, "init", "--no-demo"]) == 0
|
|
31
|
+
for title in ("One", "Two"):
|
|
32
|
+
assert _cli(["-C", root, "new", "INT", "--type", "intent", "--title", title,
|
|
33
|
+
"--no-interactive"]) == 0
|
|
34
|
+
assert _cli(["-C", root, "new", "REQ", "--title", "R1", "--ground", "INT-0001",
|
|
35
|
+
"--no-interactive"]) == 0
|
|
36
|
+
assert _cli(["-C", root, "new", "REQ", "--title", "R2", "--ground", "REQ-0001",
|
|
37
|
+
"--no-interactive"]) == 0
|
|
38
|
+
assert _cli(["-C", root, "new", "REQ", "--title", "R3", "--ground", "INT-0001",
|
|
39
|
+
"--no-interactive"]) == 0
|
|
40
|
+
assert _cli(["-C", root, "link", "REQ-0003", "INT-0002", "--type", "derives_from"]) == 0
|
|
41
|
+
return root
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _bytes(root: Path, uid: str) -> bytes:
|
|
45
|
+
return next(root.rglob(f"{uid}.yml")).read_bytes()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_removing_the_last_grounding_link_is_refused_naming_all_it_would_orphan(graph, capsys):
|
|
49
|
+
before = _bytes(graph, "REQ-0001")
|
|
50
|
+
assert _cli(["-C", graph, "unlink", "REQ-0001", "INT-0001"]) == 2
|
|
51
|
+
err = capsys.readouterr().err
|
|
52
|
+
assert "leave REQ-0001, REQ-0002 reaching no root" in err
|
|
53
|
+
assert "nothing was changed" in err
|
|
54
|
+
assert _bytes(graph, "REQ-0001") == before
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_leaving_a_delivery_root_unserved_is_refused(graph, capsys):
|
|
58
|
+
assert _cli(["-C", graph, "unlink", "REQ-0003", "INT-0002"]) == 2
|
|
59
|
+
err = capsys.readouterr().err
|
|
60
|
+
assert "leave INT-0002 served by nothing" in err
|
|
61
|
+
assert "reaching no root" not in err
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_linking_the_replacement_first_lets_the_old_link_go(graph):
|
|
65
|
+
assert _cli(["-C", graph, "link", "REQ-0001", "INT-0002", "--type", "derives_from"]) == 0
|
|
66
|
+
assert _cli(["-C", graph, "unlink", "REQ-0001", "INT-0001"]) == 0
|
|
67
|
+
assert not [l for l in load_project(graph).get("REQ-0001").links
|
|
68
|
+
if l.target == "INT-0001"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_retyping_the_last_grounding_link_to_one_that_grounds_nothing_is_refused(graph, capsys):
|
|
72
|
+
assert _cli(["-C", graph, "link", "REQ-0001", "INT-0001", "--type", "relates",
|
|
73
|
+
"--retype"]) == 2
|
|
74
|
+
assert "reaching no root" in capsys.readouterr().err
|
|
75
|
+
assert _cli(["-C", graph, "link", "REQ-0001", "INT-0001", "--type", "implements",
|
|
76
|
+
"--retype"]) == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_a_link_that_grounds_nothing_is_always_removable(graph):
|
|
80
|
+
assert _cli(["-C", graph, "link", "REQ-0002", "INT-0002", "--type", "relates"]) == 0
|
|
81
|
+
assert _cli(["-C", graph, "unlink", "REQ-0002", "INT-0002"]) == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_a_graph_red_elsewhere_does_not_block_an_unrelated_removal(graph):
|
|
85
|
+
assert _cli(["-C", graph, "new", "REQ", "--title", "orphan", "--no-interactive"]) == 0
|
|
86
|
+
assert _cli(["-C", graph, "link", "REQ-0002", "INT-0002", "--type", "relates"]) == 0
|
|
87
|
+
assert _cli(["-C", graph, "unlink", "REQ-0002", "INT-0002"]) == 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_a_wider_view_judges_the_removal(graph):
|
|
91
|
+
"""What a composing tool passes: here the wider graph also grounds REQ-0001 in
|
|
92
|
+
INT-0002, so a removal refused over the project alone is allowed."""
|
|
93
|
+
project = load_project(graph)
|
|
94
|
+
with pytest.raises(LinkError):
|
|
95
|
+
remove_link(project, "REQ-0001", "INT-0001")
|
|
96
|
+
wider = Index.build(project).with_edges("REQ-0001", add=[("INT-0002", "derives_from")])
|
|
97
|
+
assert remove_link(project, "REQ-0001", "INT-0001",
|
|
98
|
+
view=GroundingView(wider, project.get)) == ["derives_from"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_adding_through_a_view_restamps_in_place_rather_than_duplicating(graph, tmp_path):
|
|
102
|
+
"""A target found only in the wider graph, as a borrowed clause is: a second
|
|
103
|
+
--stamp refreshes the edge instead of adding another."""
|
|
104
|
+
other = tmp_path / "source"
|
|
105
|
+
assert _cli(["-C", other, "init", "--no-demo"]) == 0
|
|
106
|
+
assert _cli(["-C", other, "new", "INT", "--type", "intent", "--title", "Borrowed",
|
|
107
|
+
"--no-interactive"]) == 0
|
|
108
|
+
source = load_project(other)
|
|
109
|
+
project = load_project(graph)
|
|
110
|
+
|
|
111
|
+
def find(target: str):
|
|
112
|
+
return source.get(target.split(":", 1)[1]) if target.startswith("src:") \
|
|
113
|
+
else project.get(target)
|
|
114
|
+
|
|
115
|
+
view = GroundingView(Index.build(project), find)
|
|
116
|
+
assert add_link(project, "REQ-0003", "src:INT-0001", "relates", stamp=True,
|
|
117
|
+
view=view) == "linked"
|
|
118
|
+
assert add_link(project, "REQ-0003", "src:INT-0001", "relates", stamp=True,
|
|
119
|
+
view=view) == "restamped"
|
|
120
|
+
with pytest.raises(LinkError, match="already exists"):
|
|
121
|
+
add_link(project, "REQ-0003", "src:INT-0001", "relates", view=view)
|
|
122
|
+
edges = [l for l in load_project(graph).get("REQ-0003").links
|
|
123
|
+
if l.target == "src:INT-0001"]
|
|
124
|
+
assert len(edges) == 1 and edges[0].stamp
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|