throughline 2.2.0__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {throughline-2.2.0/src/throughline.egg-info → throughline-2.2.1}/PKG-INFO +2 -2
  2. {throughline-2.2.0 → throughline-2.2.1}/README.md +1 -1
  3. {throughline-2.2.0 → throughline-2.2.1}/pyproject.toml +1 -1
  4. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/cli.py +25 -6
  5. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/filters.py +86 -0
  6. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/schema.py +48 -0
  7. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/schema_ops.py +52 -0
  8. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/storage.py +15 -4
  9. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/tomledit.py +46 -0
  10. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/validate.py +34 -14
  11. {throughline-2.2.0 → throughline-2.2.1/src/throughline.egg-info}/PKG-INFO +2 -2
  12. {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/SOURCES.txt +2 -1
  13. {throughline-2.2.0 → throughline-2.2.1}/tests/test_engine.py +52 -0
  14. {throughline-2.2.0 → throughline-2.2.1}/tests/test_filters.py +62 -2
  15. {throughline-2.2.0 → throughline-2.2.1}/tests/test_schema_ops.py +86 -0
  16. throughline-2.2.1/tests/test_yaml_loader.py +52 -0
  17. {throughline-2.2.0 → throughline-2.2.1}/LICENSE +0 -0
  18. {throughline-2.2.0 → throughline-2.2.1}/NOTICE +0 -0
  19. {throughline-2.2.0 → throughline-2.2.1}/setup.cfg +0 -0
  20. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/__init__.py +0 -0
  21. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/dump.py +0 -0
  22. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/fingerprint.py +0 -0
  23. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/graph.py +0 -0
  24. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/grounding.py +0 -0
  25. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/identity.py +0 -0
  26. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/inject.py +0 -0
  27. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/model.py +0 -0
  28. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/uid.py +0 -0
  29. {throughline-2.2.0 → throughline-2.2.1}/src/throughline/version.py +0 -0
  30. {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/dependency_links.txt +0 -0
  31. {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/entry_points.txt +0 -0
  32. {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/requires.txt +0 -0
  33. {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/top_level.txt +0 -0
  34. {throughline-2.2.0 → throughline-2.2.1}/tests/test_amend.py +0 -0
  35. {throughline-2.2.0 → throughline-2.2.1}/tests/test_doctor.py +0 -0
  36. {throughline-2.2.0 → throughline-2.2.1}/tests/test_ratify_status.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 2.2.0
3
+ Version: 2.2.1
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
- 156
39
+ 159
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
42
  25
@@ -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
- 156
11
+ 159
12
12
  <!-- tl:end --> system requirements,
13
13
  <!-- tl:count type == 'user_requirement' -->
14
14
  25
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "throughline"
7
- version = "2.2.0"
7
+ version = "2.2.1"
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"
@@ -47,7 +47,7 @@ from .storage import (
47
47
  write_manifest,
48
48
  )
49
49
  from .uid import PREFIX_GRAMMAR, UidError, next_uid, parse_uid, valid_prefix
50
- from .validate import ERROR, FilterError, eval_filter, validate
50
+ from .validate import ERROR, OFF, WARNING, FilterError, eval_filter, validate
51
51
  from .version import distribution_version
52
52
 
53
53
  OK, FINDINGS, USAGE = 0, 1, 2
@@ -1286,12 +1286,13 @@ def _ctx_coverage(schema) -> str:
1286
1286
  out.append("_No `[[rules.coverage]]` declared._")
1287
1287
  return "\n".join(out)
1288
1288
  out.append("Each rule requires the matching items to have the stated link "
1289
- "(unmet → a `coverage` finding):\n")
1290
- for rule in schema.coverage:
1289
+ "(unmet → a `coverage` finding). They are numbered as "
1290
+ "`tl schema rule remove` counts them:\n")
1291
+ for pos, rule in enumerate(schema.coverage, start=1):
1291
1292
  filt = rule.get("filter", "*")
1292
1293
  needs = rule.get("needs", "?")
1293
- sev = rule.get("severity", "error")
1294
- out.append(f"- items where `{filt}` **need** `{needs}` "
1294
+ sev = rule.get("severity", WARNING)
1295
+ out.append(f"{pos}. items where `{filt}` **need** `{needs}` "
1295
1296
  f"(severity: {sev})")
1296
1297
  return "\n".join(out)
1297
1298
 
@@ -1363,7 +1364,7 @@ _CTX_COMMAND_EMPHASIS = {
1363
1364
  "delete": "tombstones an item; the file stays, the item stops counting",
1364
1365
  "amend": "change content through the tool, never by opening the YAML",
1365
1366
  "schema": "change the schema itself — nouns: status, transition, type, attr, "
1366
- "linktype, linkrule, grounding; refuses a change that would "
1367
+ "linktype, linkrule, grounding, rule; refuses a change that would "
1367
1368
  "invalidate existing items and says what to fix",
1368
1369
  }
1369
1370
 
@@ -1814,6 +1815,7 @@ def _add_schema_parser(sub) -> None:
1814
1815
  "linktype": "the link vocabulary",
1815
1816
  "linkrule": "endpoint constraints on a link type",
1816
1817
  "grounding": "the grounding configuration",
1818
+ "rule": "coverage rules the gate enforces",
1817
1819
  }
1818
1820
  made: dict[str, object] = {}
1819
1821
 
@@ -1928,6 +1930,23 @@ def _add_schema_parser(sub) -> None:
1928
1930
  v.add_argument("field", metavar="FIELD")
1929
1931
  v.add_argument("value")
1930
1932
 
1933
+ v = _schema_verb("rule", "add",
1934
+ lambda p, a: schema_ops.rule_add(
1935
+ p, filter=a.filter, needs=a.needs, severity=a.severity),
1936
+ "declare a coverage rule")
1937
+ v.add_argument("--filter", required=True,
1938
+ help="which items the rule governs, e.g. \"type == 'system_"
1939
+ "requirement' and attrs.get('verification') == 'test'\"")
1940
+ v.add_argument("--needs", required=True,
1941
+ help="what they must have: incoming:<link type> or "
1942
+ "outgoing:<link type>")
1943
+ v.add_argument("--severity", default=None, choices=[ERROR, WARNING, OFF],
1944
+ help="default: warning")
1945
+ v = _schema_verb("rule", "remove",
1946
+ lambda p, a: schema_ops.rule_remove(p, a.index),
1947
+ "withdraw a coverage rule, by its position in `tl context`")
1948
+ v.add_argument("index", type=int, metavar="N")
1949
+
1931
1950
 
1932
1951
  def build_parser() -> argparse.ArgumentParser:
1933
1952
  p = argparse.ArgumentParser(prog="tl", description=__doc__.splitlines()[0])
@@ -34,10 +34,96 @@ _METHODS = frozenset({
34
34
  })
35
35
 
36
36
 
37
+ # Every name a filter may reference (SR-0045). Declared here, beside the grammar,
38
+ # so the static check below and the namespace `validate._filter_namespace` builds
39
+ # cannot drift apart; a test holds the two to each other.
40
+ FILTER_NAMES = frozenset({
41
+ "type", "status", "register", "uid", "derived", "normative",
42
+ "title", "text", "rationale", "attrs", "links",
43
+ "true", "false", "none",
44
+ })
45
+
46
+
37
47
  class FilterError(ValueError):
38
48
  """A filter expression could not be parsed or evaluated (SR-0045)."""
39
49
 
40
50
 
51
+ def check_filter(expr: str, names: frozenset[str] = FILTER_NAMES) -> None:
52
+ """Validate ``expr`` against the grammar without evaluating it (SR-0191).
53
+
54
+ Evaluation cannot answer 'is this expression sound?', because `and`/`or`
55
+ short-circuit: in ``type == 'x' and verifcation == 'y'`` the misspelling is
56
+ reached only for items that pass the first test, so whether the mistake is
57
+ noticed depends on which items the graph happens to hold. This walks every
58
+ branch instead, so the answer is a property of the expression alone.
59
+
60
+ Raises :class:`FilterError` naming the first fault found.
61
+ """
62
+ if not expr or not expr.strip():
63
+ return
64
+ try:
65
+ tree = ast.parse(expr, mode="eval")
66
+ except SyntaxError as e:
67
+ raise FilterError(f"could not parse filter: {e.msg}") from e
68
+ _check(tree.body, names)
69
+
70
+
71
+ def _check(node, names: frozenset[str]) -> None:
72
+ """Walk one node of the same grammar :func:`_eval` executes. The two stay in
73
+ step by sharing ``_COMPARISONS`` and ``_METHODS``; a test evaluates and checks
74
+ the same expressions so a form accepted by one cannot be rejected by the
75
+ other."""
76
+ if isinstance(node, ast.BoolOp):
77
+ for value in node.values:
78
+ _check(value, names)
79
+ return
80
+ if isinstance(node, ast.UnaryOp):
81
+ if not isinstance(node.op, (ast.Not, ast.USub, ast.UAdd)):
82
+ raise FilterError(f"unsupported operator '{type(node.op).__name__}'")
83
+ _check(node.operand, names)
84
+ return
85
+ if isinstance(node, ast.Compare):
86
+ _check(node.left, names)
87
+ for op, comparator in zip(node.ops, node.comparators):
88
+ if type(op) not in _COMPARISONS:
89
+ raise FilterError(f"unsupported comparison '{type(op).__name__}'")
90
+ _check(comparator, names)
91
+ return
92
+ if isinstance(node, ast.Name):
93
+ if node.id not in names:
94
+ raise FilterError(f"unknown name '{node.id}'")
95
+ return
96
+ if isinstance(node, ast.Constant):
97
+ return
98
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
99
+ for element in node.elts:
100
+ _check(element, names)
101
+ return
102
+ if isinstance(node, ast.Subscript):
103
+ if isinstance(node.slice, ast.Slice):
104
+ raise FilterError("slices are not allowed in filters")
105
+ _check(node.value, names)
106
+ _check(node.slice, names)
107
+ return
108
+ if isinstance(node, ast.Call):
109
+ _check_call(node, names)
110
+ return
111
+ raise FilterError(f"unsupported expression '{type(node).__name__}'")
112
+
113
+
114
+ def _check_call(node, names: frozenset[str]) -> None:
115
+ func = node.func
116
+ if not isinstance(func, ast.Attribute):
117
+ raise FilterError("only method calls on values are allowed")
118
+ if func.attr not in _METHODS:
119
+ raise FilterError(f"method '{func.attr}' is not allowed in filters")
120
+ if node.keywords:
121
+ raise FilterError("keyword arguments are not allowed in filters")
122
+ _check(func.value, names)
123
+ for arg in node.args:
124
+ _check(arg, names)
125
+
126
+
41
127
  def safe_eval(expr: str, namespace: dict) -> object:
42
128
  """Evaluate one filter expression against ``namespace`` without eval/exec.
43
129
 
@@ -19,8 +19,16 @@ from __future__ import annotations
19
19
  import re
20
20
  from dataclasses import dataclass
21
21
 
22
+ from .filters import FilterError, check_filter
23
+
22
24
  ERROR, WARNING, OFF = "error", "warning", "off"
23
25
 
26
+ # What a coverage rule's `needs` clause looks like: a direction and a link type
27
+ # (SR-0042). Declared here because this is where a rule is admitted or refused;
28
+ # validation reads the same pattern, so the clause the loader accepts and the
29
+ # clause the gate acts on can never be two different things.
30
+ COVERAGE_NEEDS_RE = re.compile(r"(incoming|outgoing):(\w+)$")
31
+
24
32
  # Grounding defaults when a project declares no [grounding] table. Kept here so
25
33
  # the grounding layer and the schema agree on one source of truth.
26
34
  _GROUNDING_DEFAULTS = {
@@ -53,6 +61,45 @@ class SchemaError(ValueError):
53
61
  """Malformed or internally inconsistent project configuration (SR-0082)."""
54
62
 
55
63
 
64
+ def _check_coverage(coverage: tuple) -> None:
65
+ """Refuse a coverage rule that could not be enforced (SR-0191).
66
+
67
+ A rule the gate cannot run is the failure this exists to end: until now a
68
+ filter that raised matched nothing, so `check` reported the graph sound while
69
+ the rule asserted nothing at all. Checked here, at load, because the filter
70
+ language short-circuits — whether an unusable expression is reached depends on
71
+ which items the graph holds, so only a check made before any item is read
72
+ gives the same answer every time.
73
+ """
74
+ for pos, rule in enumerate(coverage):
75
+ where = f"[[rules.coverage]] #{pos + 1}"
76
+ if not isinstance(rule, dict):
77
+ raise SchemaError(f"{where} is not a table")
78
+ try:
79
+ check_filter(rule.get("filter") or "")
80
+ except FilterError as e:
81
+ raise SchemaError(
82
+ f"{where} has an unusable filter: {e}\n"
83
+ f" filter = {rule.get('filter')!r}\n"
84
+ " An item attribute is read with attrs.get('name'); `tl query "
85
+ "<EXPR>` tries an expression out.") from e
86
+ needs = rule.get("needs")
87
+ if needs is None:
88
+ raise SchemaError(
89
+ f"{where} declares no 'needs', so it requires nothing of the "
90
+ "items it matches; state it as 'incoming:<link type>' or "
91
+ "'outgoing:<link type>'")
92
+ if not COVERAGE_NEEDS_RE.match(str(needs).strip()):
93
+ raise SchemaError(
94
+ f"{where} has an unusable 'needs' clause {needs!r} — expected "
95
+ "'incoming:<link type>' or 'outgoing:<link type>'")
96
+ severity = rule.get("severity")
97
+ if severity is not None and severity not in (ERROR, WARNING, OFF):
98
+ raise SchemaError(
99
+ f"{where} has unknown severity {severity!r} (expected "
100
+ f"'{ERROR}', '{WARNING}' or '{OFF}')")
101
+
102
+
56
103
  @dataclass(frozen=True)
57
104
  class AttrSpec:
58
105
  """One declared attribute of an item type."""
@@ -182,6 +229,7 @@ class Schema:
182
229
 
183
230
  rules = config.get("rules") or {}
184
231
  coverage = tuple(rules.get("coverage", []) or [])
232
+ _check_coverage(coverage)
185
233
  rule_overrides = {k: v for k, v in rules.items() if k != "coverage"}
186
234
 
187
235
  docs_paths = tuple((config.get("docs") or {}).get("paths", []) or [])
@@ -492,3 +492,55 @@ def attr_remove(project: Project, itype: str, name: str) -> Change:
492
492
  def _remove_attr(doc: TomlDocument, itype: str, name: str, why: str) -> None:
493
493
  doc.remove_key(f"types.{itype}", f"attrs.{name}")
494
494
  doc.note_table(f"types.{itype}", why)
495
+
496
+
497
+ # `coverage` is the only kind of rule declared as a table under [rules] (SR-0192).
498
+ # Every other key there names a validation rule and sets its severity — a
499
+ # different shape, with a meaning of its own — so these verbs are defined for
500
+ # coverage alone rather than for a generality that does not exist.
501
+ _COVERAGE = ("rules", "coverage")
502
+
503
+
504
+ def _coverage(project: Project) -> list[dict]:
505
+ return list((project.config.get("rules") or {}).get("coverage") or [])
506
+
507
+
508
+ def rule_add(project: Project, *, filter: str, needs: str,
509
+ severity: str | None = None) -> Change:
510
+ """Declare a coverage rule. The filter and the needs clause are validated by
511
+ :meth:`Schema.from_config` before anything is written, in
512
+ :func:`apply_change` — which is the point of the operation: a rule the gate
513
+ could not enforce cannot reach the file (SR-0191, SR-0192)."""
514
+ rule: dict = {"filter": filter, "needs": needs}
515
+ if severity is not None:
516
+ rule["severity"] = severity
517
+ cfg = _copy(project)
518
+ rules = cfg.setdefault("rules", {})
519
+ existing = list(rules.get("coverage") or [])
520
+ if rule in existing:
521
+ raise SchemaOpError("that coverage rule is already declared")
522
+ rules["coverage"] = existing + [rule]
523
+ return Change(
524
+ f"adding a coverage rule: items matching {filter!r} need {needs}", cfg,
525
+ lambda doc, why: doc.add_array_table(_COVERAGE, rule, because=why))
526
+
527
+
528
+ def rule_remove(project: Project, index: int) -> Change:
529
+ """Withdraw the ``index``-th coverage rule, counting from 1 as `tl context`
530
+ prints them. By position because a coverage rule has no name to be called
531
+ by, and the list is short and ordered."""
532
+ rules_list = _coverage(project)
533
+ if not rules_list:
534
+ raise SchemaOpError("this project declares no coverage rules")
535
+ if index < 1 or index > len(rules_list):
536
+ raise SchemaOpError(
537
+ f"this project declares {len(rules_list)} coverage rule(s); "
538
+ f"there is no #{index}")
539
+ doomed = rules_list[index - 1]
540
+ cfg = _copy(project)
541
+ cfg.setdefault("rules", {})["coverage"] = [
542
+ r for pos, r in enumerate(rules_list) if pos != index - 1]
543
+ return Change(
544
+ f"removing coverage rule #{index} (needs {doomed.get('needs')})", cfg,
545
+ lambda doc, why: doc.remove_array_table(_COVERAGE, index - 1,
546
+ because=why))
@@ -22,6 +22,17 @@ from typing import NamedTuple
22
22
 
23
23
  import yaml
24
24
 
25
+ # One loader for every YAML the Tool reads (SR-0193). PyYAML's C loader parses the
26
+ # same files about nine times faster than the pure-Python one and constructs the
27
+ # same result; both are *safe* loaders that build no Python objects (NFR-0022).
28
+ # Chosen once here, so no module reads YAML by another route.
29
+ _YAML_LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
30
+
31
+
32
+ def _load_yaml(text: str):
33
+ """Parse one YAML document safely, through the loader chosen above."""
34
+ return yaml.load(text, Loader=_YAML_LOADER)
35
+
25
36
  from .fingerprint import fingerprint
26
37
  from .graph import Index
27
38
  from .grounding import ratification_refusal
@@ -635,7 +646,7 @@ def _build_project(root: Path, config: dict, manifest_names: set[str]) -> Projec
635
646
  manifests = sorted(m for name in manifest_names for m in root.rglob(name))
636
647
  for manifest in manifests:
637
648
  reg_dir = manifest.parent
638
- raw = yaml.safe_load(manifest.read_text(encoding="utf-8")) or {}
649
+ raw = _load_yaml(manifest.read_text(encoding="utf-8")) or {}
639
650
  reg = Register.from_manifest(raw, path=reg_dir)
640
651
  if reg.prefix in project.registers:
641
652
  # A second register folder claims a prefix already loaded. Keeping the
@@ -649,7 +660,7 @@ def _build_project(root: Path, config: dict, manifest_names: set[str]) -> Projec
649
660
  for item_file in sorted(reg_dir.glob("*.yml")):
650
661
  if item_file.name in manifest_names:
651
662
  continue
652
- d = yaml.safe_load(item_file.read_text(encoding="utf-8")) or {}
663
+ d = _load_yaml(item_file.read_text(encoding="utf-8")) or {}
653
664
  item = Item.from_dict(d, path=item_file)
654
665
  item._register_prefix = reg.prefix
655
666
  for msg in item._load_errors:
@@ -750,7 +761,7 @@ def baseline_statuses(project: Project, ref: str = "HEAD") -> dict[str, str] | N
750
761
  capture_output=True, text=True, check=True).stdout
751
762
  except (subprocess.CalledProcessError, FileNotFoundError, OSError):
752
763
  continue # not present at ref (new file) or bad ref for this path
753
- data = yaml.safe_load(blob) or {}
764
+ data = _load_yaml(blob) or {}
754
765
  status = data.get("status")
755
766
  if isinstance(status, str):
756
767
  out[item.uid] = status
@@ -787,7 +798,7 @@ def baseline_statuses(project: Project, ref: str = "HEAD") -> dict[str, str] | N
787
798
  capture_output=True, text=True, check=True).stdout
788
799
  except (subprocess.CalledProcessError, FileNotFoundError, OSError):
789
800
  continue
790
- data = yaml.safe_load(blob) or {}
801
+ data = _load_yaml(blob) or {}
791
802
  uid, status = data.get("uid"), data.get("status")
792
803
  if isinstance(uid, str) and isinstance(status, str):
793
804
  out.setdefault(uid, status)
@@ -342,6 +342,52 @@ class TomlDocument:
342
342
  start, end = span
343
343
  del self._lines[self._comment_start(start, -1):end]
344
344
 
345
+ def array_table_spans(self,
346
+ name: str | tuple[str, ...]) -> list[tuple[int, int]]:
347
+ """``(header_index, end)`` for each ``[[name]]`` block, in file order.
348
+ ``end`` is exclusive and stops at the next header of any kind."""
349
+ want = _as_path(name)
350
+ headers = self._headers()
351
+ return [(idx, headers[pos + 1][0] if pos + 1 < len(headers)
352
+ else len(self._lines))
353
+ for pos, (idx, found, is_array) in enumerate(headers)
354
+ if found == want and is_array]
355
+
356
+ def add_array_table(self, name: str | tuple[str, ...], values: dict, *,
357
+ because: str | None = None) -> None:
358
+ """Append a ``[[name]]`` block holding ``values``.
359
+
360
+ Appended rather than written in place, because the members of an
361
+ array-of-tables are ordered and the ones already there are not this
362
+ change's business — the same reason :meth:`add_to_array` writes at the
363
+ end of an array (SR-0183)."""
364
+ written = _render_path(_as_path(name))
365
+ body: list[str] = []
366
+ for key, value in values.items():
367
+ body.extend(render_assignment(key, value))
368
+ note = comment_block(because) if because else []
369
+ tail = [""] if self._lines and self._lines[-1].strip() else []
370
+ self._lines.extend(tail + note + [f"[[{written}]]"] + body)
371
+
372
+ def remove_array_table(self, name: str | tuple[str, ...], index: int, *,
373
+ because: str | None = None) -> None:
374
+ """Remove the ``index``-th (0-based) ``[[name]]`` block, taking the
375
+ comment that introduced it with it.
376
+
377
+ ``because`` is left behind in the block's place. A removal has no key or
378
+ table left to hang its reason on, and the gap where the rule used to be
379
+ is the one spot where a reader looking for it will pass (SR-0184)."""
380
+ spans = self.array_table_spans(name)
381
+ written = _render_path(_as_path(name))
382
+ if index < 0 or index >= len(spans):
383
+ raise TomlEditError(
384
+ f"this document has {len(spans)} [[{written}]] block(s); "
385
+ f"there is no #{index + 1}")
386
+ start, end = spans[index]
387
+ floor = spans[index - 1][1] - 1 if index else -1
388
+ at = self._comment_start(start, floor)
389
+ self._lines[at:end] = comment_block(because) if because else []
390
+
345
391
  def note_table(self, name: str | tuple[str, ...], because: str) -> None:
346
392
  """Record ``because`` above the header of an existing table."""
347
393
  span = self._table_span(name)
@@ -15,7 +15,7 @@ from .fingerprint import fingerprint
15
15
  from .filters import FilterError, safe_eval
16
16
  from .graph import Index
17
17
  from .grounding import reaches_root
18
- from .schema import ERROR, OFF, WARNING
18
+ from .schema import COVERAGE_NEEDS_RE, ERROR, OFF, WARNING
19
19
  from .storage import CONFIG_NAME, FORMAT_VERSION, STATUS_ROLES_MAJOR
20
20
  from .uid import UID_RE, collisions
21
21
 
@@ -54,6 +54,10 @@ _DEFAULT_SEVERITY = {
54
54
  "ambiguous": WARNING, "coverage": WARNING, "vague-word": WARNING,
55
55
  "unpublished": WARNING,
56
56
  }
57
+ # `rule-filter` is deliberately absent above. Every rule there describes a
58
+ # judgement a project may reasonably make differently; a coverage rule that
59
+ # cannot be evaluated is not one of those. Silencing it would restore exactly the
60
+ # inert gate SR-0191 exists to expose, with nothing to tell it from a live one.
57
61
 
58
62
  def _file(item) -> str:
59
63
  return str(item._path) if item._path else ""
@@ -352,10 +356,18 @@ def _coverage_rules(project, idx: Index, strict: bool) -> list[Finding]:
352
356
  incoming/outgoing link of a given type."""
353
357
  findings: list[Finding] = []
354
358
  schema = project.schema
355
- for rule in schema.coverage:
359
+ cfg_file = str(project.path / CONFIG_NAME)
360
+ for pos, rule in enumerate(schema.coverage):
356
361
  needs = rule.get("needs", "")
357
- m = re.match(r"(incoming|outgoing):(\w+)", needs)
362
+ m = COVERAGE_NEEDS_RE.match(str(needs).strip())
358
363
  if not m:
364
+ # Unreachable through a loaded project — Schema.from_config refuses a
365
+ # rule this malformed (SR-0191). Reported rather than skipped so a
366
+ # schema built by hand cannot reintroduce the silent inert rule.
367
+ findings.append(Finding(
368
+ "rule-filter", ERROR, "", cfg_file,
369
+ f"[[rules.coverage]] #{pos + 1} has an unusable 'needs' clause "
370
+ f"{needs!r}"))
359
371
  continue
360
372
  direction, ltype = m.group(1), m.group(2)
361
373
  sev = rule.get("severity", WARNING)
@@ -363,8 +375,23 @@ def _coverage_rules(project, idx: Index, strict: bool) -> list[Finding]:
363
375
  sev = ERROR
364
376
  if sev == OFF:
365
377
  continue
378
+ expr = rule.get("filter", "")
366
379
  for item in project.items():
367
- if item.is_deleted or not _match_filter(item, rule.get("filter", ""), idx):
380
+ if item.is_deleted:
381
+ continue
382
+ try:
383
+ matched = eval_filter(item, expr, idx)
384
+ except FilterError as e:
385
+ # The rule cannot be enforced. Reported once, as an error, and the
386
+ # rule abandoned: a filter that raises used to be read as 'matches
387
+ # nothing', which left the gate asserting nothing while check
388
+ # printed that the graph was sound (SR-0191).
389
+ findings.append(Finding(
390
+ "rule-filter", ERROR, "", cfg_file,
391
+ f"[[rules.coverage]] #{pos + 1} ({needs}) could not be "
392
+ f"evaluated against {item.uid}: {e} — filter = {expr!r}"))
393
+ break
394
+ if not matched:
368
395
  continue
369
396
  links = (idx.in_links(item.uid, {ltype}) if direction == "incoming"
370
397
  else idx.out_links(item.uid, {ltype}))
@@ -403,7 +430,9 @@ class _LinkView:
403
430
  def _filter_namespace(item, idx: Index | None = None) -> dict:
404
431
  """The one set of names an SR-0045 filter can reference (grammar: SR-0104).
405
432
  Shared by coverage rules, the `query` CLI, and document injection so the
406
- language is identical in all three."""
433
+ language is identical in all three. The keys are exactly
434
+ :data:`throughline.filters.FILTER_NAMES`, which is what the static check
435
+ validates against; a test holds the two together."""
407
436
  return {
408
437
  "type": item.type, "status": item.status, "register": item._register_prefix,
409
438
  "uid": item.uid, "derived": item.derived, "normative": item.normative,
@@ -425,15 +454,6 @@ def eval_filter(item, expr: str, idx: Index | None = None) -> bool:
425
454
  return bool(safe_eval(expr, _filter_namespace(item, idx)))
426
455
 
427
456
 
428
- def _match_filter(item, expr: str, idx: Index | None = None) -> bool:
429
- """Lenient variant for config-declared coverage rules: a malformed rule
430
- filter simply fails to match rather than aborting the whole check."""
431
- try:
432
- return eval_filter(item, expr, idx)
433
- except FilterError:
434
- return False
435
-
436
-
437
457
  def is_external(target: str) -> bool:
438
458
  """True if a link target is a free external reference — a URL, a repository path,
439
459
  or an anchor (SR-0031) — that the graph deliberately leaves opaque. Public so a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 2.2.0
3
+ Version: 2.2.1
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
- 156
39
+ 159
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
42
  25
@@ -30,4 +30,5 @@ tests/test_doctor.py
30
30
  tests/test_engine.py
31
31
  tests/test_filters.py
32
32
  tests/test_ratify_status.py
33
- tests/test_schema_ops.py
33
+ tests/test_schema_ops.py
34
+ tests/test_yaml_loader.py
@@ -569,6 +569,58 @@ def test_coverage_rule_needs_incoming_link():
569
569
  assert ("FR-1", "coverage") in _rules(validate(p))
570
570
 
571
571
 
572
+ def test_coverage_rule_with_an_unusable_filter_is_refused_at_load():
573
+ """SR-0191. The filter names a bare attribute where the language requires
574
+ attrs.get(...). Before this, the rule matched no item, check reported the
575
+ graph sound, and the gate asserted nothing at all."""
576
+ with pytest.raises(SchemaError, match="unknown name 'verification'"):
577
+ Schema.from_config({"rules": {"coverage": [
578
+ {"filter": "type == 'requirement' and verification == 'test'",
579
+ "needs": "incoming:verifies"}]}})
580
+
581
+
582
+ def test_coverage_rule_refusal_names_the_rule_and_the_expression():
583
+ """SR-0191. A refusal that does not say which rule sends its reader off to
584
+ read the whole file."""
585
+ with pytest.raises(SchemaError) as e:
586
+ Schema.from_config({"rules": {"coverage": [
587
+ {"filter": "true", "needs": "incoming:verifies"},
588
+ {"filter": "nope == 1", "needs": "incoming:verifies"}]}})
589
+ assert "#2" in str(e.value)
590
+ assert "nope == 1" in str(e.value)
591
+
592
+
593
+ def test_coverage_rule_with_an_unusable_needs_clause_is_refused():
594
+ """SR-0191. An unparseable `needs` was skipped, which left the rule inert in
595
+ exactly the way a broken filter did."""
596
+ for needs in ("verifies", "sideways:verifies", None):
597
+ rule = {"filter": "true"}
598
+ if needs is not None:
599
+ rule["needs"] = needs
600
+ with pytest.raises(SchemaError):
601
+ Schema.from_config({"rules": {"coverage": [rule]}})
602
+
603
+
604
+ def test_coverage_rule_with_an_unknown_severity_is_refused():
605
+ """SR-0191. A misspelt severity is neither error, warning nor off, so the
606
+ finding it produced could be neither counted nor silenced."""
607
+ with pytest.raises(SchemaError, match="severity"):
608
+ Schema.from_config({"rules": {"coverage": [
609
+ {"filter": "true", "needs": "incoming:verifies",
610
+ "severity": "eror"}]}})
611
+
612
+
613
+ def test_a_filter_that_raises_at_evaluation_is_an_error_not_a_non_match():
614
+ """SR-0191. The static check cannot see every failure — comparing values
615
+ whose types do not compare depends on the data. Reaching past the load-time
616
+ refusal is how that residue is exercised."""
617
+ p = _grounded_project()
618
+ p.schema.coverage = ({"filter": "attrs.get('missing') > 1",
619
+ "needs": "incoming:verifies"},)
620
+ assert any(f.rule == "rule-filter" and f.severity == "error"
621
+ for f in validate(p))
622
+
623
+
572
624
  # ---------------------------------------------------------------------- schema
573
625
 
574
626
  def test_schema_helpers_are_the_single_source(tmp_path):
@@ -10,10 +10,10 @@ from __future__ import annotations
10
10
 
11
11
  import pytest
12
12
 
13
- from throughline.filters import FilterError, safe_eval
13
+ from throughline.filters import FILTER_NAMES, FilterError, check_filter, safe_eval
14
14
  from throughline.graph import Index
15
15
  from throughline.model import Item, Link
16
- from throughline.validate import eval_filter
16
+ from throughline.validate import _filter_namespace, eval_filter
17
17
 
18
18
  NS = {
19
19
  "uid": "SR-0045", "type": "system_requirement", "status": "approved",
@@ -167,3 +167,63 @@ def test_outgoing_and_to_work_without_index():
167
167
  _, _, it = _link_graph()
168
168
  assert eval_filter(it["SR-0001"], "links.outgoing('implements')", None)
169
169
  assert eval_filter(it["SR-0001"], "links.to('UR-0001')", None)
170
+
171
+
172
+ # ------------------------------------------- static validation (SR-0191)
173
+
174
+ def test_check_filter_accepts_what_eval_accepts():
175
+ """The static check and the evaluator walk one grammar. Every form the
176
+ evaluator runs must pass the check, or a legal filter would be refused at
177
+ load for being legal."""
178
+ for expr in (
179
+ "type == 'system_requirement'",
180
+ "attrs.get('priority') == 'must'",
181
+ "not normative or derived",
182
+ "status in ['approved', 'ratified']",
183
+ "attrs['score'] > 2",
184
+ "title.lower().startswith('filter')",
185
+ "'security' in attrs.get('tags')",
186
+ "uid == 'SR-0045' and (title != none)",
187
+ ):
188
+ check_filter(expr) # must not raise
189
+ safe_eval(expr, NS) # and must still evaluate
190
+
191
+
192
+ def test_check_filter_rejects_a_bare_attribute_name():
193
+ """The defect this exists for: `verification == 'test'` instead of
194
+ `attrs.get('verification')`. The evaluator already knew; nothing asked it."""
195
+ with pytest.raises(FilterError, match="unknown name 'verification'"):
196
+ check_filter("verification == 'test'")
197
+
198
+
199
+ def test_check_filter_sees_past_a_short_circuit():
200
+ """Why the check is static. Evaluation never reaches the misspelling for an
201
+ item whose type does not match, so an evaluation-time check would report
202
+ soundness or breakage depending on which items the graph happened to hold."""
203
+ expr = "type == 'nfr' and verifcation == 'test'"
204
+ # The evaluator, given a non-matching item, short-circuits and reports False.
205
+ assert safe_eval(expr, NS) is False
206
+ # The static check reads the whole expression and is not fooled.
207
+ with pytest.raises(FilterError, match="unknown name 'verifcation'"):
208
+ check_filter(expr)
209
+
210
+
211
+ def test_check_filter_rejects_syntax_methods_and_forms():
212
+ for expr, message in (
213
+ ("type == ", "could not parse"),
214
+ ("title.format('{0.__class__}')", "not allowed"),
215
+ ("attrs.get('a', b=1)", "keyword arguments"),
216
+ ("title[0:2] == 'ab'", "slices are not allowed"),
217
+ ("__import__('os')", "only method calls"),
218
+ ("lambda: 1", "unsupported expression"),
219
+ ):
220
+ with pytest.raises(FilterError, match=message):
221
+ check_filter(expr)
222
+
223
+
224
+ def test_filter_names_are_exactly_the_runtime_namespace():
225
+ """The static check validates against FILTER_NAMES; the evaluator reads the
226
+ namespace `_filter_namespace` builds. If they drift, the check either refuses
227
+ a legal filter or admits one that raises at evaluation."""
228
+ item = Item(uid="SR-0001", type="system_requirement", title="t")
229
+ assert set(_filter_namespace(item)) == set(FILTER_NAMES)
@@ -283,3 +283,89 @@ def test_a_change_the_schema_itself_rejects_is_a_usage_error(tmp_path):
283
283
  "--because", "No longer used."])
284
284
 
285
285
  assert code == 2
286
+
287
+
288
+ # ------------------------------------------- coverage rules (SR-0192)
289
+
290
+ def test_a_coverage_rule_can_be_added_and_records_why(tmp_path):
291
+ """SR-0192. `[[rules.coverage]]` was the one part of the schema with no verb,
292
+ so a project wanting a rule other than the seeded one had to edit the file."""
293
+ root = _scaffold(tmp_path)
294
+ code = _cli(["-C", str(root), "schema", "rule", "add",
295
+ "--filter", "type == 'requirement' and status != 'deleted'",
296
+ "--needs", "incoming:verifies",
297
+ "--because", "A requirement nothing tests is not finished."])
298
+
299
+ assert code == 0
300
+ rules = _config(root)["rules"]["coverage"]
301
+ assert {"filter": "type == 'requirement' and status != 'deleted'",
302
+ "needs": "incoming:verifies"} in rules
303
+ assert "A requirement nothing tests" in (root / "throughline.toml").read_text()
304
+
305
+
306
+ def test_adding_a_rule_whose_filter_cannot_be_evaluated_is_refused(tmp_path):
307
+ """SR-0191 and SR-0192 together: this is why the verb exists. The expression
308
+ is the one that made a real project's gate inert — a bare attribute name
309
+ where the language requires attrs.get(...). It must not reach the file."""
310
+ root = _scaffold(tmp_path)
311
+ before = (root / "throughline.toml").read_text()
312
+
313
+ code = _cli(["-C", str(root), "schema", "rule", "add",
314
+ "--filter", "type == 'requirement' and verification == 'test'",
315
+ "--needs", "incoming:verifies",
316
+ "--because", "Tested requirements need a test."])
317
+
318
+ assert code == 2
319
+ assert (root / "throughline.toml").read_text() == before
320
+
321
+
322
+ def test_adding_a_rule_with_an_unusable_needs_clause_is_refused(tmp_path):
323
+ root = _scaffold(tmp_path)
324
+ before = (root / "throughline.toml").read_text()
325
+ code = _cli(["-C", str(root), "schema", "rule", "add",
326
+ "--filter", "type == 'requirement'", "--needs", "verifies",
327
+ "--because", "x"])
328
+ assert code == 2
329
+ assert (root / "throughline.toml").read_text() == before
330
+
331
+
332
+ def test_a_coverage_rule_can_be_removed_by_position(tmp_path):
333
+ """SR-0192. A coverage rule has no name to be called by, so it is withdrawn
334
+ by the position `tl context` prints it at."""
335
+ root = _scaffold(tmp_path)
336
+ base = len(_config(root).get("rules", {}).get("coverage", []))
337
+ for ltype in ("verifies", "implements"):
338
+ assert _cli(["-C", str(root), "schema", "rule", "add",
339
+ "--filter", f"type == '{ltype}-ish'",
340
+ "--needs", f"incoming:{ltype}",
341
+ "--because", f"Cover {ltype}."]) == 0
342
+ assert len(_config(root)["rules"]["coverage"]) == base + 2
343
+
344
+ code = _cli(["-C", str(root), "schema", "rule", "remove", str(base + 1),
345
+ "--because", "Superseded by the second rule."])
346
+
347
+ assert code == 0
348
+ remaining = _config(root)["rules"]["coverage"]
349
+ assert len(remaining) == base + 1
350
+ assert remaining[-1]["needs"] == "incoming:implements"
351
+ # The reason survives the thing it explains (SR-0184).
352
+ assert "Superseded by the second rule." in (root / "throughline.toml").read_text()
353
+
354
+
355
+ def test_removing_a_rule_that_is_not_there_is_a_usage_error(tmp_path):
356
+ root = _scaffold(tmp_path)
357
+ beyond = len(_config(root).get("rules", {}).get("coverage", [])) + 1
358
+ assert _cli(["-C", str(root), "schema", "rule", "remove", str(beyond),
359
+ "--because", "x"]) == 2
360
+
361
+
362
+ def test_adding_a_rule_leaves_the_rest_of_the_file_alone(tmp_path):
363
+ """SR-0183. An array-of-tables is appended, so no existing block moves."""
364
+ root = _scaffold(tmp_path)
365
+ before = (root / "throughline.toml").read_text()
366
+ assert _cli(["-C", str(root), "schema", "rule", "add",
367
+ "--filter", "type == 'requirement'",
368
+ "--needs", "incoming:verifies",
369
+ "--because", "Requirements need tests."]) == 0
370
+ after = (root / "throughline.toml").read_text()
371
+ assert after.startswith(before.rstrip("\n"))
@@ -0,0 +1,52 @@
1
+ # Copyright (c) 2026 Henry J Grech-Cini
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """SR-0193: one safe loader for every YAML read, the C one where PyYAML has it."""
4
+ from __future__ import annotations
5
+
6
+ import yaml
7
+ import pytest
8
+
9
+ from throughline import storage
10
+ from throughline.cli import main as cli
11
+
12
+
13
+ def _dump(root) -> str:
14
+ """The project as `tl dump` prints it — the whole parsed graph as one string."""
15
+ import io
16
+ from contextlib import redirect_stdout
17
+
18
+ out = io.StringIO()
19
+ with redirect_stdout(out):
20
+ code = cli(["-C", str(root), "dump"])
21
+ assert code == 0
22
+ return out.getvalue()
23
+
24
+
25
+ def test_the_c_loader_is_used_when_libyaml_is_present():
26
+ if yaml.__with_libyaml__:
27
+ assert storage._YAML_LOADER is yaml.CSafeLoader
28
+ else: # pragma: no cover - depends on how PyYAML was built
29
+ assert storage._YAML_LOADER is yaml.SafeLoader
30
+
31
+
32
+ def test_both_loaders_read_a_project_identically(tmp_path, monkeypatch):
33
+ root = tmp_path / "p"
34
+ root.mkdir()
35
+ assert cli(["-C", str(root), "init", "--name", "loader", "--no-demo"]) == 0
36
+ assert cli(["-C", str(root), "new", "INT", "--type", "intent", "--origin", "human",
37
+ "--title", "Why", "--no-interactive"]) == 0
38
+ assert cli(["-C", str(root), "new", "REQ", "--type", "requirement", "--origin", "human",
39
+ "--ground", "INT-0001", "--ground-type", "derives_from",
40
+ "--title", "A title with: a colon, \"quotes\" and — a dash",
41
+ "--text", "Two\nlines", "--no-interactive"]) == 0
42
+ fast = _dump(root)
43
+ monkeypatch.setattr(storage, "_YAML_LOADER", yaml.SafeLoader)
44
+ slow = _dump(root)
45
+ assert fast == slow and '"REQ-0001"' in fast
46
+
47
+
48
+ @pytest.mark.parametrize("loader", [yaml.SafeLoader] + ([yaml.CSafeLoader] if yaml.__with_libyaml__ else []))
49
+ def test_a_python_object_tag_is_refused_by_either_loader(loader, monkeypatch):
50
+ monkeypatch.setattr(storage, "_YAML_LOADER", loader)
51
+ with pytest.raises(yaml.YAMLError):
52
+ storage._load_yaml("uid: !!python/object/apply:os.system ['echo owned']\n")
File without changes
File without changes
File without changes