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.
Files changed (80) hide show
  1. archledger/__init__.py +8 -0
  2. archledger/_version.py +24 -0
  3. archledger/assembly.py +236 -0
  4. archledger/checks.py +404 -0
  5. archledger/cli.py +929 -0
  6. archledger/cli_formatting.py +257 -0
  7. archledger/cli_payloads.py +408 -0
  8. archledger/config/__init__.py +32 -0
  9. archledger/config/model.py +233 -0
  10. archledger/config/parse.py +662 -0
  11. archledger/config/render.py +238 -0
  12. archledger/conversion_plan.py +273 -0
  13. archledger/converters.py +165 -0
  14. archledger/diagrams.py +217 -0
  15. archledger/dialects.py +105 -0
  16. archledger/errors.py +34 -0
  17. archledger/formats.py +136 -0
  18. archledger/ids.py +38 -0
  19. archledger/launcher.py +7 -0
  20. archledger/migration.py +176 -0
  21. archledger/model.py +339 -0
  22. archledger/py.typed +0 -0
  23. archledger/record_types.py +333 -0
  24. archledger/render.py +72 -0
  25. archledger/repository.py +1054 -0
  26. archledger/section_rendering.py +389 -0
  27. archledger/source_refs.py +197 -0
  28. archledger/source_tracking.py +482 -0
  29. archledger/storage/__init__.py +1 -0
  30. archledger/storage/common.py +50 -0
  31. archledger/storage/frontmatter.py +81 -0
  32. archledger/storage/meta.py +130 -0
  33. archledger/storage/paths.py +151 -0
  34. archledger/storage/project_config.py +33 -0
  35. archledger/storage/source_state.py +149 -0
  36. archledger/templates/__init__.py +1 -0
  37. archledger/templates/arc42_document.adoc.j2 +124 -0
  38. archledger/templates/arc42_document.md.j2 +120 -0
  39. archledger/templates/records/adr.adoc.j2 +37 -0
  40. archledger/templates/records/adr.md.j2 +33 -0
  41. archledger/templates/records/black_box.adoc.j2 +22 -0
  42. archledger/templates/records/black_box.md.j2 +22 -0
  43. archledger/templates/records/concept.adoc.j2 +16 -0
  44. archledger/templates/records/concept.md.j2 +16 -0
  45. archledger/templates/records/constraint.adoc.j2 +17 -0
  46. archledger/templates/records/constraint.md.j2 +17 -0
  47. archledger/templates/records/context_interface.adoc.j2 +20 -0
  48. archledger/templates/records/context_interface.md.j2 +20 -0
  49. archledger/templates/records/diagram.adoc.j2 +46 -0
  50. archledger/templates/records/diagram.md.j2 +43 -0
  51. archledger/templates/records/glossary_term.adoc.j2 +17 -0
  52. archledger/templates/records/glossary_term.md.j2 +17 -0
  53. archledger/templates/records/infrastructure.adoc.j2 +17 -0
  54. archledger/templates/records/infrastructure.md.j2 +19 -0
  55. archledger/templates/records/interface.adoc.j2 +18 -0
  56. archledger/templates/records/interface.md.j2 +19 -0
  57. archledger/templates/records/quality_goal.adoc.j2 +17 -0
  58. archledger/templates/records/quality_goal.md.j2 +17 -0
  59. archledger/templates/records/quality_requirement.adoc.j2 +27 -0
  60. archledger/templates/records/quality_requirement.md.j2 +25 -0
  61. archledger/templates/records/quality_scenario.adoc.j2 +22 -0
  62. archledger/templates/records/quality_scenario.md.j2 +22 -0
  63. archledger/templates/records/requirement.adoc.j2 +27 -0
  64. archledger/templates/records/requirement.md.j2 +25 -0
  65. archledger/templates/records/risk.adoc.j2 +18 -0
  66. archledger/templates/records/risk.md.j2 +18 -0
  67. archledger/templates/records/runtime_scenario.adoc.j2 +18 -0
  68. archledger/templates/records/runtime_scenario.md.j2 +18 -0
  69. archledger/templates/records/stakeholder.adoc.j2 +17 -0
  70. archledger/templates/records/stakeholder.md.j2 +17 -0
  71. archledger/templates/records/strategy_item.adoc.j2 +26 -0
  72. archledger/templates/records/strategy_item.md.j2 +24 -0
  73. archledger/templates/records/white_box.adoc.j2 +33 -0
  74. archledger/templates/records/white_box.md.j2 +30 -0
  75. archledger-0.1.0.dist-info/METADATA +473 -0
  76. archledger-0.1.0.dist-info/RECORD +80 -0
  77. archledger-0.1.0.dist-info/WHEEL +5 -0
  78. archledger-0.1.0.dist-info/entry_points.txt +2 -0
  79. archledger-0.1.0.dist-info/licenses/LICENSE +201 -0
  80. archledger-0.1.0.dist-info/top_level.txt +1 -0
archledger/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ try:
4
+ from archledger._version import version as __version__
5
+ except ImportError: # pragma: no cover
6
+ __version__ = "0.0.0"
7
+
8
+ __all__ = ["__version__"]
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
+ }