okstra 0.156.0 → 0.157.0
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.
- package/docs/architecture.md +3 -1
- package/docs/for-ai/skills/okstra-schedule-gen.md +5 -4
- package/docs/project-structure-overview.md +7 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/python/okstra_ctl/clarification_items.py +3 -3
- package/runtime/python/okstra_ctl/render_final_report.py +31 -44
- package/runtime/python/okstra_ctl/report_contract.py +15 -0
- package/runtime/python/okstra_ctl/report_markdown.py +441 -0
- package/runtime/python/okstra_ctl/schedule_semantics.py +186 -91
- package/runtime/python/okstra_ctl/stage_map.py +111 -6
- package/runtime/python/okstra_ctl/wizard.py +1 -11
- package/runtime/python/okstra_project/state.py +14 -2
- package/runtime/skills/okstra-schedule-gen/SKILL.md +43 -18
- package/runtime/templates/reports/final-report-v2.template.md +74 -10
- package/runtime/templates/reports/md/macros/sections.md +19 -0
- package/runtime/templates/reports/md/tasks/change-impact-analysis.template.md +18 -0
- package/runtime/templates/reports/md/tasks/error-analysis.template.md +13 -0
- package/runtime/templates/reports/md/tasks/feature-analysis.template.md +13 -0
- package/runtime/templates/reports/md/tasks/final-verification.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +15 -0
- package/runtime/templates/reports/md/tasks/implementation.template.md +15 -0
- package/runtime/templates/reports/md/tasks/improvement-discovery.template.md +10 -0
- package/runtime/templates/reports/md/tasks/project-analysis.template.md +15 -0
- package/runtime/templates/reports/md/tasks/release-handoff.template.md +13 -0
- package/runtime/templates/reports/md/tasks/requirements-discovery.template.md +15 -0
- package/runtime/templates/reports/schedule.template.md +166 -63
- package/runtime/validators/validate-run.py +19 -6
- package/runtime/validators/validate-schedule.py +94 -65
- package/src/commands/inspect/stage-map.mjs +6 -1
- package/src/commands/inspect/worker-liveness.mjs +15 -3
- package/src/commands/lifecycle/install.mjs +69 -4
- package/src/commands/lifecycle/uninstall.mjs +21 -35
- package/src/lib/install-assets.mjs +37 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Schema-ordered Markdown for the AI handoff final-report body.
|
|
2
|
+
|
|
3
|
+
`data.json` is the report's single source of truth; this module renders its
|
|
4
|
+
subtrees as Markdown a reading agent can skim — headings for structure, tables
|
|
5
|
+
for uniform row sets, prose for narrative fields.
|
|
6
|
+
|
|
7
|
+
Property order comes from the schema, which is authored in reading order (a
|
|
8
|
+
decision draft reads `context -> decision -> consequences`). Serialising the
|
|
9
|
+
same subtree with `json.dumps(sort_keys=True)` would put `alternativesConsidered`
|
|
10
|
+
first and `decision` last, which is why order is taken from the schema rather
|
|
11
|
+
than from the mapping.
|
|
12
|
+
|
|
13
|
+
Order is a hint, never a gate: a key the schema does not mention still renders,
|
|
14
|
+
it just sorts after the ones the schema names. Nothing in `data.json` is
|
|
15
|
+
dropped except `userNarrative`, which belongs to the human HTML.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any, Iterable, Sequence
|
|
21
|
+
|
|
22
|
+
from okstra_ctl.md_table import to_cell_text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Markdown has no `#######`; deeper structures degrade to a bold label so the
|
|
26
|
+
# heading tree stays parseable instead of emitting an invalid level.
|
|
27
|
+
MAX_HEADING_LEVEL = 6
|
|
28
|
+
# Past these, a row set reads better as one block per row than as a table that
|
|
29
|
+
# no longer aligns: 8 columns is roughly a terminal width, and a cell longer
|
|
30
|
+
# than 160 characters is prose that a `<br>`-folded cell would bury.
|
|
31
|
+
TABLE_MAX_COLUMNS = 8
|
|
32
|
+
TABLE_MAX_CELL = 160
|
|
33
|
+
# A scalar this long stops being a field value and starts being a paragraph.
|
|
34
|
+
INLINE_MAX_CHARS = 140
|
|
35
|
+
|
|
36
|
+
EMPTY_MARKER = "_none_"
|
|
37
|
+
|
|
38
|
+
# The human HTML owns this field; the AI Markdown carries the structured facts
|
|
39
|
+
# it was written from, so repeating it here would duplicate the whole report.
|
|
40
|
+
HUMAN_ONLY_KEYS = frozenset({"userNarrative"})
|
|
41
|
+
|
|
42
|
+
_WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
43
|
+
# A bare token (path, id, enum value, command) reads better fenced; prose does
|
|
44
|
+
# not. Whitespace is the discriminator, so this pattern deliberately has none.
|
|
45
|
+
_TOKEN_RE = re.compile(r"^[\w./:#@+-]+$")
|
|
46
|
+
|
|
47
|
+
_ACRONYMS = {
|
|
48
|
+
"adr": "ADR",
|
|
49
|
+
"api": "API",
|
|
50
|
+
"cli": "CLI",
|
|
51
|
+
"ci": "CI",
|
|
52
|
+
"id": "ID",
|
|
53
|
+
"ids": "IDs",
|
|
54
|
+
"pr": "PR",
|
|
55
|
+
"sql": "SQL",
|
|
56
|
+
"url": "URL",
|
|
57
|
+
"usd": "USD",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Row identity for block-form lists. A row heading reads `E-001 — <what it is>`,
|
|
61
|
+
# so the identifier and the human name are looked up separately: an id alone
|
|
62
|
+
# ("1") does not say what the block holds, and a name alone loses the handle
|
|
63
|
+
# every cross-reference in the report cites.
|
|
64
|
+
_ROW_IDENT_KEYS = ("id", "rowId", "number", "stage", "stageNumber", "key")
|
|
65
|
+
# Naming fields only. A prose field like `summary` is deliberately absent: it
|
|
66
|
+
# would be truncated into the heading and then printed again in full below it.
|
|
67
|
+
_ROW_NAME_KEYS = (
|
|
68
|
+
"title",
|
|
69
|
+
"name",
|
|
70
|
+
"slug",
|
|
71
|
+
"label",
|
|
72
|
+
# `role` outranks `agent` because several rows of one execution audit share
|
|
73
|
+
# an agent ("Claude Code") and only the role tells them apart in a heading.
|
|
74
|
+
"role",
|
|
75
|
+
"agent",
|
|
76
|
+
"worker",
|
|
77
|
+
"command",
|
|
78
|
+
"path",
|
|
79
|
+
)
|
|
80
|
+
# Long enough to identify the row, short enough to stay one heading line.
|
|
81
|
+
ROW_NAME_MAX_CHARS = 80
|
|
82
|
+
# A short token list (`P-001, P-002, …`) reads as one line; past this width an
|
|
83
|
+
# item is content that deserves its own bullet.
|
|
84
|
+
INLINE_ITEM_MAX_CHARS = 32
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def humanise(key: str) -> str:
|
|
88
|
+
"""`stageMap` -> `Stage Map`, keeping known acronyms upper-case."""
|
|
89
|
+
spaced = _WORD_BOUNDARY_RE.sub(" ", str(key)).replace("_", " ").replace("-", " ")
|
|
90
|
+
words = spaced.split()
|
|
91
|
+
if not words:
|
|
92
|
+
return str(key)
|
|
93
|
+
return " ".join(
|
|
94
|
+
_ACRONYMS.get(word.lower(), word[:1].upper() + word[1:]) for word in words
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SchemaIndex:
|
|
99
|
+
"""Property-order lookup over the final-report schema.
|
|
100
|
+
|
|
101
|
+
Resolves `$ref` and flattens `allOf` / `oneOf` / `anyOf` branches into one
|
|
102
|
+
ordered key list, because the schema uses all three to compose task blocks
|
|
103
|
+
and a reader needs a single order regardless of which branch matched.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, schema: Any) -> None:
|
|
107
|
+
self._defs = schema.get("$defs", {}) if isinstance(schema, dict) else {}
|
|
108
|
+
|
|
109
|
+
def resolve(self, node: Any) -> dict:
|
|
110
|
+
seen: set[str] = set()
|
|
111
|
+
while isinstance(node, dict) and "$ref" in node:
|
|
112
|
+
ref = str(node["$ref"])
|
|
113
|
+
if ref in seen:
|
|
114
|
+
return {}
|
|
115
|
+
seen.add(ref)
|
|
116
|
+
node = self._defs.get(ref.rsplit("/", 1)[-1], {})
|
|
117
|
+
return node if isinstance(node, dict) else {}
|
|
118
|
+
|
|
119
|
+
def _branches(self, node: Any) -> list[dict]:
|
|
120
|
+
resolved = self.resolve(node)
|
|
121
|
+
branches = [resolved]
|
|
122
|
+
for keyword in ("allOf", "oneOf", "anyOf"):
|
|
123
|
+
for branch in resolved.get(keyword) or ():
|
|
124
|
+
branches.append(self.resolve(branch))
|
|
125
|
+
return branches
|
|
126
|
+
|
|
127
|
+
def key_order(self, node: Any) -> list[str]:
|
|
128
|
+
order: list[str] = []
|
|
129
|
+
for branch in self._branches(node):
|
|
130
|
+
for key in branch.get("properties") or ():
|
|
131
|
+
if key not in order:
|
|
132
|
+
order.append(key)
|
|
133
|
+
return order
|
|
134
|
+
|
|
135
|
+
def child(self, node: Any, key: str) -> dict:
|
|
136
|
+
for branch in self._branches(node):
|
|
137
|
+
candidate = (branch.get("properties") or {}).get(key)
|
|
138
|
+
if candidate is not None:
|
|
139
|
+
return self.resolve(candidate)
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
def item(self, node: Any) -> dict:
|
|
143
|
+
for branch in self._branches(node):
|
|
144
|
+
if "items" in branch:
|
|
145
|
+
return self.resolve(branch["items"])
|
|
146
|
+
return {}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_scalar(value: Any) -> bool:
|
|
150
|
+
return value is None or isinstance(value, (str, int, float, bool))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_empty(value: Any) -> bool:
|
|
154
|
+
if value is None:
|
|
155
|
+
return True
|
|
156
|
+
if isinstance(value, (list, dict, str)):
|
|
157
|
+
return len(value) == 0
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_inline(value: Any) -> bool:
|
|
162
|
+
"""True when the value fits on one `- **Label**: value` line."""
|
|
163
|
+
if not _is_scalar(value):
|
|
164
|
+
return False
|
|
165
|
+
if isinstance(value, str):
|
|
166
|
+
return "\n" not in value and len(value) <= INLINE_MAX_CHARS
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def scalar_text(value: Any) -> str:
|
|
171
|
+
if value is None:
|
|
172
|
+
return EMPTY_MARKER
|
|
173
|
+
if isinstance(value, bool):
|
|
174
|
+
return f"`{str(value).lower()}`"
|
|
175
|
+
if isinstance(value, (int, float)):
|
|
176
|
+
return f"`{value}`"
|
|
177
|
+
text = str(value).strip()
|
|
178
|
+
if not text:
|
|
179
|
+
return EMPTY_MARKER
|
|
180
|
+
return f"`{text}`" if _TOKEN_RE.match(text) else text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def heading(level: int, text: str) -> str:
|
|
184
|
+
if level <= MAX_HEADING_LEVEL:
|
|
185
|
+
return f"{'#' * level} {text}"
|
|
186
|
+
return f"**{text}**"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _join_blocks(blocks: Iterable[str]) -> str:
|
|
190
|
+
return "\n\n".join(block for block in blocks if block and block.strip())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _visible_keys(
|
|
194
|
+
value: dict, node: Any, index: SchemaIndex, skip: Iterable[str] = ()
|
|
195
|
+
) -> list[str]:
|
|
196
|
+
hidden = HUMAN_ONLY_KEYS | set(skip)
|
|
197
|
+
known = [key for key in index.key_order(node) if key in value]
|
|
198
|
+
rest = [key for key in value if key not in known]
|
|
199
|
+
return [key for key in (*known, *rest) if key not in hidden]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _first_present(
|
|
203
|
+
row: dict, keys: Iterable[str], limit: int | None = None
|
|
204
|
+
) -> tuple[str, str]:
|
|
205
|
+
"""The first key in *keys* carrying a usable scalar, as ``(key, text)``."""
|
|
206
|
+
for key in keys:
|
|
207
|
+
candidate = row.get(key)
|
|
208
|
+
if candidate is None or not _is_scalar(candidate):
|
|
209
|
+
continue
|
|
210
|
+
text = " ".join(str(candidate).split())
|
|
211
|
+
if not text:
|
|
212
|
+
continue
|
|
213
|
+
if limit is not None and len(text) > limit:
|
|
214
|
+
text = text[: limit - 1].rstrip() + "…"
|
|
215
|
+
return key, text
|
|
216
|
+
return "", ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _row_label(row: Any, position: int) -> tuple[str, set[str]]:
|
|
220
|
+
"""Heading text for a block-form row, and the keys it already shows.
|
|
221
|
+
|
|
222
|
+
The caller drops those keys from the body: repeating `Stage: 1` under a
|
|
223
|
+
`#### 1 — …` heading costs a line and tells the reader nothing new.
|
|
224
|
+
"""
|
|
225
|
+
if not isinstance(row, dict):
|
|
226
|
+
return f"Item {position}", set()
|
|
227
|
+
ident_key, ident = _first_present(row, _ROW_IDENT_KEYS)
|
|
228
|
+
name_key, name = _first_present(row, _ROW_NAME_KEYS, limit=ROW_NAME_MAX_CHARS)
|
|
229
|
+
label = " — ".join(part for part in (ident, name) if part)
|
|
230
|
+
if not label:
|
|
231
|
+
return f"Item {position}", set()
|
|
232
|
+
consumed = {key for key in (ident_key, name_key) if key}
|
|
233
|
+
# A truncated name still needs its full text in the body.
|
|
234
|
+
if name and row.get(name_key) is not None and name.endswith("…"):
|
|
235
|
+
consumed.discard(name_key)
|
|
236
|
+
return label, consumed
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _table_columns(
|
|
240
|
+
rows: Sequence[dict], node: Any, index: SchemaIndex
|
|
241
|
+
) -> list[str] | None:
|
|
242
|
+
"""Column order if *rows* render as a table, else ``None``.
|
|
243
|
+
|
|
244
|
+
A table has to stay aligned to beat block form, so every cell must be a
|
|
245
|
+
short scalar. One prose field is enough to send the whole set to blocks.
|
|
246
|
+
"""
|
|
247
|
+
if len(rows) < 2:
|
|
248
|
+
return None
|
|
249
|
+
columns: list[str] = []
|
|
250
|
+
for key in index.key_order(index.item(node)):
|
|
251
|
+
if key not in HUMAN_ONLY_KEYS and any(key in row for row in rows):
|
|
252
|
+
columns.append(key)
|
|
253
|
+
for row in rows:
|
|
254
|
+
for key in row:
|
|
255
|
+
if key not in columns and key not in HUMAN_ONLY_KEYS:
|
|
256
|
+
columns.append(key)
|
|
257
|
+
if not columns or len(columns) > TABLE_MAX_COLUMNS:
|
|
258
|
+
return None
|
|
259
|
+
for row in rows:
|
|
260
|
+
for key in columns:
|
|
261
|
+
cell = row.get(key)
|
|
262
|
+
if not _is_scalar(cell) or len(to_cell_text(cell)) > TABLE_MAX_CELL:
|
|
263
|
+
return None
|
|
264
|
+
return columns
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _render_table(rows: Sequence[dict], columns: Sequence[str]) -> str:
|
|
268
|
+
header = "| " + " | ".join(humanise(column) for column in columns) + " |"
|
|
269
|
+
rule = "|" + "|".join(" --- " for _ in columns) + "|"
|
|
270
|
+
body = [
|
|
271
|
+
"| " + " | ".join(to_cell_text(row.get(column)) for column in columns) + " |"
|
|
272
|
+
for row in rows
|
|
273
|
+
]
|
|
274
|
+
return "\n".join([header, rule, *body])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _inline_sequence(value: Sequence[Any]) -> str | None:
|
|
278
|
+
"""One comma-joined line if every item is a short token, else ``None``."""
|
|
279
|
+
parts: list[str] = []
|
|
280
|
+
for item in value:
|
|
281
|
+
if item is None or not _is_scalar(item):
|
|
282
|
+
return None
|
|
283
|
+
text = str(item).strip()
|
|
284
|
+
if not text or len(text) > INLINE_ITEM_MAX_CHARS or not _TOKEN_RE.match(text):
|
|
285
|
+
return None
|
|
286
|
+
parts.append(scalar_text(item))
|
|
287
|
+
return ", ".join(parts) if parts else None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _inline_field(value: Any) -> str | None:
|
|
291
|
+
"""The one-line form of a mapping field, or ``None`` if it needs a section."""
|
|
292
|
+
if _is_empty(value) and not isinstance(value, (int, float)):
|
|
293
|
+
return EMPTY_MARKER
|
|
294
|
+
if _is_inline(value):
|
|
295
|
+
return scalar_text(value)
|
|
296
|
+
if isinstance(value, list):
|
|
297
|
+
return _inline_sequence(value)
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _render_sequence(value: Sequence[Any], node: Any, index: SchemaIndex, level: int) -> str:
|
|
302
|
+
if all(_is_inline(item) for item in value):
|
|
303
|
+
return _inline_sequence(value) or "\n".join(
|
|
304
|
+
f"- {scalar_text(item)}" for item in value
|
|
305
|
+
)
|
|
306
|
+
if all(isinstance(item, dict) for item in value):
|
|
307
|
+
columns = _table_columns(value, node, index)
|
|
308
|
+
if columns:
|
|
309
|
+
return _render_table(value, columns)
|
|
310
|
+
item_node = index.item(node)
|
|
311
|
+
blocks: list[str] = []
|
|
312
|
+
for position, item in enumerate(value, start=1):
|
|
313
|
+
label, consumed = _row_label(item, position)
|
|
314
|
+
blocks.append(heading(level, label))
|
|
315
|
+
if isinstance(item, dict):
|
|
316
|
+
blocks.append(
|
|
317
|
+
_render_mapping(item, item_node, index, level + 1, skip=consumed)
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
blocks.append(
|
|
321
|
+
render_value(item, node=item_node, index=index, level=level + 1)
|
|
322
|
+
)
|
|
323
|
+
return _join_blocks(blocks)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _render_mapping(
|
|
327
|
+
value: dict,
|
|
328
|
+
node: Any,
|
|
329
|
+
index: SchemaIndex,
|
|
330
|
+
level: int,
|
|
331
|
+
skip: Iterable[str] = (),
|
|
332
|
+
) -> str:
|
|
333
|
+
blocks: list[str] = []
|
|
334
|
+
bullets: list[str] = []
|
|
335
|
+
for key in _visible_keys(value, node, index, skip):
|
|
336
|
+
child = value[key]
|
|
337
|
+
label = humanise(key)
|
|
338
|
+
inline = _inline_field(child)
|
|
339
|
+
if inline is not None:
|
|
340
|
+
bullets.append(f"- **{label}**: {inline}")
|
|
341
|
+
continue
|
|
342
|
+
if bullets:
|
|
343
|
+
blocks.append("\n".join(bullets))
|
|
344
|
+
bullets = []
|
|
345
|
+
blocks.append(heading(level, label))
|
|
346
|
+
blocks.append(
|
|
347
|
+
render_value(child, node=index.child(node, key), index=index, level=level + 1)
|
|
348
|
+
)
|
|
349
|
+
if bullets:
|
|
350
|
+
blocks.append("\n".join(bullets))
|
|
351
|
+
return _join_blocks(blocks)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def render_value(value: Any, *, node: Any, index: SchemaIndex, level: int) -> str:
|
|
355
|
+
"""Render one `data.json` subtree as a Markdown block."""
|
|
356
|
+
if _is_empty(value) and not isinstance(value, (int, float)):
|
|
357
|
+
return EMPTY_MARKER
|
|
358
|
+
if _is_scalar(value):
|
|
359
|
+
return scalar_text(value)
|
|
360
|
+
if isinstance(value, list):
|
|
361
|
+
return _render_sequence(value, node, index, level)
|
|
362
|
+
if isinstance(value, dict):
|
|
363
|
+
return _render_mapping(value, node, index, level)
|
|
364
|
+
return scalar_text(value)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class ReportSections:
|
|
368
|
+
"""Template-facing view of `data.json` addressed by dotted path.
|
|
369
|
+
|
|
370
|
+
Templates name the sections they want in the order a reading agent should
|
|
371
|
+
meet them; `rest()` then sweeps whatever the template did not name, so a
|
|
372
|
+
field added to the schema reaches the Markdown without a template edit.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
def __init__(self, data: dict, schema: Any) -> None:
|
|
376
|
+
self._data = data
|
|
377
|
+
self._index = SchemaIndex(schema)
|
|
378
|
+
self._schema = schema
|
|
379
|
+
self._rendered: set[str] = set()
|
|
380
|
+
|
|
381
|
+
def _walk(self, path: str) -> tuple[Any, Any, bool]:
|
|
382
|
+
value: Any = self._data
|
|
383
|
+
node: Any = self._schema
|
|
384
|
+
for part in path.split("."):
|
|
385
|
+
if not isinstance(value, dict) or part not in value:
|
|
386
|
+
return None, {}, False
|
|
387
|
+
node = self._index.child(node, part)
|
|
388
|
+
value = value[part]
|
|
389
|
+
return value, node, True
|
|
390
|
+
|
|
391
|
+
def has(self, path: str) -> bool:
|
|
392
|
+
value, _, found = self._walk(path)
|
|
393
|
+
return found and not _is_empty(value)
|
|
394
|
+
|
|
395
|
+
def section(self, path: str, level: int = 3) -> str:
|
|
396
|
+
self._rendered.add(path)
|
|
397
|
+
value, node, found = self._walk(path)
|
|
398
|
+
if not found:
|
|
399
|
+
return EMPTY_MARKER
|
|
400
|
+
return render_value(value, node=node, index=self._index, level=level)
|
|
401
|
+
|
|
402
|
+
def _is_claimed(self, path: str) -> bool:
|
|
403
|
+
"""True once *path* — or anything under it — has been rendered.
|
|
404
|
+
|
|
405
|
+
A template that renders `implementationPlanning.stageMap` has claimed
|
|
406
|
+
part of `implementationPlanning`; a later `rest("")` sweep must not
|
|
407
|
+
emit the whole block a second time.
|
|
408
|
+
"""
|
|
409
|
+
return any(
|
|
410
|
+
rendered == path or rendered.startswith(f"{path}.")
|
|
411
|
+
for rendered in self._rendered
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def rest(self, prefix: str, level: int = 3) -> str:
|
|
415
|
+
"""Every child of *prefix* no `section()` call has already rendered."""
|
|
416
|
+
container, node, found = (
|
|
417
|
+
self._walk(prefix) if prefix else (self._data, self._schema, True)
|
|
418
|
+
)
|
|
419
|
+
if not found or not isinstance(container, dict):
|
|
420
|
+
return ""
|
|
421
|
+
blocks: list[str] = []
|
|
422
|
+
for key in _visible_keys(container, node, self._index):
|
|
423
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
424
|
+
if self._is_claimed(path) or _is_empty(container[key]):
|
|
425
|
+
continue
|
|
426
|
+
self._rendered.add(path)
|
|
427
|
+
blocks.append(heading(level, humanise(key)))
|
|
428
|
+
blocks.append(
|
|
429
|
+
render_value(
|
|
430
|
+
container[key],
|
|
431
|
+
node=self._index.child(node, key),
|
|
432
|
+
index=self._index,
|
|
433
|
+
level=level + 1,
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
return _join_blocks(blocks)
|
|
437
|
+
|
|
438
|
+
def mark_rendered(self, *paths: str) -> str:
|
|
439
|
+
"""Claim paths the spine renders by hand so `rest()` skips them."""
|
|
440
|
+
self._rendered.update(paths)
|
|
441
|
+
return ""
|