throughline 3.18.2__tar.gz → 3.19.2__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-3.18.2/src/throughline.egg-info → throughline-3.19.2}/PKG-INFO +1 -1
- {throughline-3.18.2 → throughline-3.19.2}/pyproject.toml +1 -1
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/__init__.py +2 -2
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/apply.py +87 -47
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/dump.py +8 -1
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/fingerprint.py +14 -0
- {throughline-3.18.2 → throughline-3.19.2/src/throughline.egg-info}/PKG-INFO +1 -1
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_apply.py +113 -21
- {throughline-3.18.2 → throughline-3.19.2}/LICENSE +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/NOTICE +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/README.md +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/setup.cfg +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/brief.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/cli.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/collision.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/composition.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/diagrams.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/filters.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/graph.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/grounding.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/identity.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/inject.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/items.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/links.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/model.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/parser.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/ratification.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/resolvers.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/schema.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/schema_ops.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/seam.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/signed_links.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/sources.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/storage.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/tomledit.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/uid.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/union.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/validate.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/version.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/views.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline/worklist.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/SOURCES.txt +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_amend.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_baseline.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_clarify.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_deprecated_names.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_diagram_cli.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_doctor.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_engine.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_filters.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_flag.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_library_api.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_link_guard.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_normative_by_type.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_own_tree.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratification_change.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratification_withdrawal.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratified_content.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratifier_correction.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratify_several.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratify_status.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratify_transition_refused_first.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_register_type.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_resolve_collision.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_resolve_cross_graph.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_schema_ops.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_signed_links.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_spec_lists.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_tombstone_record.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_unset_attrs.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_worklist.py +0 -0
- {throughline-3.18.2 → throughline-3.19.2}/tests/test_yaml_loader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: throughline
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.19.2
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "throughline"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.19.2"
|
|
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"
|
|
@@ -30,7 +30,7 @@ from .composition import (
|
|
|
30
30
|
)
|
|
31
31
|
from .diagrams import diagram_transitions, diagram_types
|
|
32
32
|
from .dump import build_dump
|
|
33
|
-
from .fingerprint import fingerprint
|
|
33
|
+
from .fingerprint import fingerprint, revision
|
|
34
34
|
from .graph import Index
|
|
35
35
|
from .grounding import (
|
|
36
36
|
GroundingError,
|
|
@@ -179,7 +179,7 @@ __all__ = [
|
|
|
179
179
|
"ProjectError", "CONFIG_NAME", "MANIFEST_NAME",
|
|
180
180
|
# identity of an item
|
|
181
181
|
"UID_RE", "parse_uid", "format_uid", "next_uid", "collisions",
|
|
182
|
-
"Index", "fingerprint",
|
|
182
|
+
"Index", "fingerprint", "revision",
|
|
183
183
|
# the schema a project declares
|
|
184
184
|
"Schema", "AttrSpec", "LinkRule", "SchemaError",
|
|
185
185
|
# creating and changing an item
|
|
@@ -2,18 +2,23 @@
|
|
|
2
2
|
# SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
"""Carrying out a change set drafted where the Tool cannot run (SR-0252).
|
|
4
4
|
|
|
5
|
-
An assistant that
|
|
6
|
-
only text
|
|
7
|
-
|
|
8
|
-
and
|
|
9
|
-
|
|
10
|
-
it
|
|
11
|
-
|
|
5
|
+
An assistant or agent that cannot run the Tool — one that reads a capped number
|
|
6
|
+
of files and returns only text, as Microsoft 365 Copilot does in a controlled
|
|
7
|
+
environment — works from an export of the graph, built on `tl dump` and the
|
|
8
|
+
fingerprint and revision each item carries there (SR-0251). The export is the
|
|
9
|
+
business of whatever tool briefs that assistant (rhodium-org/throughline-ms-copilot
|
|
10
|
+
does it for Copilot); the assistant sends its changes back as a *change set*: one JSON
|
|
11
|
+
document listing the Tool's own operations — new, amend, link, unlink, delete —
|
|
12
|
+
each naming the item it touches and, for an item that already exists, quoting
|
|
13
|
+
the revision it was drafted against: the digest of its whole stored record that
|
|
14
|
+
the dump gives beside its fingerprint, so a change to any field since the export
|
|
15
|
+
refuses the set (format 2). A format-1 set quotes the fingerprint instead and is
|
|
16
|
+
still read, with the weaker check that implies.
|
|
12
17
|
|
|
13
18
|
The set is carried out all or nothing. Every operation is checked against the
|
|
14
19
|
graph as it stands before anything is written: a malformed operation, an
|
|
15
20
|
operation the Tool does not offer an assistant (a decision, or a change to the
|
|
16
|
-
structure), or a
|
|
21
|
+
structure), or a revision that is not the item's current one refuses the
|
|
17
22
|
whole set, naming every offending operation. Each operation then runs through
|
|
18
23
|
the same function the matching command runs — :func:`new_item`,
|
|
19
24
|
:func:`amend_item`, :func:`add_link`, :func:`remove_link`, :func:`delete_item`
|
|
@@ -33,7 +38,7 @@ from dataclasses import dataclass, field
|
|
|
33
38
|
from pathlib import Path
|
|
34
39
|
from typing import Callable
|
|
35
40
|
|
|
36
|
-
from .fingerprint import fingerprint
|
|
41
|
+
from .fingerprint import fingerprint, revision
|
|
37
42
|
from .grounding import GroundingError
|
|
38
43
|
from .items import amend_item, delete_item, item_type_for, new_item, parse_attrs
|
|
39
44
|
from .links import GroundingView, LinkError, add_link, remove_link
|
|
@@ -42,8 +47,16 @@ from .schema import SchemaError
|
|
|
42
47
|
from .storage import ProjectError, write_item
|
|
43
48
|
from .uid import UidError
|
|
44
49
|
|
|
45
|
-
#: The version of the change-set form this release
|
|
46
|
-
|
|
50
|
+
#: The version of the change-set form this release documents and writes about.
|
|
51
|
+
#: Format 2 quotes each existing item's ``revision``; format 1 quoted its
|
|
52
|
+
#: ``fingerprint``, which misses a change to the title, rationale, links, status
|
|
53
|
+
#: or a non-normative attribute, and is still read so a set already drafted
|
|
54
|
+
#: against it is not broken.
|
|
55
|
+
CHANGE_SET_FORMAT_VERSION = 2
|
|
56
|
+
|
|
57
|
+
#: The versions :func:`parse_change_set` reads, and the key each one quotes an
|
|
58
|
+
#: existing item's version under.
|
|
59
|
+
READABLE_FORMATS = {1: "fingerprint", 2: "revision"}
|
|
47
60
|
|
|
48
61
|
#: The operations an assistant may ask for, in the Tool's own vocabulary.
|
|
49
62
|
OPERATIONS = ("new", "amend", "link", "unlink", "delete")
|
|
@@ -54,39 +67,43 @@ _DECISIONS = ("ratify", "withdraw", "clarify", "status", "invalidate", "flag",
|
|
|
54
67
|
"review", "schema", "migrate", "resolve", "init", "register")
|
|
55
68
|
|
|
56
69
|
_LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
57
|
-
|
|
70
|
+
_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
58
71
|
|
|
59
|
-
# The keys each operation may carry, and the ones it must.
|
|
72
|
+
# The keys each operation may carry, and the ones it must. An operation on an
|
|
73
|
+
# existing item may also carry the version key of its format (READABLE_FORMATS).
|
|
60
74
|
_KEYS: dict[str, tuple[set[str], set[str]]] = {
|
|
61
75
|
"new": ({"register"},
|
|
62
76
|
{"op", "register", "type", "title", "text", "rationale", "attrs",
|
|
63
77
|
"links", "label"}),
|
|
64
78
|
"amend": ({"uid"},
|
|
65
|
-
{"op", "uid", "
|
|
79
|
+
{"op", "uid", "title", "text", "rationale", "attrs",
|
|
66
80
|
"unset"}),
|
|
67
81
|
"link": ({"uid", "target", "type"},
|
|
68
|
-
{"op", "uid", "
|
|
82
|
+
{"op", "uid", "target", "type", "stamp"}),
|
|
69
83
|
"unlink": ({"uid", "target"},
|
|
70
|
-
{"op", "uid", "
|
|
71
|
-
"delete": ({"uid"}, {"op", "uid", "
|
|
84
|
+
{"op", "uid", "target", "type"}),
|
|
85
|
+
"delete": ({"uid"}, {"op", "uid", "reason"}),
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
#: The change-set form, as the text handed to an assistant with the export.
|
|
75
|
-
#:
|
|
76
|
-
#: is told to write cannot drift from what
|
|
89
|
+
#: A tool that briefs an assistant takes it from here (`tl apply --describe`),
|
|
90
|
+
#: so what the assistant is told to write cannot drift from what
|
|
91
|
+
#: :func:`parse_change_set` accepts.
|
|
77
92
|
CHANGE_SET_FORMAT = f"""\
|
|
78
93
|
# A throughline change set — the form `tl apply` accepts (format {CHANGE_SET_FORMAT_VERSION})
|
|
79
94
|
|
|
80
95
|
A change set is one JSON document: a list of operations, carried out in order,
|
|
81
96
|
all or nothing. `tl apply` refuses the whole set, naming every offending
|
|
82
97
|
operation, when any operation is malformed, is not one of the five below, or
|
|
83
|
-
quotes a
|
|
98
|
+
quotes a revision that is not the item's current one. Nothing is written
|
|
84
99
|
until every operation has been checked, and a refused set changes nothing.
|
|
85
100
|
|
|
86
|
-
An item that already exists is named by its UID and carries the "
|
|
87
|
-
the export gave for it (the `
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
An item that already exists is named by its UID and carries the "revision"
|
|
102
|
+
the export gave for it (the `revision` field of the item in `tl dump`). Any
|
|
103
|
+
change to the item since the export — its text, title, rationale, links,
|
|
104
|
+
status or any attribute — refuses the set, so a change is never written over a
|
|
105
|
+
newer one. An item created earlier in the same set is named by its label,
|
|
106
|
+
written "$label", and carries no revision.
|
|
90
107
|
|
|
91
108
|
Everything the set creates or changes is recorded with origin "ai" and waits
|
|
92
109
|
as proposed: a person decides afterwards. Decisions — ratify, withdraw,
|
|
@@ -102,29 +119,29 @@ The five operations:
|
|
|
102
119
|
where the register declares one. "links" ground the item: a non-root item
|
|
103
120
|
must reach a root through them. "label" is optional.
|
|
104
121
|
|
|
105
|
-
{{"op": "amend", "uid": "SR-0012", "
|
|
122
|
+
{{"op": "amend", "uid": "SR-0012", "revision": "sha256:...", "title": "...",
|
|
106
123
|
"text": "...", "rationale": "...", "attrs": {{"priority": "must"}},
|
|
107
124
|
"unset": ["owner"]}}
|
|
108
125
|
changes only the fields given; an empty string clears a field. Only an
|
|
109
126
|
attribute the item's type declares may be set.
|
|
110
127
|
|
|
111
|
-
{{"op": "link", "uid": "SR-0012", "
|
|
128
|
+
{{"op": "link", "uid": "SR-0012", "revision": "sha256:...",
|
|
112
129
|
"target": "UR-0003", "type": "implements"}}
|
|
113
130
|
adds a typed link from the item to the target.
|
|
114
131
|
|
|
115
|
-
{{"op": "unlink", "uid": "SR-0012", "
|
|
132
|
+
{{"op": "unlink", "uid": "SR-0012", "revision": "sha256:...",
|
|
116
133
|
"target": "UR-0003", "type": "implements"}}
|
|
117
134
|
removes that link; "type" may be left out when one link joins them. Refused
|
|
118
135
|
where it would leave an item ungrounded.
|
|
119
136
|
|
|
120
|
-
{{"op": "delete", "uid": "SR-0012", "
|
|
137
|
+
{{"op": "delete", "uid": "SR-0012", "revision": "sha256:...",
|
|
121
138
|
"reason": "..."}}
|
|
122
139
|
tombstones the item: its file stays and its UID is never reused.
|
|
123
140
|
|
|
124
141
|
Rules: no other keys; a "uid", "target" or "label" is written exactly; every
|
|
125
142
|
value is a string except "attrs" (strings, numbers or booleans), "stamp"
|
|
126
143
|
(boolean), and "unset" and "links" (lists). The list may also be sent as
|
|
127
|
-
{{"change_set_format": {CHANGE_SET_FORMAT_VERSION}, "operations": [...]}}. Never send rewritten
|
|
144
|
+
{{"change_set_format": {CHANGE_SET_FORMAT_VERSION}, "operations": [...]}}, which is the safer form. Never send rewritten
|
|
128
145
|
item files: send this change set, and nothing else.
|
|
129
146
|
"""
|
|
130
147
|
|
|
@@ -172,24 +189,43 @@ def _scalar(value: object) -> bool:
|
|
|
172
189
|
or isinstance(value, bool)
|
|
173
190
|
|
|
174
191
|
|
|
192
|
+
def _inferred_format(data: list) -> int:
|
|
193
|
+
"""The format of a set that does not say: 1 only where its operations quote a
|
|
194
|
+
fingerprint and none quotes a revision, so a format-1 set drafted before
|
|
195
|
+
format 2 still reads as it was written. A set mixing the two is read as the
|
|
196
|
+
current format, whose rules then name each fingerprint as an unknown key."""
|
|
197
|
+
ops = [op for op in data if isinstance(op, dict)]
|
|
198
|
+
if any("fingerprint" in op for op in ops) and not any("revision" in op for op in ops):
|
|
199
|
+
return 1
|
|
200
|
+
return CHANGE_SET_FORMAT_VERSION
|
|
201
|
+
|
|
202
|
+
|
|
175
203
|
def parse_change_set(text: str) -> list[dict]:
|
|
176
204
|
"""Read a change set from its JSON text, refusing it whole when any operation
|
|
177
|
-
is malformed or is not one an assistant may take (SR-0252).
|
|
205
|
+
is malformed or is not one an assistant may take (SR-0252). A set that names
|
|
206
|
+
its ``change_set_format`` is read in that format; one that does not is read
|
|
207
|
+
as the current format unless it quotes only fingerprints. The operations
|
|
178
208
|
come back as given, checked for shape; whether each can be carried out is
|
|
179
209
|
:func:`apply_change_set`'s to say."""
|
|
180
210
|
try:
|
|
181
211
|
data = json.loads(text)
|
|
182
212
|
except json.JSONDecodeError as e:
|
|
183
213
|
raise ChangeSetError([f"the change set is not JSON: {e}"]) from None
|
|
214
|
+
version = None
|
|
184
215
|
if isinstance(data, dict) and "operations" in data:
|
|
185
|
-
version = data.get("change_set_format"
|
|
186
|
-
if version
|
|
216
|
+
version = data.get("change_set_format")
|
|
217
|
+
if version is not None and (isinstance(version, bool)
|
|
218
|
+
or version not in READABLE_FORMATS):
|
|
187
219
|
raise ChangeSetError([f"change_set_format {version!r} is not one this "
|
|
188
|
-
|
|
220
|
+
"release reads ("
|
|
221
|
+
+ ", ".join(map(str, READABLE_FORMATS)) + ")"])
|
|
189
222
|
data = data["operations"]
|
|
190
223
|
if not isinstance(data, list):
|
|
191
224
|
raise ChangeSetError(["the change set must be a JSON list of operations "
|
|
192
225
|
"(or an object whose \"operations\" is that list)"])
|
|
226
|
+
if version is None:
|
|
227
|
+
version = _inferred_format(data)
|
|
228
|
+
quoted = READABLE_FORMATS[version]
|
|
193
229
|
problems: list[str] = []
|
|
194
230
|
ops: list[dict] = []
|
|
195
231
|
labels: set[str] = set()
|
|
@@ -209,6 +245,8 @@ def parse_change_set(text: str) -> list[dict]:
|
|
|
209
245
|
f"{', '.join(OPERATIONS)}, got {kind!r}")
|
|
210
246
|
continue
|
|
211
247
|
required, allowed = _KEYS[kind]
|
|
248
|
+
if kind != "new":
|
|
249
|
+
allowed = allowed | {quoted}
|
|
212
250
|
name = f"{where} {kind}"
|
|
213
251
|
extra = sorted(set(op) - allowed)
|
|
214
252
|
if extra:
|
|
@@ -217,7 +255,7 @@ def parse_change_set(text: str) -> list[dict]:
|
|
|
217
255
|
if missing:
|
|
218
256
|
problems.append(f"{name}: missing {', '.join(missing)}")
|
|
219
257
|
for key in ("uid", "target", "register", "type", "title", "text",
|
|
220
|
-
"rationale", "label", "reason",
|
|
258
|
+
"rationale", "label", "reason", quoted):
|
|
221
259
|
if key in op and not isinstance(op[key], str):
|
|
222
260
|
problems.append(f"{name}: \"{key}\" must be a string")
|
|
223
261
|
if "stamp" in op and not isinstance(op["stamp"], bool):
|
|
@@ -245,12 +283,11 @@ def parse_change_set(text: str) -> list[dict]:
|
|
|
245
283
|
if uid[1:] not in labels:
|
|
246
284
|
problems.append(f"{name}: {uid} names no item created earlier "
|
|
247
285
|
"in this set")
|
|
248
|
-
elif
|
|
286
|
+
elif quoted not in op:
|
|
249
287
|
problems.append(f"{name} {uid}: an operation on an existing item "
|
|
250
|
-
"carries the \"
|
|
251
|
-
elif isinstance(op[
|
|
252
|
-
|
|
253
|
-
problems.append(f"{name} {uid}: \"fingerprint\" must be the sha256:… "
|
|
288
|
+
f"carries the \"{quoted}\" it was based on")
|
|
289
|
+
elif isinstance(op[quoted], str) and not _DIGEST_RE.match(op[quoted]):
|
|
290
|
+
problems.append(f"{name} {uid}: \"{quoted}\" must be the sha256:… "
|
|
254
291
|
"value the export gave")
|
|
255
292
|
if kind == "new":
|
|
256
293
|
label = op.get("label")
|
|
@@ -309,10 +346,10 @@ def apply_change_set(project, operations: list[dict], *,
|
|
|
309
346
|
``project``, all or nothing (SR-0252).
|
|
310
347
|
|
|
311
348
|
Every operation is first checked against the graph as it stands: the item it
|
|
312
|
-
names exists and is not deleted, the
|
|
313
|
-
current one, and what it links to can be
|
|
314
|
-
the whole set before a file is written.
|
|
315
|
-
function the matching command runs, and a refusal from any of them restores
|
|
349
|
+
names exists and is not deleted, the revision it quotes (or, in a format-1
|
|
350
|
+
set, the fingerprint) is the item's current one, and what it links to can be
|
|
351
|
+
found. A problem anywhere refuses the whole set before a file is written.
|
|
352
|
+
Then each operation runs through the function the matching command runs, and a refusal from any of them restores
|
|
316
353
|
every file and refuses the set the same way. ``view`` and ``find`` let a
|
|
317
354
|
program that grounds over a wider graph than the one it writes — a union with
|
|
318
355
|
its sources — judge links and find targets there (SR-0212); left out, they are
|
|
@@ -334,10 +371,13 @@ def apply_change_set(project, operations: list[dict], *,
|
|
|
334
371
|
problems.append(f"{name}: {uid} does not exist")
|
|
335
372
|
elif item.is_deleted:
|
|
336
373
|
problems.append(f"{name}: {uid} is deleted — a tombstone is permanent")
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
374
|
+
else:
|
|
375
|
+
key = "revision" if "revision" in op else "fingerprint"
|
|
376
|
+
now = revision(item) if key == "revision" else fingerprint(item, schema)
|
|
377
|
+
if op.get(key) != now:
|
|
378
|
+
problems.append(f"{name}: {uid} changed since the export — the set "
|
|
379
|
+
f"quotes {key} {op.get(key)} but the item now "
|
|
380
|
+
f"carries {now}")
|
|
341
381
|
return item
|
|
342
382
|
|
|
343
383
|
def target_known(name: str, ref: str) -> None:
|
|
@@ -14,10 +14,16 @@ content as it stands (SR-0251), under this documented key, so a reader working
|
|
|
14
14
|
from the dump can quote back which version of an item it read — what a change
|
|
15
15
|
set hands to ``tl apply`` (SR-0252). It is computed here by the one fingerprint
|
|
16
16
|
rule and nowhere else, so a tool built on the dump never hashes an item itself.
|
|
17
|
+
|
|
18
|
+
Each item also carries ``revision``: a digest of its whole stored record, so it
|
|
19
|
+
moves on any change at all — a title, a rationale, a link, a status — where the
|
|
20
|
+
fingerprint, which ratification is taken over, moves only on normative content.
|
|
21
|
+
A change set quotes the revision (SR-0252), so an operation drafted against an
|
|
22
|
+
item that has since changed in any way is refused rather than written over it.
|
|
17
23
|
"""
|
|
18
24
|
from __future__ import annotations
|
|
19
25
|
|
|
20
|
-
from .fingerprint import fingerprint
|
|
26
|
+
from .fingerprint import fingerprint, revision
|
|
21
27
|
from .model import Project
|
|
22
28
|
from .storage import FORMAT_VERSION
|
|
23
29
|
|
|
@@ -43,6 +49,7 @@ def build_dump(project: Project, tool_version: str) -> dict:
|
|
|
43
49
|
def entry(it) -> dict:
|
|
44
50
|
d = it.to_dict()
|
|
45
51
|
d["fingerprint"] = fingerprint(it, schema)
|
|
52
|
+
d["revision"] = revision(it)
|
|
46
53
|
return d
|
|
47
54
|
|
|
48
55
|
return {
|
|
@@ -24,6 +24,7 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
import copy
|
|
26
26
|
import hashlib
|
|
27
|
+
import json
|
|
27
28
|
import unicodedata
|
|
28
29
|
|
|
29
30
|
from .schema import Schema
|
|
@@ -119,3 +120,16 @@ def content_fingerprint(uid: str, content: object) -> str | None:
|
|
|
119
120
|
return None
|
|
120
121
|
return _digest(uid, content["type"], content["text"], content["normative"],
|
|
121
122
|
content["derived"], list(attrs.items()))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def revision(item) -> str:
|
|
126
|
+
"""The revision of an item's whole stored record (SR-0251): SHA-256 over the
|
|
127
|
+
record the item's file holds — every field an operation reads or writes,
|
|
128
|
+
title, rationale, status, links and every attribute included — in canonical
|
|
129
|
+
JSON. Unlike :func:`fingerprint`, which a ratification is taken over and so
|
|
130
|
+
reads only normative content, any change to the item moves its revision, so a
|
|
131
|
+
change set quoting it is refused when anything about the item has moved since
|
|
132
|
+
the export it was drafted from (SR-0252)."""
|
|
133
|
+
record = json.dumps(item.to_dict(), sort_keys=True, ensure_ascii=False,
|
|
134
|
+
separators=(",", ":"), default=str)
|
|
135
|
+
return "sha256:" + hashlib.sha256(record.encode("utf-8")).hexdigest()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: throughline
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.19.2
|
|
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
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Copyright (c) 2026 Henry J Grech-Cini
|
|
2
2
|
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
"""`tl dump` states each item's fingerprint (SR-0251) and `tl apply`
|
|
4
|
-
change set of the Tool's own operations, all or nothing (SR-0252).
|
|
3
|
+
"""`tl dump` states each item's fingerprint and revision (SR-0251) and `tl apply`
|
|
4
|
+
carries out a change set of the Tool's own operations, all or nothing (SR-0252).
|
|
5
5
|
|
|
6
6
|
An assistant that reads a few files, cannot run commands and returns only text
|
|
7
|
-
works from an export and sends back a change set quoting the
|
|
8
|
-
|
|
7
|
+
works from an export and sends back a change set quoting the revision each item
|
|
8
|
+
carried. These tests hold the two halves to their requirements: the dump
|
|
9
9
|
carries the fingerprint the Tool computes, and apply refuses a set whole — on a
|
|
10
|
-
stale
|
|
10
|
+
stale revision, a malformed operation, an operation an assistant may not take,
|
|
11
11
|
or a refusal from the operation itself — changing nothing, and otherwise carries
|
|
12
12
|
out every operation through the matching command's own function, recording what
|
|
13
13
|
it creates or changes with origin ai.
|
|
@@ -21,7 +21,7 @@ from throughline import apply_change_set, build_dump, parse_change_set
|
|
|
21
21
|
from throughline.apply import ChangeSetError
|
|
22
22
|
from throughline.cli import main as _cli
|
|
23
23
|
from throughline.dump import DUMP_SCHEMA_VERSION
|
|
24
|
-
from throughline.fingerprint import fingerprint
|
|
24
|
+
from throughline.fingerprint import fingerprint, revision
|
|
25
25
|
from throughline.storage import load_project
|
|
26
26
|
|
|
27
27
|
|
|
@@ -50,6 +50,12 @@ def _fp(root: Path, uid: str) -> str:
|
|
|
50
50
|
return next(i["fingerprint"] for i in dump["items"] if i["uid"] == uid)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _rev(root: Path, uid: str) -> str:
|
|
54
|
+
"""The revision the export gives, read from the dump as an assistant would."""
|
|
55
|
+
dump = build_dump(load_project(root), "9.9.9")
|
|
56
|
+
return next(i["revision"] for i in dump["items"] if i["uid"] == uid)
|
|
57
|
+
|
|
58
|
+
|
|
53
59
|
def _tree(root: Path) -> dict[str, bytes]:
|
|
54
60
|
return {p.relative_to(root).as_posix(): p.read_bytes()
|
|
55
61
|
for p in root.rglob("*") if p.is_file()}
|
|
@@ -66,7 +72,7 @@ def _sound(root: Path) -> list[dict]:
|
|
|
66
72
|
{"op": "new", "register": "FR", "type": "requirement", "title": "a second",
|
|
67
73
|
"text": "The Tool shall do a second thing.", "rationale": "because",
|
|
68
74
|
"links": [{"target": "INT-0001", "type": "derives_from"}], "label": "fr2"},
|
|
69
|
-
{"op": "amend", "uid": "FR-0001", "
|
|
75
|
+
{"op": "amend", "uid": "FR-0001", "revision": _rev(root, "FR-0001"),
|
|
70
76
|
"text": "rewritten by an assistant"},
|
|
71
77
|
{"op": "link", "uid": "$fr2", "target": "FR-0001", "type": "derives_from"},
|
|
72
78
|
{"op": "unlink", "uid": "$fr2", "target": "FR-0001", "type": "derives_from"},
|
|
@@ -88,12 +94,25 @@ def test_the_dump_states_each_items_fingerprint_under_its_documented_key(tmp_pat
|
|
|
88
94
|
project.schema)
|
|
89
95
|
|
|
90
96
|
|
|
97
|
+
def test_the_dump_states_each_items_revision_beside_its_fingerprint(tmp_path):
|
|
98
|
+
root = _scaffold(tmp_path)
|
|
99
|
+
project = load_project(root)
|
|
100
|
+
for entry in build_dump(project, "9.9.9")["items"]:
|
|
101
|
+
assert entry["revision"] == revision(project.get(entry["uid"]))
|
|
102
|
+
before = _rev(root, "FR-0001"), _fp(root, "FR-0001")
|
|
103
|
+
assert _cli(["-C", str(root), "amend", "FR-0001", "--title", "moved"]) == 0
|
|
104
|
+
# a title is not normative: the fingerprint stands and the revision moves
|
|
105
|
+
assert _fp(root, "FR-0001") == before[1]
|
|
106
|
+
assert _rev(root, "FR-0001") != before[0]
|
|
107
|
+
|
|
108
|
+
|
|
91
109
|
def test_the_command_line_dump_carries_it_too(tmp_path, capsys):
|
|
92
110
|
root = _scaffold(tmp_path)
|
|
93
111
|
capsys.readouterr() # the scaffold's own output
|
|
94
112
|
assert _cli(["-C", str(root), "dump"]) == 0
|
|
95
113
|
dump = json.loads(capsys.readouterr().out)
|
|
96
114
|
assert all(i["fingerprint"].startswith("sha256:") for i in dump["items"])
|
|
115
|
+
assert all(i["revision"].startswith("sha256:") for i in dump["items"])
|
|
97
116
|
|
|
98
117
|
|
|
99
118
|
# --------------------------------------------------------------------------- #
|
|
@@ -105,7 +124,9 @@ def test_describe_prints_the_form_an_assistant_is_given(tmp_path, capsys):
|
|
|
105
124
|
out = capsys.readouterr().out
|
|
106
125
|
for op in ("new", "amend", "link", "unlink", "delete"):
|
|
107
126
|
assert f'"op": "{op}"' in out
|
|
108
|
-
assert "
|
|
127
|
+
assert '"revision": "sha256:...' in out and "all or nothing" in out
|
|
128
|
+
# what an assistant is told to write is format 2 only: no fingerprint key
|
|
129
|
+
assert '"fingerprint"' not in out
|
|
109
130
|
|
|
110
131
|
|
|
111
132
|
def test_a_sound_set_is_carried_out_in_order_and_everything_is_proposed_origin_ai(
|
|
@@ -129,13 +150,13 @@ def test_a_sound_set_is_carried_out_in_order_and_everything_is_proposed_origin_a
|
|
|
129
150
|
assert first.title == "original title"
|
|
130
151
|
|
|
131
152
|
|
|
132
|
-
def
|
|
153
|
+
def test_a_stale_revision_refuses_the_whole_set_and_names_the_operation(
|
|
133
154
|
tmp_path, capsys):
|
|
134
155
|
root = _scaffold(tmp_path)
|
|
135
156
|
before = _tree(root)
|
|
136
157
|
stale = "sha256:" + "0" * 64
|
|
137
158
|
ops = _sound(root)
|
|
138
|
-
ops[1]["
|
|
159
|
+
ops[1]["revision"] = stale
|
|
139
160
|
path = _set(root, ops)
|
|
140
161
|
assert _cli(["-C", str(root), "apply", str(path)]) == 1
|
|
141
162
|
out = capsys.readouterr().out
|
|
@@ -146,10 +167,10 @@ def test_a_stale_fingerprint_refuses_the_whole_set_and_names_the_operation(
|
|
|
146
167
|
def test_a_malformed_set_names_every_offending_operation(tmp_path, capsys):
|
|
147
168
|
root = _scaffold(tmp_path)
|
|
148
169
|
before = _tree(root)
|
|
149
|
-
|
|
170
|
+
rev = _rev(root, "FR-0001")
|
|
150
171
|
path = _set(root, [
|
|
151
|
-
{"op": "amend", "
|
|
152
|
-
{"op": "link", "uid": "FR-0001", "
|
|
172
|
+
{"op": "amend", "revision": rev, "text": "no uid"},
|
|
173
|
+
{"op": "link", "uid": "FR-0001", "revision": rev, "target": "INT-0001",
|
|
153
174
|
"type": "derives_from", "colour": "red"},
|
|
154
175
|
{"op": "ratify", "uid": "FR-0001"},
|
|
155
176
|
{"op": "delete", "uid": "FR-0001"},
|
|
@@ -182,10 +203,10 @@ def test_a_refusal_from_an_operation_restores_every_file(tmp_path, capsys):
|
|
|
182
203
|
refused and #1 is undone."""
|
|
183
204
|
root = _scaffold(tmp_path)
|
|
184
205
|
before = _tree(root)
|
|
185
|
-
|
|
206
|
+
rev = _rev(root, "FR-0001")
|
|
186
207
|
path = _set(root, [
|
|
187
|
-
{"op": "amend", "uid": "FR-0001", "
|
|
188
|
-
{"op": "unlink", "uid": "FR-0001", "
|
|
208
|
+
{"op": "amend", "uid": "FR-0001", "revision": rev, "text": "changed"},
|
|
209
|
+
{"op": "unlink", "uid": "FR-0001", "revision": rev, "target": "INT-0001"},
|
|
189
210
|
])
|
|
190
211
|
assert _cli(["-C", str(root), "apply", str(path)]) == 1
|
|
191
212
|
out = capsys.readouterr().out
|
|
@@ -215,7 +236,7 @@ def test_json_output_reports_each_operation_and_the_files(tmp_path, capsys):
|
|
|
215
236
|
assert report["created"] == ["FR-0002"] and report["changed"] == ["FR-0001"]
|
|
216
237
|
assert sorted(report["files"]) == ["features/FR-0001.yml", "features/FR-0002.yml"]
|
|
217
238
|
# a refusal is reported the same way
|
|
218
|
-
path = _set(root, [{"op": "delete", "uid": "FR-0009", "
|
|
239
|
+
path = _set(root, [{"op": "delete", "uid": "FR-0009", "revision": "sha256:" + "a" * 64}])
|
|
219
240
|
assert _cli(["-C", str(root), "apply", str(path), "--format", "json"]) == 1
|
|
220
241
|
refusal = json.loads(capsys.readouterr().out)
|
|
221
242
|
assert refusal["applied"] is False and "FR-0009 does not exist" in refusal["problems"][0]
|
|
@@ -223,21 +244,21 @@ def test_json_output_reports_each_operation_and_the_files(tmp_path, capsys):
|
|
|
223
244
|
|
|
224
245
|
def test_a_delete_tombstones_and_an_operation_on_a_tombstone_is_refused(tmp_path):
|
|
225
246
|
root = _scaffold(tmp_path)
|
|
226
|
-
|
|
227
|
-
path = _set(root, [{"op": "delete", "uid": "FR-0001", "
|
|
247
|
+
rev = _rev(root, "FR-0001")
|
|
248
|
+
path = _set(root, [{"op": "delete", "uid": "FR-0001", "revision": rev,
|
|
228
249
|
"reason": "withdrawn by the assistant"}])
|
|
229
250
|
assert _cli(["-C", str(root), "apply", str(path)]) == 0
|
|
230
251
|
gone = _item(root, "FR-0001")
|
|
231
252
|
assert gone.is_deleted and gone.deleted["reason"] == "withdrawn by the assistant"
|
|
232
253
|
assert gone.attrs["origin"] == "ai"
|
|
233
|
-
path = _set(root, [{"op": "amend", "uid": "FR-0001", "
|
|
254
|
+
path = _set(root, [{"op": "amend", "uid": "FR-0001", "revision": rev,
|
|
234
255
|
"text": "back from the dead"}])
|
|
235
256
|
assert _cli(["-C", str(root), "apply", str(path)]) == 1
|
|
236
257
|
|
|
237
258
|
|
|
238
259
|
def test_the_wrapped_form_is_accepted_and_the_library_needs_no_command_line(tmp_path):
|
|
239
260
|
root = _scaffold(tmp_path)
|
|
240
|
-
text = json.dumps({"change_set_format":
|
|
261
|
+
text = json.dumps({"change_set_format": 2, "operations": _sound(root)})
|
|
241
262
|
operations = parse_change_set(text)
|
|
242
263
|
project = load_project(root)
|
|
243
264
|
result = apply_change_set(project, operations)
|
|
@@ -251,3 +272,74 @@ def test_the_wrapped_form_is_accepted_and_the_library_needs_no_command_line(tmp_
|
|
|
251
272
|
assert any("FR-0001 changed since the export" in p for p in e.problems)
|
|
252
273
|
else:
|
|
253
274
|
raise AssertionError("a stale set was applied")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --------------------------------------------------------------------------- #
|
|
278
|
+
# SR-0252 — a change to any field since the export refuses the set
|
|
279
|
+
# --------------------------------------------------------------------------- #
|
|
280
|
+
|
|
281
|
+
import pytest # noqa: E402
|
|
282
|
+
|
|
283
|
+
_EDITS = {
|
|
284
|
+
"title": ["amend", "FR-0001", "--title", "moved by someone else"],
|
|
285
|
+
"rationale": ["amend", "FR-0001", "--rationale", "a newer reason"],
|
|
286
|
+
"attribute": ["amend", "FR-0001", "--attr", "owner=someone"],
|
|
287
|
+
"link": ["link", "FR-0001", "FR-0002", "--type", "relates"],
|
|
288
|
+
"status": ["status", "FR-0001", "deferred"],
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@pytest.mark.parametrize("edit", sorted(_EDITS))
|
|
293
|
+
def test_an_edit_after_the_export_that_moves_no_fingerprint_still_refuses_the_set(
|
|
294
|
+
tmp_path, capsys, edit):
|
|
295
|
+
root = _scaffold(tmp_path)
|
|
296
|
+
assert _cli(["-C", str(root), "new", "FR", "--type", "requirement",
|
|
297
|
+
"--title", "other", "--text", "other", "--ground", "INT-0001"]) == 0
|
|
298
|
+
if edit == "attribute":
|
|
299
|
+
assert _cli(["-C", str(root), "schema", "attr", "add", "requirement",
|
|
300
|
+
"owner", "--kind", "string", "--because", "a test"]) == 0
|
|
301
|
+
exported = _rev(root, "FR-0001"), _fp(root, "FR-0001")
|
|
302
|
+
assert _cli(["-C", str(root), *_EDITS[edit]]) == 0
|
|
303
|
+
assert _fp(root, "FR-0001") == exported[1], "the edit is not normative"
|
|
304
|
+
before = _tree(root)
|
|
305
|
+
path = _set(root, [{"op": "amend", "uid": "FR-0001", "revision": exported[0],
|
|
306
|
+
"title": "the assistant's title"}])
|
|
307
|
+
capsys.readouterr()
|
|
308
|
+
assert _cli(["-C", str(root), "apply", str(path)]) == 1
|
|
309
|
+
out = capsys.readouterr().out
|
|
310
|
+
assert "#1 amend: FR-0001 changed since the export" in out
|
|
311
|
+
assert _tree(root) == before
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_a_format_1_set_quoting_fingerprints_is_still_read(tmp_path):
|
|
315
|
+
root = _scaffold(tmp_path)
|
|
316
|
+
fp = _fp(root, "FR-0001")
|
|
317
|
+
for text in (json.dumps([{"op": "amend", "uid": "FR-0001", "fingerprint": fp,
|
|
318
|
+
"title": "bare list"}]),
|
|
319
|
+
json.dumps({"change_set_format": 1, "operations": [
|
|
320
|
+
{"op": "amend", "uid": "FR-0001", "fingerprint": fp,
|
|
321
|
+
"title": "wrapped"}]})):
|
|
322
|
+
result = apply_change_set(load_project(root), parse_change_set(text))
|
|
323
|
+
assert result.changed == ["FR-0001"]
|
|
324
|
+
assert _item(root, "FR-0001").title == "wrapped"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_format_2_takes_only_the_revision_and_format_1_only_the_fingerprint(tmp_path):
|
|
328
|
+
root = _scaffold(tmp_path)
|
|
329
|
+
fp, rev = _fp(root, "FR-0001"), _rev(root, "FR-0001")
|
|
330
|
+
cases = [
|
|
331
|
+
({"change_set_format": 2, "operations": [
|
|
332
|
+
{"op": "amend", "uid": "FR-0001", "fingerprint": fp, "title": "x"}]},
|
|
333
|
+
"unknown key(s) fingerprint"),
|
|
334
|
+
({"change_set_format": 1, "operations": [
|
|
335
|
+
{"op": "amend", "uid": "FR-0001", "revision": rev, "title": "x"}]},
|
|
336
|
+
"unknown key(s) revision"),
|
|
337
|
+
([{"op": "amend", "uid": "FR-0001", "revision": rev, "title": "x"},
|
|
338
|
+
{"op": "amend", "uid": "FR-0001", "fingerprint": fp, "title": "y"}],
|
|
339
|
+
"#2 amend: unknown key(s) fingerprint"),
|
|
340
|
+
({"change_set_format": 3, "operations": []}, "is not one this release reads"),
|
|
341
|
+
]
|
|
342
|
+
for data, expected in cases:
|
|
343
|
+
with pytest.raises(ChangeSetError) as e:
|
|
344
|
+
parse_change_set(json.dumps(data))
|
|
345
|
+
assert any(expected in p for p in e.value.problems), (expected, e.value.problems)
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|