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.
Files changed (48) hide show
  1. {throughline-3.6.2/src/throughline.egg-info → throughline-3.7.0}/PKG-INFO +2 -2
  2. {throughline-3.6.2 → throughline-3.7.0}/README.md +1 -1
  3. {throughline-3.6.2 → throughline-3.7.0}/pyproject.toml +1 -1
  4. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/cli.py +47 -66
  5. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/graph.py +15 -0
  6. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/grounding.py +39 -0
  7. throughline-3.7.0/src/throughline/links.py +151 -0
  8. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/storage.py +163 -73
  9. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/validate.py +8 -10
  10. {throughline-3.6.2 → throughline-3.7.0/src/throughline.egg-info}/PKG-INFO +2 -2
  11. {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/SOURCES.txt +3 -0
  12. throughline-3.7.0/tests/test_baseline.py +163 -0
  13. throughline-3.7.0/tests/test_link_guard.py +124 -0
  14. {throughline-3.6.2 → throughline-3.7.0}/LICENSE +0 -0
  15. {throughline-3.6.2 → throughline-3.7.0}/NOTICE +0 -0
  16. {throughline-3.6.2 → throughline-3.7.0}/setup.cfg +0 -0
  17. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/__init__.py +0 -0
  18. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/dump.py +0 -0
  19. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/filters.py +0 -0
  20. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/fingerprint.py +0 -0
  21. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/identity.py +0 -0
  22. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/inject.py +0 -0
  23. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/model.py +0 -0
  24. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/ratification.py +0 -0
  25. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/schema.py +0 -0
  26. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/schema_ops.py +0 -0
  27. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/tomledit.py +0 -0
  28. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/uid.py +0 -0
  29. {throughline-3.6.2 → throughline-3.7.0}/src/throughline/version.py +0 -0
  30. {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/dependency_links.txt +0 -0
  31. {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/entry_points.txt +0 -0
  32. {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/requires.txt +0 -0
  33. {throughline-3.6.2 → throughline-3.7.0}/src/throughline.egg-info/top_level.txt +0 -0
  34. {throughline-3.6.2 → throughline-3.7.0}/tests/test_amend.py +0 -0
  35. {throughline-3.6.2 → throughline-3.7.0}/tests/test_doctor.py +0 -0
  36. {throughline-3.6.2 → throughline-3.7.0}/tests/test_engine.py +0 -0
  37. {throughline-3.6.2 → throughline-3.7.0}/tests/test_filters.py +0 -0
  38. {throughline-3.6.2 → throughline-3.7.0}/tests/test_normative_by_type.py +0 -0
  39. {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratification_change.py +0 -0
  40. {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratification_withdrawal.py +0 -0
  41. {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratifier_correction.py +0 -0
  42. {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratify_several.py +0 -0
  43. {throughline-3.6.2 → throughline-3.7.0}/tests/test_ratify_status.py +0 -0
  44. {throughline-3.6.2 → throughline-3.7.0}/tests/test_schema_ops.py +0 -0
  45. {throughline-3.6.2 → throughline-3.7.0}/tests/test_spec_lists.py +0 -0
  46. {throughline-3.6.2 → throughline-3.7.0}/tests/test_tombstone_record.py +0 -0
  47. {throughline-3.6.2 → throughline-3.7.0}/tests/test_unset_attrs.py +0 -0
  48. {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.6.2
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
- 174
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
- 174
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.6.2"
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
- baseline_statuses,
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
- stamp = fingerprint(dst, project.schema) if args.stamp else None
699
- if getattr(args, "retype", False):
700
- # Retype changes an existing edge in place rather than adding a parallel
701
- # one (SR-0143), so the semantic-link review the tool is built for — e.g.
702
- # narrowing a mitigates to a relates — needs no hand-editing.
703
- existing = [ln for ln in src.links if ln.target == dst_uid]
704
- if not existing:
705
- return _err(f"no existing link {src_uid} -> {dst_uid} to retype "
706
- f"(drop --retype to add a new one)")
707
- present_types = {ln.type for ln in existing}
708
- if len(present_types) > 1:
709
- return _err(f"multiple link types {src_uid} -> {dst_uid} "
710
- f"({', '.join(sorted(present_types))}); remove the "
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
- return OK
736
- src.links.append(Link(target=dst_uid, type=ltype, stamp=stamp))
737
- write_item(src, project.register_of(src.uid))
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
- src = project.get(src_uid)
758
- if src is None:
759
- return _err(f"source {src_uid} does not exist")
760
-
761
- def _matches(ln) -> bool:
762
- return ln.target == dst_uid and (args.type is None or ln.type == args.type)
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 = None
1015
- if project.schema.transitions is not None and args.base:
1016
- baseline = baseline_statuses(project, args.base)
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
- def baseline_statuses(project: Project, ref: str = "HEAD") -> dict[str, str] | None:
848
- """Map ``uid -> status`` as each item stood at git ``ref`` — the snapshot the
849
- working tree is measured against.
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
- # Resolve the ref once: an unborn HEAD or a bad --base makes the whole
872
- # baseline unavailable (inert) rather than "everything looks new".
873
- if subprocess.run(["git", "-C", str(top), "rev-parse", "--verify", "--quiet",
874
- f"{ref}^{{commit}}"], capture_output=True).returncode != 0:
875
- return None
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
- out: dict[str, str] = {}
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
- rel = item._path.resolve().relative_to(top)
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
- blob = subprocess.run(
887
- ["git", "-C", str(top), "show", f"{ref}:{rel.as_posix()}"],
888
- capture_output=True, text=True, check=True).stdout
889
- except (subprocess.CalledProcessError, FileNotFoundError, OSError):
890
- continue # not present at ref (new file) or bad ref for this path
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
- out[item.uid] = status
895
-
896
- # Files that existed at `ref` but are gone from the working tree now. No
897
- # current item carries their status, so a transition can't be measured — but
898
- # a vanished tombstone must still reach the gate (SR-0093). Read their status
899
- # straight from the tree.
900
- present = {i._path.resolve() for i in project.items() if i._path is not None}
901
- # Scope the scan to this project's own subtree: a project may be a
902
- # subdirectory of a larger repo (e.g. examples/ alongside the self-host
903
- # graph), and a tombstone in a *sibling* project must not be read as this
904
- # project's erased record.
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
- proj_rel = root.relative_to(top)
907
- prefix = "" if proj_rel == Path(".") else proj_rel.as_posix() + "/"
908
- except ValueError:
909
- prefix = ""
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
- tree = subprocess.run(
912
- ["git", "-C", str(top), "ls-tree", "-r", "--name-only", ref,
913
- "--", prefix or "."],
914
- capture_output=True, text=True, check=True).stdout.splitlines()
915
- except (subprocess.CalledProcessError, FileNotFoundError, OSError):
916
- tree = []
917
- for rel in tree:
918
- if not rel.endswith(".yml") or Path(rel).name == MANIFEST_NAME:
919
- continue
920
- if (top / rel).resolve() in present:
921
- continue # a current item — already handled above
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
- blob = subprocess.run(
924
- ["git", "-C", str(top), "show", f"{ref}:{rel}"],
925
- capture_output=True, text=True, check=True).stdout
926
- except (subprocess.CalledProcessError, FileNotFoundError, OSError):
927
- continue
928
- data = _load_yaml(blob) or {}
929
- uid, status = data.get("uid"), data.get("status")
930
- if isinstance(uid, str) and isinstance(status, str):
931
- out.setdefault(uid, status)
932
- return out
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 reaches_root
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
- if not schema.is_root(item):
277
- if not idx.out_links(item.uid, ground_kinds):
278
- add("orphan", item.uid, f,
279
- f"{item.type} has no grounding link — nothing justifies it")
280
- elif not reaches_root(idx, schema, item.uid):
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 item.type in schema.delivery_roots and not idx.in_links(item.uid, ground_kinds):
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.6.2
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
- 174
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