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.
- {throughline-2.2.0/src/throughline.egg-info → throughline-2.3.0}/PKG-INFO +3 -3
- {throughline-2.2.0 → throughline-2.3.0}/README.md +2 -2
- {throughline-2.2.0 → throughline-2.3.0}/pyproject.toml +1 -1
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/cli.py +126 -7
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/filters.py +86 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/grounding.py +35 -19
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/schema.py +48 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/schema_ops.py +52 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/storage.py +15 -4
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/tomledit.py +46 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/validate.py +51 -26
- {throughline-2.2.0 → throughline-2.3.0/src/throughline.egg-info}/PKG-INFO +3 -3
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/SOURCES.txt +2 -1
- {throughline-2.2.0 → throughline-2.3.0}/tests/test_doctor.py +114 -0
- {throughline-2.2.0 → throughline-2.3.0}/tests/test_engine.py +203 -2
- {throughline-2.2.0 → throughline-2.3.0}/tests/test_filters.py +62 -2
- {throughline-2.2.0 → throughline-2.3.0}/tests/test_schema_ops.py +86 -0
- throughline-2.3.0/tests/test_yaml_loader.py +52 -0
- {throughline-2.2.0 → throughline-2.3.0}/LICENSE +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/NOTICE +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/setup.cfg +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/__init__.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/dump.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/fingerprint.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/graph.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/identity.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/inject.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/model.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/uid.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline/version.py +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-2.2.0 → throughline-2.3.0}/tests/test_amend.py +0 -0
- {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.
|
|
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
|
-
|
|
39
|
+
161
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
|
-
|
|
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
|
-
|
|
11
|
+
161
|
|
12
12
|
<!-- tl:end --> system requirements,
|
|
13
13
|
<!-- tl:count type == 'user_requirement' -->
|
|
14
|
-
|
|
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.
|
|
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)
|
|
1290
|
-
|
|
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",
|
|
1294
|
-
out.append(f"
|
|
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,
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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)
|