throughline 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {throughline-2.2.0/src/throughline.egg-info → throughline-2.3.0}/PKG-INFO +3 -3
  2. {throughline-2.2.0 → throughline-2.3.0}/README.md +2 -2
  3. {throughline-2.2.0 → throughline-2.3.0}/pyproject.toml +1 -1
  4. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/cli.py +126 -7
  5. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/filters.py +86 -0
  6. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/grounding.py +35 -19
  7. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/schema.py +48 -0
  8. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/schema_ops.py +52 -0
  9. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/storage.py +15 -4
  10. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/tomledit.py +46 -0
  11. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/validate.py +51 -26
  12. {throughline-2.2.0 → throughline-2.3.0/src/throughline.egg-info}/PKG-INFO +3 -3
  13. {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/SOURCES.txt +2 -1
  14. {throughline-2.2.0 → throughline-2.3.0}/tests/test_doctor.py +114 -0
  15. {throughline-2.2.0 → throughline-2.3.0}/tests/test_engine.py +203 -2
  16. {throughline-2.2.0 → throughline-2.3.0}/tests/test_filters.py +62 -2
  17. {throughline-2.2.0 → throughline-2.3.0}/tests/test_schema_ops.py +86 -0
  18. throughline-2.3.0/tests/test_yaml_loader.py +52 -0
  19. {throughline-2.2.0 → throughline-2.3.0}/LICENSE +0 -0
  20. {throughline-2.2.0 → throughline-2.3.0}/NOTICE +0 -0
  21. {throughline-2.2.0 → throughline-2.3.0}/setup.cfg +0 -0
  22. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/__init__.py +0 -0
  23. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/dump.py +0 -0
  24. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/fingerprint.py +0 -0
  25. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/graph.py +0 -0
  26. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/identity.py +0 -0
  27. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/inject.py +0 -0
  28. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/model.py +0 -0
  29. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/uid.py +0 -0
  30. {throughline-2.2.0 → throughline-2.3.0}/src/throughline/version.py +0 -0
  31. {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/dependency_links.txt +0 -0
  32. {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/entry_points.txt +0 -0
  33. {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/requires.txt +0 -0
  34. {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/top_level.txt +0 -0
  35. {throughline-2.2.0 → throughline-2.3.0}/tests/test_amend.py +0 -0
  36. {throughline-2.2.0 → throughline-2.3.0}/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.3.0
4
4
  Summary: A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check.
5
5
  Author-email: Henry J Grech-Cini <henry.grechcini@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -36,10 +36,10 @@ version control; a `check` command validates the whole graph and gates CI.
36
36
 
37
37
  **Dogfooded:** throughline's own spec is itself a throughline project —
38
38
  <!-- tl:count type == 'system_requirement' -->
39
- 156
39
+ 161
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
- 25
42
+ 26
43
43
  <!-- tl:end --> user requirements, and
44
44
  <!-- tl:count type == 'nfr' -->
45
45
  21
@@ -8,10 +8,10 @@ version control; a `check` command validates the whole graph and gates CI.
8
8
 
9
9
  **Dogfooded:** throughline's own spec is itself a throughline project —
10
10
  <!-- tl:count type == 'system_requirement' -->
11
- 156
11
+ 161
12
12
  <!-- tl:end --> system requirements,
13
13
  <!-- tl:count type == 'user_requirement' -->
14
- 25
14
+ 26
15
15
  <!-- tl:end --> user requirements, and
16
16
  <!-- tl:count type == 'nfr' -->
17
17
  21
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "throughline"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -20,6 +20,7 @@ from .graph import Index
20
20
  from .grounding import (
21
21
  GroundingError,
22
22
  invalidate,
23
+ ratification_obstacle,
23
24
  ratify,
24
25
  reaches_root,
25
26
  set_status,
@@ -47,7 +48,7 @@ from .storage import (
47
48
  write_manifest,
48
49
  )
49
50
  from .uid import PREFIX_GRAMMAR, UidError, next_uid, parse_uid, valid_prefix
50
- from .validate import ERROR, FilterError, eval_filter, validate
51
+ from .validate import ERROR, OFF, WARNING, FilterError, eval_filter, validate
51
52
  from .version import distribution_version
52
53
 
53
54
  OK, FINDINGS, USAGE = 0, 1, 2
@@ -1286,12 +1287,13 @@ def _ctx_coverage(schema) -> str:
1286
1287
  out.append("_No `[[rules.coverage]]` declared._")
1287
1288
  return "\n".join(out)
1288
1289
  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:
1290
+ "(unmet → a `coverage` finding). They are numbered as "
1291
+ "`tl schema rule remove` counts them:\n")
1292
+ for pos, rule in enumerate(schema.coverage, start=1):
1291
1293
  filt = rule.get("filter", "*")
1292
1294
  needs = rule.get("needs", "?")
1293
- sev = rule.get("severity", "error")
1294
- out.append(f"- items where `{filt}` **need** `{needs}` "
1295
+ sev = rule.get("severity", WARNING)
1296
+ out.append(f"{pos}. items where `{filt}` **need** `{needs}` "
1295
1297
  f"(severity: {sev})")
1296
1298
  return "\n".join(out)
1297
1299
 
@@ -1363,7 +1365,7 @@ _CTX_COMMAND_EMPHASIS = {
1363
1365
  "delete": "tombstones an item; the file stays, the item stops counting",
1364
1366
  "amend": "change content through the tool, never by opening the YAML",
1365
1367
  "schema": "change the schema itself — nouns: status, transition, type, attr, "
1366
- "linktype, linkrule, grounding; refuses a change that would "
1368
+ "linktype, linkrule, grounding, rule; refuses a change that would "
1367
1369
  "invalidate existing items and says what to fix",
1368
1370
  }
1369
1371
 
@@ -1694,6 +1696,75 @@ def cmd_subgraph(args) -> int:
1694
1696
  return OK
1695
1697
 
1696
1698
 
1699
+ def _render_for_ratification(project, item, *, emit) -> None:
1700
+ """Put the item in front of the person about to sign it (SR-0195, UR-0029).
1701
+
1702
+ Not :func:`throughline.inject.render_item`: that renders Markdown for a
1703
+ published document, and states every attribute alike. A ratifier is being asked
1704
+ to judge, so this separates the normative attributes — the ones whose change
1705
+ breaks the signature — from the rest, and names each grounding target so the
1706
+ 'why' reads as a sentence instead of a bare UID the reader has to go and look
1707
+ up. Emitted on stderr like every other piece of guidance (SR-0120), leaving
1708
+ stdout to the one line that says what was ratified.
1709
+ """
1710
+ schema = project.schema
1711
+ emit("")
1712
+ emit(f"{item.uid} [{item.type}/{item.status}] {item.title}".rstrip())
1713
+ for label, body in (("", item.text), ("rationale", item.rationale)):
1714
+ if not body:
1715
+ continue
1716
+ emit("")
1717
+ if label:
1718
+ emit(f"{label}:")
1719
+ for line in body.splitlines():
1720
+ emit(f" {line}" if line else "")
1721
+ grounding = [ln for ln in (_ground_line(project, link, schema)
1722
+ for link in item.links) if ln]
1723
+ emit("")
1724
+ if grounding:
1725
+ emit("grounded by:")
1726
+ for line in grounding:
1727
+ emit(f" {line}")
1728
+ else:
1729
+ # A root needs no grounding and says so; anything else could not have got
1730
+ # this far, since ratification_obstacle refuses an ungrounded item.
1731
+ emit("grounded by: nothing — this is a root and justifies itself")
1732
+ normative = [n for n in schema.normative_attrs(item.type) if n in item.attrs]
1733
+ if normative:
1734
+ emit("")
1735
+ emit("normative attributes (a change here breaks this signature): "
1736
+ + " · ".join(f"{n}={item.attrs[n]}" for n in normative))
1737
+ emit("")
1738
+
1739
+
1740
+ def _ground_line(project, link, schema) -> str | None:
1741
+ """One grounding link rendered with its target's title, or None for a link that
1742
+ confers no grounding — those are context, not justification, and listing them
1743
+ here would present a 'relates' neighbour as a reason the item exists."""
1744
+ if link.type not in schema.ground_link_types:
1745
+ return None
1746
+ target = project.get(link.target)
1747
+ if target is None:
1748
+ return f"{link.type} {link.target} (unresolved)"
1749
+ return f"{link.type} {link.target} [{target.type}/{target.status}] {target.title}".rstrip()
1750
+
1751
+
1752
+ def _confirm(question: str) -> bool:
1753
+ """Ask a yes/no question on an interactive terminal, defaulting to no (SR-0195).
1754
+
1755
+ A confirmation is not a prompt for a value, so it has no flag behind it and
1756
+ SR-0120's rule that a fully specified command is never prompted for one does
1757
+ not reach it: the whole point is to stop a command that already says everything
1758
+ it needs to say. Silence, EOF and anything unrecognised all decline — the
1759
+ default must never be the irreversible answer.
1760
+ """
1761
+ try:
1762
+ raw = input(f"{question} [y/N]: ").strip().lower()
1763
+ except EOFError:
1764
+ return False
1765
+ return raw in ("y", "yes")
1766
+
1767
+
1697
1768
  def cmd_ratify(args) -> int:
1698
1769
  try:
1699
1770
  project = load_project(args.path)
@@ -1704,6 +1775,26 @@ def cmd_ratify(args) -> int:
1704
1775
  uid = _resolve_uid(project, args.uid, "ratify", "UID")
1705
1776
  if uid is None:
1706
1777
  return USAGE
1778
+ item = project.get(uid)
1779
+ if item is None:
1780
+ return _err(f"{uid} does not exist")
1781
+ # Refuse before rendering anything or asking anyone (SR-0195). The old order
1782
+ # asked who was taking accountability and only then discovered that nothing
1783
+ # could be signed, which taught that the prompt was a formality. The index is
1784
+ # built once and handed to ratify, so the question asked here and the write
1785
+ # below read the same graph.
1786
+ idx = Index.build(project)
1787
+ obstacle = ratification_obstacle(project.schema, idx, item)
1788
+ if obstacle is not None:
1789
+ return _err(obstacle)
1790
+ # Ratifying is taking accountability, so the content comes before the signature
1791
+ # (UR-0029) — and before the identity prompt, so the reader knows what they are
1792
+ # being asked about while they are being asked. Non-interactive runs render
1793
+ # nothing: there is no reader to serve, and output nobody reads is noise in CI.
1794
+ interactive = _interactive()
1795
+ if interactive:
1796
+ _render_for_ratification(
1797
+ project, item, emit=lambda line: print(line, file=sys.stderr))
1707
1798
  # Offer the identity this repository already signs commits with (SR-0156). It
1708
1799
  # is only ever a default: _resolve_value shows it and takes it on assent, and a
1709
1800
  # non-interactive session that names no ratifier is refused, not signed for.
@@ -1711,8 +1802,18 @@ def cmd_ratify(args) -> int:
1711
1802
  default=default_ratifier(args.path))
1712
1803
  if by is None:
1713
1804
  return USAGE
1805
+ # The stop that makes the rendering more than decoration, asked whether or not
1806
+ # --by was supplied (SR-0195) — a fully specified command is exactly how a bulk
1807
+ # or habitual ratification is run, and display without a stop is a warning that
1808
+ # scrolled past. SR-0120 permits it: confirming an act is not prompting for a
1809
+ # value. Declining writes nothing and is not an error; the user was asked and
1810
+ # answered.
1811
+ if interactive and not _confirm(f"ratify {uid} as {by}?"):
1812
+ print("not ratified", file=sys.stderr)
1813
+ return OK
1714
1814
  try:
1715
- item = ratify(project, uid, by=by, by_id=getattr(args, "by_id", None))
1815
+ item = ratify(project, uid, by=by, index=idx,
1816
+ by_id=getattr(args, "by_id", None))
1716
1817
  except IdentityError as e:
1717
1818
  return _err(str(e))
1718
1819
  except (ProjectError, GroundingError, SchemaError) as e:
@@ -1814,6 +1915,7 @@ def _add_schema_parser(sub) -> None:
1814
1915
  "linktype": "the link vocabulary",
1815
1916
  "linkrule": "endpoint constraints on a link type",
1816
1917
  "grounding": "the grounding configuration",
1918
+ "rule": "coverage rules the gate enforces",
1817
1919
  }
1818
1920
  made: dict[str, object] = {}
1819
1921
 
@@ -1928,6 +2030,23 @@ def _add_schema_parser(sub) -> None:
1928
2030
  v.add_argument("field", metavar="FIELD")
1929
2031
  v.add_argument("value")
1930
2032
 
2033
+ v = _schema_verb("rule", "add",
2034
+ lambda p, a: schema_ops.rule_add(
2035
+ p, filter=a.filter, needs=a.needs, severity=a.severity),
2036
+ "declare a coverage rule")
2037
+ v.add_argument("--filter", required=True,
2038
+ help="which items the rule governs, e.g. \"type == 'system_"
2039
+ "requirement' and attrs.get('verification') == 'test'\"")
2040
+ v.add_argument("--needs", required=True,
2041
+ help="what they must have: incoming:<link type> or "
2042
+ "outgoing:<link type>")
2043
+ v.add_argument("--severity", default=None, choices=[ERROR, WARNING, OFF],
2044
+ help="default: warning")
2045
+ v = _schema_verb("rule", "remove",
2046
+ lambda p, a: schema_ops.rule_remove(p, a.index),
2047
+ "withdraw a coverage rule, by its position in `tl context`")
2048
+ v.add_argument("index", type=int, metavar="N")
2049
+
1931
2050
 
1932
2051
  def build_parser() -> argparse.ArgumentParser:
1933
2052
  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
 
@@ -67,6 +67,33 @@ def ratification_refusal(schema, idx: Index, item: Item) -> str | None:
67
67
  return None
68
68
 
69
69
 
70
+ def ratification_obstacle(schema, idx: Index, item: Item) -> str | None:
71
+ """Why :func:`ratify` would refuse ``item`` as the graph now stands, or ``None``
72
+ when it would proceed — the whole precondition set, asked without writing
73
+ anything (SR-0195).
74
+
75
+ ratify must refuse an item it cannot accept *before* a front end renders it or
76
+ asks anyone to confirm it, so "may this be signed?" has to be answerable ahead
77
+ of the act. :func:`ratify` answers it through this same function rather than
78
+ repeating the conditions, so the refusal a user is shown early is by
79
+ construction the refusal the write would have raised. It is a superset of
80
+ :func:`ratification_refusal`, which stays the narrower "must never be signed"
81
+ predicate the migration repair binds against (SR-0152) — an unstamped record
82
+ is exactly what that repair exists to complete, so it must not be told that an
83
+ already-ratified item has nothing to accept."""
84
+ refusal = ratification_refusal(schema, idx, item)
85
+ if refusal is not None:
86
+ return refusal
87
+ already = (item.status == schema.status_role("ratified")
88
+ if schema.ratify_moves_status
89
+ else item.attrs.get(RATIFIED_BY_ATTR) is not None)
90
+ if already and item.attrs.get("ratified_fingerprint") == fingerprint(item, schema):
91
+ return (f"{item.uid} is already ratified by "
92
+ f"{item.attrs.get('ratified_by', 'a human')} and its content has "
93
+ "not changed since — there is nothing to accept")
94
+ return None
95
+
96
+
70
97
  def ratify(project, uid: str, by: str, *, index: Index | None = None,
71
98
  by_id: str | None = None) -> Item:
72
99
  """A human takes accountability. Refused for ambiguous or ungrounded items —
@@ -86,26 +113,15 @@ def ratify(project, uid: str, by: str, *, index: Index | None = None,
86
113
  raise GroundingError(f"{uid} does not exist")
87
114
  schema = project.schema
88
115
  idx = index if index is not None else Index.build(project)
89
- refusal = ratification_refusal(schema, idx, item)
90
- if refusal is not None:
91
- raise GroundingError(refusal)
92
- # Ratifying an already-ratified item whose content has not moved accepts
93
- # nothing, and would replace the record of who accepted it leaving no trace
94
- # that it changed (SR-0148). An item ratified before the stamp existed has
95
- # none to compare against, so that first call is allowed through and stamps it.
116
+ # Every reason this may be refused, including that an already-ratified item
117
+ # whose content has not moved accepts nothing and would replace the record of
118
+ # who accepted it leaving no trace that it changed (SR-0148). An item ratified
119
+ # before the stamp existed has none to compare against, so that first call is
120
+ # allowed through and stamps it.
121
+ obstacle = ratification_obstacle(schema, idx, item)
122
+ if obstacle is not None:
123
+ raise GroundingError(obstacle)
96
124
  current = fingerprint(item, schema)
97
- # "Already ratified" is read from whatever this project uses as the durable
98
- # proof (SR-0172). Where ratification advances the item, that is the status, as
99
- # it always has been. Where it does not, the status says nothing about sign-off
100
- # and the record itself is the only honest witness.
101
- already = (item.status == schema.status_role("ratified")
102
- if schema.ratify_moves_status
103
- else item.attrs.get(RATIFIED_BY_ATTR) is not None)
104
- if already and item.attrs.get("ratified_fingerprint") == current:
105
- raise GroundingError(
106
- f"{uid} is already ratified by "
107
- f"{item.attrs.get('ratified_by', 'a human')} and its content has not "
108
- "changed since — there is nothing to accept")
109
125
  # Advancing is the default, and is transition-validated — an item that cannot
110
126
  # legally reach the ratified status is refused rather than moved illegally. A
111
127
  # project that binds the ratified role to a workflow state turns this off, and
@@ -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)