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.
- {throughline-2.2.0/src/throughline.egg-info → throughline-2.2.1}/PKG-INFO +2 -2
- {throughline-2.2.0 → throughline-2.2.1}/README.md +1 -1
- {throughline-2.2.0 → throughline-2.2.1}/pyproject.toml +1 -1
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/cli.py +25 -6
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/filters.py +86 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/schema.py +48 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/schema_ops.py +52 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/storage.py +15 -4
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/tomledit.py +46 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/validate.py +34 -14
- {throughline-2.2.0 → throughline-2.2.1/src/throughline.egg-info}/PKG-INFO +2 -2
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/SOURCES.txt +2 -1
- {throughline-2.2.0 → throughline-2.2.1}/tests/test_engine.py +52 -0
- {throughline-2.2.0 → throughline-2.2.1}/tests/test_filters.py +62 -2
- {throughline-2.2.0 → throughline-2.2.1}/tests/test_schema_ops.py +86 -0
- throughline-2.2.1/tests/test_yaml_loader.py +52 -0
- {throughline-2.2.0 → throughline-2.2.1}/LICENSE +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/NOTICE +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/setup.cfg +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/__init__.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/dump.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/fingerprint.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/graph.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/grounding.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/identity.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/inject.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/model.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/uid.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline/version.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/tests/test_amend.py +0 -0
- {throughline-2.2.0 → throughline-2.2.1}/tests/test_doctor.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
1290
|
-
|
|
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",
|
|
1294
|
-
out.append(f"
|
|
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 =
|
|
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)
|
|
@@ -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
|
-
|
|
359
|
+
cfg_file = str(project.path / CONFIG_NAME)
|
|
360
|
+
for pos, rule in enumerate(schema.coverage):
|
|
356
361
|
needs = rule.get("needs", "")
|
|
357
|
-
m =
|
|
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
|
|
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.
|
|
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
|
-
|
|
39
|
+
159
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
42
|
25
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|