archledger 0.1.0__py3-none-any.whl
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.
- archledger/__init__.py +8 -0
- archledger/_version.py +24 -0
- archledger/assembly.py +236 -0
- archledger/checks.py +404 -0
- archledger/cli.py +929 -0
- archledger/cli_formatting.py +257 -0
- archledger/cli_payloads.py +408 -0
- archledger/config/__init__.py +32 -0
- archledger/config/model.py +233 -0
- archledger/config/parse.py +662 -0
- archledger/config/render.py +238 -0
- archledger/conversion_plan.py +273 -0
- archledger/converters.py +165 -0
- archledger/diagrams.py +217 -0
- archledger/dialects.py +105 -0
- archledger/errors.py +34 -0
- archledger/formats.py +136 -0
- archledger/ids.py +38 -0
- archledger/launcher.py +7 -0
- archledger/migration.py +176 -0
- archledger/model.py +339 -0
- archledger/py.typed +0 -0
- archledger/record_types.py +333 -0
- archledger/render.py +72 -0
- archledger/repository.py +1054 -0
- archledger/section_rendering.py +389 -0
- archledger/source_refs.py +197 -0
- archledger/source_tracking.py +482 -0
- archledger/storage/__init__.py +1 -0
- archledger/storage/common.py +50 -0
- archledger/storage/frontmatter.py +81 -0
- archledger/storage/meta.py +130 -0
- archledger/storage/paths.py +151 -0
- archledger/storage/project_config.py +33 -0
- archledger/storage/source_state.py +149 -0
- archledger/templates/__init__.py +1 -0
- archledger/templates/arc42_document.adoc.j2 +124 -0
- archledger/templates/arc42_document.md.j2 +120 -0
- archledger/templates/records/adr.adoc.j2 +37 -0
- archledger/templates/records/adr.md.j2 +33 -0
- archledger/templates/records/black_box.adoc.j2 +22 -0
- archledger/templates/records/black_box.md.j2 +22 -0
- archledger/templates/records/concept.adoc.j2 +16 -0
- archledger/templates/records/concept.md.j2 +16 -0
- archledger/templates/records/constraint.adoc.j2 +17 -0
- archledger/templates/records/constraint.md.j2 +17 -0
- archledger/templates/records/context_interface.adoc.j2 +20 -0
- archledger/templates/records/context_interface.md.j2 +20 -0
- archledger/templates/records/diagram.adoc.j2 +46 -0
- archledger/templates/records/diagram.md.j2 +43 -0
- archledger/templates/records/glossary_term.adoc.j2 +17 -0
- archledger/templates/records/glossary_term.md.j2 +17 -0
- archledger/templates/records/infrastructure.adoc.j2 +17 -0
- archledger/templates/records/infrastructure.md.j2 +19 -0
- archledger/templates/records/interface.adoc.j2 +18 -0
- archledger/templates/records/interface.md.j2 +19 -0
- archledger/templates/records/quality_goal.adoc.j2 +17 -0
- archledger/templates/records/quality_goal.md.j2 +17 -0
- archledger/templates/records/quality_requirement.adoc.j2 +27 -0
- archledger/templates/records/quality_requirement.md.j2 +25 -0
- archledger/templates/records/quality_scenario.adoc.j2 +22 -0
- archledger/templates/records/quality_scenario.md.j2 +22 -0
- archledger/templates/records/requirement.adoc.j2 +27 -0
- archledger/templates/records/requirement.md.j2 +25 -0
- archledger/templates/records/risk.adoc.j2 +18 -0
- archledger/templates/records/risk.md.j2 +18 -0
- archledger/templates/records/runtime_scenario.adoc.j2 +18 -0
- archledger/templates/records/runtime_scenario.md.j2 +18 -0
- archledger/templates/records/stakeholder.adoc.j2 +17 -0
- archledger/templates/records/stakeholder.md.j2 +17 -0
- archledger/templates/records/strategy_item.adoc.j2 +26 -0
- archledger/templates/records/strategy_item.md.j2 +24 -0
- archledger/templates/records/white_box.adoc.j2 +33 -0
- archledger/templates/records/white_box.md.j2 +30 -0
- archledger-0.1.0.dist-info/METADATA +473 -0
- archledger-0.1.0.dist-info/RECORD +80 -0
- archledger-0.1.0.dist-info/WHEEL +5 -0
- archledger-0.1.0.dist-info/entry_points.txt +2 -0
- archledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- archledger-0.1.0.dist-info/top_level.txt +1 -0
archledger/__init__.py
ADDED
archledger/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
archledger/assembly.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
9
|
+
|
|
10
|
+
from archledger import __version__
|
|
11
|
+
from archledger.dialects import get_dialect
|
|
12
|
+
from archledger.errors import RenderError
|
|
13
|
+
from archledger.model import (
|
|
14
|
+
ArchitectureRecord,
|
|
15
|
+
default_document_filename_for_output_format,
|
|
16
|
+
document_template_name_for_source_format,
|
|
17
|
+
is_visible_status,
|
|
18
|
+
native_output_format_for_source_format,
|
|
19
|
+
)
|
|
20
|
+
from archledger.repository import ArchitectureRepository
|
|
21
|
+
from archledger.section_rendering import (
|
|
22
|
+
adr_sections,
|
|
23
|
+
building_block_hierarchy,
|
|
24
|
+
concepts,
|
|
25
|
+
constraints_list,
|
|
26
|
+
context_interfaces,
|
|
27
|
+
deployment_view,
|
|
28
|
+
glossary_table,
|
|
29
|
+
quality_goals_table,
|
|
30
|
+
quality_requirements_overview,
|
|
31
|
+
quality_scenarios,
|
|
32
|
+
requirements_overview,
|
|
33
|
+
risk_table,
|
|
34
|
+
runtime_scenarios,
|
|
35
|
+
section_body,
|
|
36
|
+
section_diagrams,
|
|
37
|
+
solution_strategy_items,
|
|
38
|
+
stakeholders_table,
|
|
39
|
+
)
|
|
40
|
+
from archledger.storage.common import utc_now_iso, write_text
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class AssemblyResult:
|
|
45
|
+
output_path: Path
|
|
46
|
+
rendered_text: str
|
|
47
|
+
source_format: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def assemble_document(
|
|
51
|
+
repo: ArchitectureRepository,
|
|
52
|
+
*,
|
|
53
|
+
output: Path | None = None,
|
|
54
|
+
source_format: str | None = None,
|
|
55
|
+
include_draft: bool = False,
|
|
56
|
+
include_superseded: bool = False,
|
|
57
|
+
strict: bool = False,
|
|
58
|
+
write: bool = True,
|
|
59
|
+
) -> AssemblyResult:
|
|
60
|
+
check_result = repo.check(strict=strict)
|
|
61
|
+
if check_result.has_failures(strict=strict):
|
|
62
|
+
raise RenderError(
|
|
63
|
+
f"Build blocked by {len(check_result.errors)} error(s) and "
|
|
64
|
+
f"{len(check_result.warnings)} warning(s)."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
resolved_source_format = (
|
|
68
|
+
repo.config.source_format if source_format is None else source_format
|
|
69
|
+
)
|
|
70
|
+
dialect = get_dialect(resolved_source_format)
|
|
71
|
+
all_records = repo.load_all_records(include_sections=True)
|
|
72
|
+
sections = {
|
|
73
|
+
record.section: record for record in all_records if record.type == "section"
|
|
74
|
+
}
|
|
75
|
+
records = _visible_records(
|
|
76
|
+
all_records,
|
|
77
|
+
include_draft=include_draft,
|
|
78
|
+
include_superseded=include_superseded,
|
|
79
|
+
)
|
|
80
|
+
env = Environment(
|
|
81
|
+
loader=PackageLoader("archledger", "templates"),
|
|
82
|
+
autoescape=select_autoescape(
|
|
83
|
+
enabled_extensions=(),
|
|
84
|
+
default_for_string=False,
|
|
85
|
+
),
|
|
86
|
+
keep_trailing_newline=True,
|
|
87
|
+
)
|
|
88
|
+
template = env.get_template(
|
|
89
|
+
document_template_name_for_source_format(resolved_source_format)
|
|
90
|
+
)
|
|
91
|
+
rendered = template.render(
|
|
92
|
+
title=repo.config.arc42_title,
|
|
93
|
+
date=_document_date(records),
|
|
94
|
+
generator=f"archledger {__version__}",
|
|
95
|
+
arc42_template_version=repo.config.arc42_template_version,
|
|
96
|
+
section_body=lambda section_key: section_body(sections, section_key, dialect),
|
|
97
|
+
requirements_overview=lambda: requirements_overview(records, dialect),
|
|
98
|
+
quality_goals_table=lambda: quality_goals_table(records, dialect),
|
|
99
|
+
stakeholders_table=lambda: stakeholders_table(records, dialect),
|
|
100
|
+
constraints_list=lambda: constraints_list(records, dialect),
|
|
101
|
+
context_interfaces=lambda context_kind: context_interfaces(
|
|
102
|
+
records,
|
|
103
|
+
context_kind,
|
|
104
|
+
dialect,
|
|
105
|
+
),
|
|
106
|
+
solution_strategy_items=lambda: solution_strategy_items(records, dialect),
|
|
107
|
+
section_diagrams=lambda section_key: section_diagrams(
|
|
108
|
+
records,
|
|
109
|
+
section_key,
|
|
110
|
+
dialect,
|
|
111
|
+
),
|
|
112
|
+
building_block_hierarchy=lambda: building_block_hierarchy(records, dialect),
|
|
113
|
+
runtime_scenarios=lambda: runtime_scenarios(records, dialect),
|
|
114
|
+
deployment_view=lambda: deployment_view(records, dialect),
|
|
115
|
+
concepts=lambda: concepts(records, dialect),
|
|
116
|
+
adr_sections=lambda: adr_sections(records, dialect),
|
|
117
|
+
quality_requirements_overview=lambda: quality_requirements_overview(
|
|
118
|
+
records,
|
|
119
|
+
dialect,
|
|
120
|
+
),
|
|
121
|
+
quality_scenarios=lambda: quality_scenarios(records, dialect),
|
|
122
|
+
risk_table=lambda: risk_table(records, dialect),
|
|
123
|
+
glossary_table=lambda: glossary_table(records, dialect),
|
|
124
|
+
)
|
|
125
|
+
output_path = _resolve_output_path(repo, resolved_source_format, output)
|
|
126
|
+
if write:
|
|
127
|
+
write_text(output_path, rendered)
|
|
128
|
+
return AssemblyResult(
|
|
129
|
+
output_path=output_path,
|
|
130
|
+
rendered_text=rendered,
|
|
131
|
+
source_format=resolved_source_format,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _document_date(records: list[ArchitectureRecord]) -> str:
|
|
136
|
+
source_date_epoch = os.getenv("SOURCE_DATE_EPOCH")
|
|
137
|
+
if source_date_epoch:
|
|
138
|
+
try:
|
|
139
|
+
timestamp = int(source_date_epoch)
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
raise RenderError(
|
|
142
|
+
"SOURCE_DATE_EPOCH must be an integer Unix timestamp."
|
|
143
|
+
) from exc
|
|
144
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).date().isoformat()
|
|
145
|
+
|
|
146
|
+
latest_date: date | None = None
|
|
147
|
+
for record in records:
|
|
148
|
+
for key in ("updated_at", "date"):
|
|
149
|
+
metadata_value = record.metadata.get(key)
|
|
150
|
+
parsed = _parse_record_datetime(metadata_value)
|
|
151
|
+
if parsed is None:
|
|
152
|
+
continue
|
|
153
|
+
candidate = parsed.date()
|
|
154
|
+
if latest_date is None or candidate > latest_date:
|
|
155
|
+
latest_date = candidate
|
|
156
|
+
|
|
157
|
+
if latest_date is not None:
|
|
158
|
+
return latest_date.isoformat()
|
|
159
|
+
return utc_now_iso()[:10]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_record_datetime(value: object) -> datetime | None:
|
|
163
|
+
if not isinstance(value, str):
|
|
164
|
+
return None
|
|
165
|
+
candidate = value.strip()
|
|
166
|
+
if not candidate:
|
|
167
|
+
return None
|
|
168
|
+
if len(candidate) == 10:
|
|
169
|
+
try:
|
|
170
|
+
return datetime.strptime(candidate, "%Y-%m-%d")
|
|
171
|
+
except ValueError:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
normalized = candidate
|
|
175
|
+
if normalized.endswith("Z"):
|
|
176
|
+
normalized = f"{normalized[:-1]}+00:00"
|
|
177
|
+
try:
|
|
178
|
+
return datetime.fromisoformat(normalized)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def assemble_asciidoc_document(
|
|
184
|
+
repo: ArchitectureRepository,
|
|
185
|
+
*,
|
|
186
|
+
output: Path | None = None,
|
|
187
|
+
include_draft: bool = False,
|
|
188
|
+
include_superseded: bool = False,
|
|
189
|
+
strict: bool = False,
|
|
190
|
+
write: bool = True,
|
|
191
|
+
) -> AssemblyResult:
|
|
192
|
+
return assemble_document(
|
|
193
|
+
repo,
|
|
194
|
+
output=output,
|
|
195
|
+
source_format="asciidoc",
|
|
196
|
+
include_draft=include_draft,
|
|
197
|
+
include_superseded=include_superseded,
|
|
198
|
+
strict=strict,
|
|
199
|
+
write=write,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _visible_records(
|
|
204
|
+
all_records: list[ArchitectureRecord],
|
|
205
|
+
*,
|
|
206
|
+
include_draft: bool,
|
|
207
|
+
include_superseded: bool,
|
|
208
|
+
) -> list[ArchitectureRecord]:
|
|
209
|
+
return [
|
|
210
|
+
record
|
|
211
|
+
for record in all_records
|
|
212
|
+
if record.type != "section"
|
|
213
|
+
and is_visible_status(
|
|
214
|
+
record.status,
|
|
215
|
+
include_draft=include_draft,
|
|
216
|
+
include_superseded=include_superseded,
|
|
217
|
+
)
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _resolve_output_path(
|
|
222
|
+
repo: ArchitectureRepository,
|
|
223
|
+
source_format: str,
|
|
224
|
+
output: Path | None,
|
|
225
|
+
) -> Path:
|
|
226
|
+
if output is None:
|
|
227
|
+
native_output_format = native_output_format_for_source_format(source_format)
|
|
228
|
+
default_output = (
|
|
229
|
+
repo.config.build_default_output
|
|
230
|
+
if repo.config.build_default_format == native_output_format
|
|
231
|
+
else default_document_filename_for_output_format(native_output_format)
|
|
232
|
+
)
|
|
233
|
+
return repo.paths.build_dir / default_output
|
|
234
|
+
if output.is_absolute():
|
|
235
|
+
return output
|
|
236
|
+
return repo.paths.workspace_root / output
|
archledger/checks.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from archledger.model import PLACEHOLDER_SNIPPETS, ArchitectureRecord
|
|
7
|
+
|
|
8
|
+
ALLOWED_CONSTRAINT_CATEGORIES = frozenset(
|
|
9
|
+
{"technical", "organizational", "regulatory", "convention"}
|
|
10
|
+
)
|
|
11
|
+
ALLOWED_RISK_LEVELS = frozenset({"low", "medium", "high"})
|
|
12
|
+
_ALLOWED_DIAGRAM_TYPES = frozenset({"text", "ascii", "unicode", "svgbob", "mermaid"})
|
|
13
|
+
_TEXT_DIAGRAM_TYPES = frozenset({"text", "ascii", "unicode"})
|
|
14
|
+
_MAX_TEXT_DIAGRAM_LINE_LENGTH = 120
|
|
15
|
+
|
|
16
|
+
_MARKDOWN_BLOCK_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
17
|
+
"mermaid": re.compile(r"```mermaid\s*\n(.*?)\n```", re.IGNORECASE | re.DOTALL),
|
|
18
|
+
"svgbob": re.compile(r"```svgbob\s*\n(.*?)\n```", re.IGNORECASE | re.DOTALL),
|
|
19
|
+
"text": re.compile(
|
|
20
|
+
r"```(?:textdiagram|diagram|text)\s*\n(.*?)\n```", re.IGNORECASE | re.DOTALL
|
|
21
|
+
),
|
|
22
|
+
"ascii": re.compile(
|
|
23
|
+
r"```(?:textdiagram|diagram|ascii|text)\s*\n(.*?)\n```",
|
|
24
|
+
re.IGNORECASE | re.DOTALL,
|
|
25
|
+
),
|
|
26
|
+
"unicode": re.compile(
|
|
27
|
+
r"```(?:textdiagram|diagram|text)\s*\n(.*?)\n```", re.IGNORECASE | re.DOTALL
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
_ASCIIDOC_BLOCK_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
31
|
+
"mermaid": re.compile(
|
|
32
|
+
r"\[mermaid\]\s*\n\.\.\.\.\s*\n(.*?)\n\.\.\.\.",
|
|
33
|
+
re.IGNORECASE | re.DOTALL,
|
|
34
|
+
),
|
|
35
|
+
"svgbob": re.compile(
|
|
36
|
+
r"\[svgbob\]\s*\n\.\.\.\.\s*\n(.*?)\n\.\.\.\.",
|
|
37
|
+
re.IGNORECASE | re.DOTALL,
|
|
38
|
+
),
|
|
39
|
+
"text": re.compile(
|
|
40
|
+
r"(?:\[source,\s*text\]|\[listing\])\s*\n----\s*\n(.*?)\n----"
|
|
41
|
+
r"|^\.\.\.\.\s*\n(.*?)\n\.\.\.\.",
|
|
42
|
+
re.IGNORECASE | re.DOTALL | re.MULTILINE,
|
|
43
|
+
),
|
|
44
|
+
"ascii": re.compile(
|
|
45
|
+
r"(?:\[source,\s*(?:text|ascii)\]|\[listing\])\s*\n----\s*\n(.*?)\n----"
|
|
46
|
+
r"|^\.\.\.\.\s*\n(.*?)\n\.\.\.\.",
|
|
47
|
+
re.IGNORECASE | re.DOTALL | re.MULTILINE,
|
|
48
|
+
),
|
|
49
|
+
"unicode": re.compile(
|
|
50
|
+
r"(?:\[source,\s*text\]|\[listing\])\s*\n----\s*\n(.*?)\n----"
|
|
51
|
+
r"|^\.\.\.\.\s*\n(.*?)\n\.\.\.\.",
|
|
52
|
+
re.IGNORECASE | re.DOTALL | re.MULTILINE,
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def content_warnings(record: ArchitectureRecord) -> list[str]:
|
|
58
|
+
warnings: list[str] = []
|
|
59
|
+
if record.type != "section":
|
|
60
|
+
stripped_body = record.body.strip()
|
|
61
|
+
if stripped_body and any(
|
|
62
|
+
snippet in stripped_body for snippet in PLACEHOLDER_SNIPPETS
|
|
63
|
+
):
|
|
64
|
+
warnings.append(f"Record body is placeholder text for {record.id}.")
|
|
65
|
+
|
|
66
|
+
checker = _CONTENT_WARNING_CHECKERS.get(record.type)
|
|
67
|
+
if checker is not None:
|
|
68
|
+
warnings.extend(checker(record))
|
|
69
|
+
warnings.extend(_body_syntax_warnings(record))
|
|
70
|
+
return warnings
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _has_non_empty_sequence(value: object) -> bool:
|
|
74
|
+
return isinstance(value, list) and any(str(item).strip() for item in value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _has_non_empty_text(value: object) -> bool:
|
|
78
|
+
return isinstance(value, str) and bool(value.strip())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _contains_adr_sections(body: str) -> bool:
|
|
82
|
+
body_lower = body.lower()
|
|
83
|
+
return all(
|
|
84
|
+
any(heading in body_lower for heading in headings)
|
|
85
|
+
for headings in (
|
|
86
|
+
("## context", "=== context"),
|
|
87
|
+
("## decision", "=== decision"),
|
|
88
|
+
("## consequences", "=== consequences"),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _looks_measurable(value: str) -> bool:
|
|
94
|
+
lowered = value.lower()
|
|
95
|
+
if any(char.isdigit() for char in lowered):
|
|
96
|
+
return True
|
|
97
|
+
indicators = (
|
|
98
|
+
"%",
|
|
99
|
+
"percent",
|
|
100
|
+
"ms",
|
|
101
|
+
"millisecond",
|
|
102
|
+
"second",
|
|
103
|
+
"minute",
|
|
104
|
+
"hour",
|
|
105
|
+
"count",
|
|
106
|
+
"byte",
|
|
107
|
+
"identical",
|
|
108
|
+
"latency",
|
|
109
|
+
"throughput",
|
|
110
|
+
"less than",
|
|
111
|
+
"greater than",
|
|
112
|
+
"at least",
|
|
113
|
+
"at most",
|
|
114
|
+
"zero",
|
|
115
|
+
"one",
|
|
116
|
+
"two",
|
|
117
|
+
)
|
|
118
|
+
return any(indicator in lowered for indicator in indicators)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _quality_goal_warnings(record: ArchitectureRecord) -> list[str]:
|
|
122
|
+
if _has_non_empty_text(record.metadata.get("scenario")):
|
|
123
|
+
return []
|
|
124
|
+
return [f"Quality goal {record.id} has no scenario."]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _stakeholder_warnings(record: ArchitectureRecord) -> list[str]:
|
|
128
|
+
if _has_non_empty_sequence(record.metadata.get("expectations")):
|
|
129
|
+
return []
|
|
130
|
+
return [f"Stakeholder {record.id} has no expectations."]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _constraint_warnings(record: ArchitectureRecord) -> list[str]:
|
|
134
|
+
warnings: list[str] = []
|
|
135
|
+
if not _has_non_empty_text(record.metadata.get("impact")):
|
|
136
|
+
warnings.append(f"Constraint {record.id} has no impact.")
|
|
137
|
+
category = record.metadata.get("category")
|
|
138
|
+
if category not in ALLOWED_CONSTRAINT_CATEGORIES:
|
|
139
|
+
warnings.append(f"Constraint {record.id} has unsupported category: {category}")
|
|
140
|
+
return warnings
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _context_interface_warnings(record: ArchitectureRecord) -> list[str]:
|
|
144
|
+
warnings: list[str] = []
|
|
145
|
+
if not _has_non_empty_text(record.metadata.get("partner")):
|
|
146
|
+
warnings.append(f"Context interface {record.id} has no partner.")
|
|
147
|
+
if not any(
|
|
148
|
+
_has_non_empty_sequence(record.metadata.get(field))
|
|
149
|
+
for field in ("inputs", "outputs", "channels")
|
|
150
|
+
):
|
|
151
|
+
warnings.append(
|
|
152
|
+
f"Context interface {record.id} has no inputs, outputs, or channels."
|
|
153
|
+
)
|
|
154
|
+
return warnings
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _white_box_warnings(record: ArchitectureRecord) -> list[str]:
|
|
158
|
+
warnings: list[str] = []
|
|
159
|
+
level = record.metadata.get("level")
|
|
160
|
+
if isinstance(level, bool) or not isinstance(level, int) or level < 1:
|
|
161
|
+
warnings.append(f"White box {record.id} must have a positive integer level.")
|
|
162
|
+
parent = record.metadata.get("parent")
|
|
163
|
+
if isinstance(level, int) and level > 1 and parent in (None, "", "null"):
|
|
164
|
+
warnings.append(f"White box {record.id} at level > 1 requires a parent.")
|
|
165
|
+
return warnings
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _black_box_warnings(record: ArchitectureRecord) -> list[str]:
|
|
169
|
+
if record.metadata.get("parent") not in (None, "", "null"):
|
|
170
|
+
return []
|
|
171
|
+
return [
|
|
172
|
+
(
|
|
173
|
+
f"Black box {record.id} should declare a parent unless it is "
|
|
174
|
+
"intentionally top-level external."
|
|
175
|
+
)
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _runtime_scenario_warnings(record: ArchitectureRecord) -> list[str]:
|
|
180
|
+
warnings: list[str] = []
|
|
181
|
+
if not _has_non_empty_sequence(record.metadata.get("participants")):
|
|
182
|
+
warnings.append(f"Runtime scenario {record.id} has no participants.")
|
|
183
|
+
if not _has_non_empty_text(record.metadata.get("trigger")):
|
|
184
|
+
warnings.append(f"Runtime scenario {record.id} has no trigger.")
|
|
185
|
+
return warnings
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _infrastructure_warnings(record: ArchitectureRecord) -> list[str]:
|
|
189
|
+
warnings: list[str] = []
|
|
190
|
+
environment = record.metadata.get("environment")
|
|
191
|
+
if not _has_non_empty_text(environment):
|
|
192
|
+
warnings.append(f"Infrastructure {record.id} has no environment.")
|
|
193
|
+
if (
|
|
194
|
+
isinstance(environment, str)
|
|
195
|
+
and environment.strip().lower() == "production"
|
|
196
|
+
and not _has_non_empty_sequence(record.metadata.get("maps_building_blocks"))
|
|
197
|
+
):
|
|
198
|
+
warnings.append(
|
|
199
|
+
f"Infrastructure {record.id} in production must map building "
|
|
200
|
+
"blocks explicitly."
|
|
201
|
+
)
|
|
202
|
+
return warnings
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _adr_warnings(record: ArchitectureRecord) -> list[str]:
|
|
206
|
+
warnings: list[str] = []
|
|
207
|
+
if not _contains_adr_sections(record.body):
|
|
208
|
+
warnings.append(
|
|
209
|
+
"ADR "
|
|
210
|
+
f"{record.id} should contain Context, Decision, and Consequences "
|
|
211
|
+
"sections."
|
|
212
|
+
)
|
|
213
|
+
if not _has_non_empty_sequence(record.metadata.get("deciders")):
|
|
214
|
+
warnings.append(f"ADR {record.id} has no deciders.")
|
|
215
|
+
return warnings
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _quality_scenario_warnings(record: ArchitectureRecord) -> list[str]:
|
|
219
|
+
response_measure = record.metadata.get("response_measure")
|
|
220
|
+
if not _has_non_empty_text(response_measure):
|
|
221
|
+
return [f"Quality scenario {record.id} has no response_measure."]
|
|
222
|
+
if isinstance(response_measure, str) and not _looks_measurable(response_measure):
|
|
223
|
+
return [f"Quality scenario {record.id} response_measure should be measurable."]
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _risk_warnings(record: ArchitectureRecord) -> list[str]:
|
|
228
|
+
warnings: list[str] = []
|
|
229
|
+
severity = record.metadata.get("severity")
|
|
230
|
+
probability = record.metadata.get("probability")
|
|
231
|
+
if severity not in ALLOWED_RISK_LEVELS:
|
|
232
|
+
warnings.append(f"Risk {record.id} has unsupported severity: {severity}")
|
|
233
|
+
if probability not in ALLOWED_RISK_LEVELS:
|
|
234
|
+
warnings.append(f"Risk {record.id} has unsupported probability: {probability}")
|
|
235
|
+
if not _has_non_empty_text(record.metadata.get("mitigation")):
|
|
236
|
+
warnings.append(f"Risk {record.id} has no mitigation.")
|
|
237
|
+
return warnings
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _glossary_term_warnings(record: ArchitectureRecord) -> list[str]:
|
|
241
|
+
if _has_non_empty_text(record.metadata.get("definition")):
|
|
242
|
+
return []
|
|
243
|
+
return [f"Glossary term {record.id} has no definition."]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _diagram_warnings(record: ArchitectureRecord) -> list[str]:
|
|
247
|
+
warnings: list[str] = []
|
|
248
|
+
diagram_type_raw = record.metadata.get("diagram_type")
|
|
249
|
+
diagram_type = (
|
|
250
|
+
diagram_type_raw.strip().lower() if isinstance(diagram_type_raw, str) else ""
|
|
251
|
+
)
|
|
252
|
+
if diagram_type not in _ALLOWED_DIAGRAM_TYPES:
|
|
253
|
+
warnings.append(
|
|
254
|
+
f"Diagram {record.id} has unsupported diagram_type: {diagram_type_raw!r}. "
|
|
255
|
+
f"Allowed types: {', '.join(sorted(_ALLOWED_DIAGRAM_TYPES))}."
|
|
256
|
+
)
|
|
257
|
+
caption = record.metadata.get("caption")
|
|
258
|
+
if not isinstance(caption, str) or not caption.strip():
|
|
259
|
+
warnings.append(f"Diagram {record.id} has no caption.")
|
|
260
|
+
|
|
261
|
+
body_format_value = record.metadata.get("body_format")
|
|
262
|
+
body_format = (
|
|
263
|
+
body_format_value.strip().lower() if isinstance(body_format_value, str) else ""
|
|
264
|
+
)
|
|
265
|
+
if body_format == "markdown":
|
|
266
|
+
warnings.extend(_markdown_diagram_warnings(record, diagram_type))
|
|
267
|
+
elif body_format == "asciidoc":
|
|
268
|
+
warnings.extend(_asciidoc_diagram_warnings(record, diagram_type))
|
|
269
|
+
return warnings
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _markdown_diagram_warnings(
|
|
273
|
+
record: ArchitectureRecord, diagram_type: str
|
|
274
|
+
) -> list[str]:
|
|
275
|
+
warnings: list[str] = []
|
|
276
|
+
pattern = _MARKDOWN_BLOCK_PATTERNS.get(diagram_type)
|
|
277
|
+
if pattern is None:
|
|
278
|
+
return warnings
|
|
279
|
+
match = pattern.search(record.body)
|
|
280
|
+
if not match:
|
|
281
|
+
warnings.append(
|
|
282
|
+
f"Diagram {record.id} markdown body is missing a fenced "
|
|
283
|
+
f"{diagram_type} block."
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
block_content = match.group(1) or ""
|
|
287
|
+
if not block_content.strip():
|
|
288
|
+
warnings.append(f"Diagram {record.id} {diagram_type} block is empty.")
|
|
289
|
+
elif diagram_type in _TEXT_DIAGRAM_TYPES:
|
|
290
|
+
for line in block_content.splitlines():
|
|
291
|
+
if len(line) > _MAX_TEXT_DIAGRAM_LINE_LENGTH:
|
|
292
|
+
warnings.append(
|
|
293
|
+
f"Diagram {record.id} has a text diagram line exceeding "
|
|
294
|
+
f"{_MAX_TEXT_DIAGRAM_LINE_LENGTH} characters."
|
|
295
|
+
)
|
|
296
|
+
break
|
|
297
|
+
return warnings
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _asciidoc_diagram_warnings(
|
|
301
|
+
record: ArchitectureRecord, diagram_type: str
|
|
302
|
+
) -> list[str]:
|
|
303
|
+
warnings: list[str] = []
|
|
304
|
+
pattern = _ASCIIDOC_BLOCK_PATTERNS.get(diagram_type)
|
|
305
|
+
if pattern is None:
|
|
306
|
+
return warnings
|
|
307
|
+
if diagram_type == "mermaid":
|
|
308
|
+
if not _has_asciidoc_mermaid_block(record.body):
|
|
309
|
+
warnings.append(
|
|
310
|
+
f"Diagram {record.id} asciidoc body is missing a [mermaid] block."
|
|
311
|
+
)
|
|
312
|
+
elif _asciidoc_mermaid_block_is_empty(record.body):
|
|
313
|
+
warnings.append(f"Diagram {record.id} asciidoc mermaid block is empty.")
|
|
314
|
+
elif diagram_type == "svgbob":
|
|
315
|
+
match = pattern.search(record.body)
|
|
316
|
+
if not match:
|
|
317
|
+
warnings.append(
|
|
318
|
+
f"Diagram {record.id} asciidoc body is missing a [svgbob] block."
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
block_content = match.group(1) or ""
|
|
322
|
+
if not block_content.strip():
|
|
323
|
+
warnings.append(f"Diagram {record.id} svgbob block is empty.")
|
|
324
|
+
else:
|
|
325
|
+
match = pattern.search(record.body)
|
|
326
|
+
if not match:
|
|
327
|
+
warnings.append(
|
|
328
|
+
f"Diagram {record.id} asciidoc body is missing a "
|
|
329
|
+
f"{diagram_type} text block."
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
groups = [g for g in match.groups() if g is not None]
|
|
333
|
+
block_content = groups[0] if groups else ""
|
|
334
|
+
if not block_content.strip():
|
|
335
|
+
warnings.append(f"Diagram {record.id} {diagram_type} block is empty.")
|
|
336
|
+
elif diagram_type in _TEXT_DIAGRAM_TYPES:
|
|
337
|
+
for line in block_content.splitlines():
|
|
338
|
+
if len(line) > _MAX_TEXT_DIAGRAM_LINE_LENGTH:
|
|
339
|
+
warnings.append(
|
|
340
|
+
f"Diagram {record.id} has a text diagram line "
|
|
341
|
+
f"exceeding {_MAX_TEXT_DIAGRAM_LINE_LENGTH} characters."
|
|
342
|
+
)
|
|
343
|
+
break
|
|
344
|
+
return warnings
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _has_asciidoc_mermaid_block(body: str) -> bool:
|
|
348
|
+
return bool(
|
|
349
|
+
re.search(
|
|
350
|
+
r"\[mermaid\]\s*\n\.\.\.\.\s*\n.*?\n\.\.\.\.",
|
|
351
|
+
body,
|
|
352
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _asciidoc_mermaid_block_is_empty(body: str) -> bool:
|
|
358
|
+
match = re.search(
|
|
359
|
+
r"\[mermaid\]\s*\n\.\.\.\.\s*\n(.*?)\n\.\.\.\.",
|
|
360
|
+
body,
|
|
361
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
362
|
+
)
|
|
363
|
+
if match is None:
|
|
364
|
+
return False
|
|
365
|
+
return not match.group(1).strip()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _body_syntax_warnings(record: ArchitectureRecord) -> list[str]:
|
|
369
|
+
body_format_value = record.metadata.get("body_format")
|
|
370
|
+
if not isinstance(body_format_value, str):
|
|
371
|
+
return []
|
|
372
|
+
body_format = body_format_value.strip().lower()
|
|
373
|
+
if body_format == "markdown":
|
|
374
|
+
if "[discrete]" in record.body and "\n===" in record.body:
|
|
375
|
+
return [
|
|
376
|
+
"Markdown "
|
|
377
|
+
f"record {record.id} contains AsciiDoc-style discrete headings."
|
|
378
|
+
]
|
|
379
|
+
return []
|
|
380
|
+
if body_format == "asciidoc":
|
|
381
|
+
if any(
|
|
382
|
+
line.startswith("## ")
|
|
383
|
+
for line in record.body.splitlines()
|
|
384
|
+
if not line.startswith("```")
|
|
385
|
+
):
|
|
386
|
+
return [f"AsciiDoc record {record.id} contains Markdown headings."]
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
_CONTENT_WARNING_CHECKERS: dict[str, Callable[[ArchitectureRecord], list[str]]] = {
|
|
391
|
+
"quality_goal": _quality_goal_warnings,
|
|
392
|
+
"stakeholder": _stakeholder_warnings,
|
|
393
|
+
"constraint": _constraint_warnings,
|
|
394
|
+
"context_interface": _context_interface_warnings,
|
|
395
|
+
"white_box": _white_box_warnings,
|
|
396
|
+
"black_box": _black_box_warnings,
|
|
397
|
+
"runtime_scenario": _runtime_scenario_warnings,
|
|
398
|
+
"infrastructure": _infrastructure_warnings,
|
|
399
|
+
"adr": _adr_warnings,
|
|
400
|
+
"quality_scenario": _quality_scenario_warnings,
|
|
401
|
+
"risk": _risk_warnings,
|
|
402
|
+
"diagram": _diagram_warnings,
|
|
403
|
+
"glossary_term": _glossary_term_warnings,
|
|
404
|
+
}
|