deepcell-cli 0.6.1__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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
"""``deepcell describe`` — canonical server-side schema discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from deepcell_cli.commands._version_display import echo_version_history
|
|
8
|
+
from deepcell_cli._findings import cited_rules_footer, rule_citation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_warning(finding: dict) -> bool:
|
|
12
|
+
"""True for advisory findings that must not gate the exit code.
|
|
13
|
+
|
|
14
|
+
Severity is optional on describe findings and its absence means error —
|
|
15
|
+
that is the behaviour every code had before any of them declared one, and
|
|
16
|
+
defaulting the other way would silently un-gate a real check whose author
|
|
17
|
+
simply did not know the field existed.
|
|
18
|
+
"""
|
|
19
|
+
return str(finding.get("severity") or "").strip().lower() == "warn"
|
|
20
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
21
|
+
from deepcell_cli.output import echo_info, output, print_plain
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_MAX_ORPHAN_SAMPLES = 3
|
|
25
|
+
|
|
26
|
+
# Issue #1371 — one line per kind, then up to this many coordinates under it.
|
|
27
|
+
# A model that trips this trips it in bulk — a status left undeclared on a
|
|
28
|
+
# period reports once per cell on it — and 32 sentences read as 32 separate
|
|
29
|
+
# problems rather than one repeated.
|
|
30
|
+
_MAX_STATUS_DIAGNOSTIC_SAMPLES = 3
|
|
31
|
+
|
|
32
|
+
# Human labels for the `kind` values in `RenderPlan.status_diagnostics`. An
|
|
33
|
+
# unknown kind falls back to its id: `kind` is a bare string on the wire so a
|
|
34
|
+
# server that learns a fourth kind stays additive, and printing the id beats
|
|
35
|
+
# dropping the group.
|
|
36
|
+
_STATUS_DIAGNOSTIC_LABELS = {
|
|
37
|
+
"ambiguous_status": "several statuses on one cell; one is shown",
|
|
38
|
+
"ambiguous_write_status": "a recompute had to pick which status to write",
|
|
39
|
+
# Retired by #1586: a period's statusRef no longer narrows a calc off a
|
|
40
|
+
# period it explicitly lists, so nothing emits this any more. The label
|
|
41
|
+
# stays because a RenderPlan cached before that change can still carry one,
|
|
42
|
+
# and printing "narrowed" beats printing a bare kind id.
|
|
43
|
+
"incompatible_status_context": "calc narrowed off a period by a status clash "
|
|
44
|
+
"(retired — no longer emitted)",
|
|
45
|
+
"unresolved_status": "status unresolved; the cell renders BLANK while "
|
|
46
|
+
"holding real values",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Optional item attributes appended after the type tag when present. Mirrors
|
|
50
|
+
# backend jingwei_api/utils/render_plan_schema.py:_OPTIONAL_ITEM_ATTRS.
|
|
51
|
+
_OPTIONAL_ITEM_ATTRS = ("scale", "currency", "order", "parentItem")
|
|
52
|
+
|
|
53
|
+
# The five dimensions, always in the order a cell's identity is spelled.
|
|
54
|
+
# Mirrors backend `render_plan_schema.py:_DIMENSION_ORDER`.
|
|
55
|
+
_DIMENSION_ORDER = (
|
|
56
|
+
("items", "items"),
|
|
57
|
+
("contexts", "contexts"),
|
|
58
|
+
("statuses", "statuses"),
|
|
59
|
+
("scenarios", "scenarios"),
|
|
60
|
+
("customDimensions", "custom dims"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _quoted_name(name: object, fallback: object) -> str:
|
|
65
|
+
"""Return ``"name"`` when it adds info over the id, else an empty string."""
|
|
66
|
+
if name and name != fallback:
|
|
67
|
+
return f' "{name}"'
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _format_block(block: dict) -> str:
|
|
72
|
+
"""Render one block entry as ``id ("name") [kind]``.
|
|
73
|
+
|
|
74
|
+
Mirrors backend ``render_plan_schema.py:_format_block``. The kind is
|
|
75
|
+
suffixed only for non-table blocks: charts and text blocks emit no grid
|
|
76
|
+
rows, so `query --sheet` cannot render them back and this listing is where
|
|
77
|
+
the author confirms they landed.
|
|
78
|
+
"""
|
|
79
|
+
if not isinstance(block, dict):
|
|
80
|
+
return str(block)
|
|
81
|
+
text = f"{block.get('id')}" + _quoted_name(block.get("name"), block.get("id"))
|
|
82
|
+
kind = block.get("kind")
|
|
83
|
+
if kind and kind != "table":
|
|
84
|
+
text += f" [{kind}]"
|
|
85
|
+
return text
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _summarize_meta_value(value: object) -> str:
|
|
89
|
+
"""Render one metadata value inline: scalars pass through, def lists
|
|
90
|
+
flatten to ``id ("label") [N members]``."""
|
|
91
|
+
if isinstance(value, list):
|
|
92
|
+
parts: list[str] = []
|
|
93
|
+
for el in value:
|
|
94
|
+
if isinstance(el, dict):
|
|
95
|
+
part = str(el.get("id"))
|
|
96
|
+
label = el.get("label")
|
|
97
|
+
if label:
|
|
98
|
+
part += f' ("{label}")'
|
|
99
|
+
members = el.get("members")
|
|
100
|
+
if members:
|
|
101
|
+
part += f" [{len(members)} members]"
|
|
102
|
+
parts.append(part)
|
|
103
|
+
else:
|
|
104
|
+
parts.append(str(el))
|
|
105
|
+
return ", ".join(parts)
|
|
106
|
+
return str(value)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#: Members named on one per-sheet dimension line before it folds. Mirrors the
|
|
110
|
+
#: backend constant; every id also appears in full under `definitions`, and the
|
|
111
|
+
#: reader who wants all of them wants `query --sheet`.
|
|
112
|
+
_MAX_SHEET_DIMENSION_MEMBERS = 20
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fold(parts: list) -> str:
|
|
116
|
+
if len(parts) <= _MAX_SHEET_DIMENSION_MEMBERS:
|
|
117
|
+
return ", ".join(parts)
|
|
118
|
+
shown = parts[:_MAX_SHEET_DIMENSION_MEMBERS]
|
|
119
|
+
return ", ".join(shown) + f", …(+{len(parts) - len(shown)} more)"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _dimension_line(key: str, value: object) -> str:
|
|
123
|
+
"""One dimension's members, inline and folded. Custom dims carry members."""
|
|
124
|
+
if key == "customDimensions":
|
|
125
|
+
return _fold([
|
|
126
|
+
f"{d.get('id')} ({', '.join(str(m) for m in d.get('members') or [])})"
|
|
127
|
+
if d.get("members")
|
|
128
|
+
else str(d.get("id"))
|
|
129
|
+
for d in value # type: ignore[union-attr]
|
|
130
|
+
if isinstance(d, dict)
|
|
131
|
+
])
|
|
132
|
+
return _fold([str(v) for v in value]) # type: ignore[union-attr]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _sheet_lines(sheets: list) -> list[str]:
|
|
136
|
+
"""Sheets, each followed by the five dimensions it actually renders."""
|
|
137
|
+
lines: list[str] = ["Sheets:"]
|
|
138
|
+
for s in sheets:
|
|
139
|
+
head = f" - {s.get('id')}" + _quoted_name(s.get("name"), s.get("id"))
|
|
140
|
+
blocks = s.get("blocks") or []
|
|
141
|
+
if blocks:
|
|
142
|
+
head += " — blocks: " + ", ".join(_format_block(b) for b in blocks)
|
|
143
|
+
lines.append(head)
|
|
144
|
+
dims = s.get("dimensions") or {}
|
|
145
|
+
# `counts` carries the true size of any list the server shortened, so the
|
|
146
|
+
# header still reports what the sheet renders.
|
|
147
|
+
counts = dims.get("counts") or {}
|
|
148
|
+
for key, label in _DIMENSION_ORDER:
|
|
149
|
+
value = dims.get(key)
|
|
150
|
+
if not value:
|
|
151
|
+
continue
|
|
152
|
+
total = counts.get(key, len(value))
|
|
153
|
+
lines.append(f" {label} ({total}): {_dimension_line(key, value)}")
|
|
154
|
+
return lines
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _definitions_lines(definitions: dict) -> list[str]:
|
|
158
|
+
"""The declared five dimensions, with the labels the ids stand for."""
|
|
159
|
+
lines: list[str] = [
|
|
160
|
+
"Definitions — the five dimensions that key a cell "
|
|
161
|
+
"(Item × Context × Status × Scenario × CustomDimensions):"
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
items = definitions.get("items") or []
|
|
165
|
+
if items:
|
|
166
|
+
lines.append(f" Items ({len(items)}):")
|
|
167
|
+
for it in items:
|
|
168
|
+
level = it.get("level") or 0
|
|
169
|
+
indent = " " + " " * (level if isinstance(level, int) and level >= 0 else 0)
|
|
170
|
+
line = f"{indent}{it.get('id')}" + _quoted_name(it.get("label"), it.get("id"))
|
|
171
|
+
line += f" [{it.get('type', 'number')}]"
|
|
172
|
+
extras = [
|
|
173
|
+
f"{attr}={it[attr]}"
|
|
174
|
+
for attr in _OPTIONAL_ITEM_ATTRS
|
|
175
|
+
if it.get(attr) is not None
|
|
176
|
+
]
|
|
177
|
+
if extras:
|
|
178
|
+
line += " (" + ", ".join(extras) + ")"
|
|
179
|
+
lines.append(line)
|
|
180
|
+
|
|
181
|
+
contexts = definitions.get("contexts") or []
|
|
182
|
+
if contexts:
|
|
183
|
+
lines.append(
|
|
184
|
+
f" Contexts ({len(contexts)}): " + ", ".join(str(c) for c in contexts)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
statuses = definitions.get("statuses") or []
|
|
188
|
+
if statuses:
|
|
189
|
+
lines.append(
|
|
190
|
+
f" Statuses ({len(statuses)}): "
|
|
191
|
+
+ ", ".join(
|
|
192
|
+
f"{s.get('id')}" + _quoted_name(s.get("label"), s.get("id"))
|
|
193
|
+
for s in statuses
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
scenarios = definitions.get("scenarios") or []
|
|
198
|
+
if scenarios:
|
|
199
|
+
lines.append(
|
|
200
|
+
f" Scenarios ({len(scenarios)}): "
|
|
201
|
+
+ ", ".join(
|
|
202
|
+
f"{s.get('id')}"
|
|
203
|
+
+ _quoted_name(s.get("label"), s.get("id"))
|
|
204
|
+
+ (" (default)" if s.get("isDefault") else "")
|
|
205
|
+
for s in scenarios
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
dims = definitions.get("customDimensions") or []
|
|
210
|
+
if dims:
|
|
211
|
+
lines.append(f" Custom dimensions ({len(dims)}):")
|
|
212
|
+
for d in dims:
|
|
213
|
+
members = d.get("members") or []
|
|
214
|
+
lines.append(
|
|
215
|
+
f" {d.get('id')}"
|
|
216
|
+
+ _quoted_name(d.get("label"), d.get("id"))
|
|
217
|
+
+ f" — {len(members)} members: "
|
|
218
|
+
+ ", ".join(
|
|
219
|
+
f"{m.get('id')}" + _quoted_name(m.get("label"), m.get("id"))
|
|
220
|
+
for m in members
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return lines
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _sources_lines(sources: list) -> list[str]:
|
|
227
|
+
"""The `<Source>` registry — one line each, id first."""
|
|
228
|
+
lines: list[str] = [f"Sources ({len(sources)}):"]
|
|
229
|
+
for source in sources:
|
|
230
|
+
line = f" {source.get('id')}"
|
|
231
|
+
if source.get("title"):
|
|
232
|
+
line += f' "{source["title"]}"'
|
|
233
|
+
tags = [source.get(key) for key in ("kind", "role") if source.get(key)]
|
|
234
|
+
if tags:
|
|
235
|
+
line += " [" + ", ".join(str(t) for t in tags) + "]"
|
|
236
|
+
if source.get("url"):
|
|
237
|
+
line += f" — {source['url']}"
|
|
238
|
+
lines.append(line)
|
|
239
|
+
return lines
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _documents_lines(documents: list) -> list[str]:
|
|
243
|
+
"""Each memo's title and heading structure — the shape, not the prose."""
|
|
244
|
+
lines: list[str] = [f"Documents ({len(documents)}):"]
|
|
245
|
+
for doc in documents:
|
|
246
|
+
head = f" - {doc.get('id')}" + _quoted_name(doc.get("name"), doc.get("id"))
|
|
247
|
+
if doc.get("lang"):
|
|
248
|
+
head += f" [{doc['lang']}]"
|
|
249
|
+
head += f" — {doc.get('block_count', 0)} blocks, {doc.get('link_count', 0)} links"
|
|
250
|
+
if doc.get("unresolved_link_count"):
|
|
251
|
+
head += f", {doc['unresolved_link_count']} UNRESOLVED"
|
|
252
|
+
lines.append(head)
|
|
253
|
+
for section in doc.get("sections") or []:
|
|
254
|
+
level = section.get("level") or 1
|
|
255
|
+
indent = " " + " " * max(0, int(level) - 1)
|
|
256
|
+
line = f"{indent}{'#' * int(level)} {section.get('title')}"
|
|
257
|
+
anchor = section.get("anchor")
|
|
258
|
+
if anchor:
|
|
259
|
+
line += (
|
|
260
|
+
f" {{#{anchor}}}" if section.get("addressable") else f" (slug: {anchor})"
|
|
261
|
+
)
|
|
262
|
+
lines.append(line)
|
|
263
|
+
for warning in doc.get("warnings") or []:
|
|
264
|
+
lines.append(f" ! {warning}")
|
|
265
|
+
return lines
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _decks_lines(decks: list) -> list[str]:
|
|
269
|
+
"""Each deck and its slides in order."""
|
|
270
|
+
lines: list[str] = [f"Decks ({len(decks)}):"]
|
|
271
|
+
for deck in decks:
|
|
272
|
+
head = f" - {deck.get('id')}" + _quoted_name(deck.get("name"), deck.get("id"))
|
|
273
|
+
if deck.get("aspect_ratio"):
|
|
274
|
+
head += f" [{deck['aspect_ratio']}]"
|
|
275
|
+
slides = deck.get("slides") or []
|
|
276
|
+
head += f" — {len(slides)} slides, {deck.get('binding_count', 0)} bindings"
|
|
277
|
+
lines.append(head)
|
|
278
|
+
for index, slide in enumerate(slides, start=1):
|
|
279
|
+
lines.append(
|
|
280
|
+
f" {index}. {slide.get('id')}"
|
|
281
|
+
+ _quoted_name(slide.get("name"), slide.get("id"))
|
|
282
|
+
)
|
|
283
|
+
unresolved = deck.get("unresolved_bindings") or []
|
|
284
|
+
if unresolved:
|
|
285
|
+
lines.append(
|
|
286
|
+
" ! unresolved bindings: "
|
|
287
|
+
+ ", ".join(str(b) for b in unresolved)
|
|
288
|
+
+ " — these render their fallback text and look intentional."
|
|
289
|
+
)
|
|
290
|
+
for warning in deck.get("warnings") or []:
|
|
291
|
+
lines.append(f" ! {warning}")
|
|
292
|
+
return lines
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
#: Rows per reading-path section, and how deep a row's subtree is followed.
|
|
296
|
+
#: Mirrors the backend constants; `-f json` keeps the whole outline, and
|
|
297
|
+
#: `deepcell reasoning graph` renders the full graph.
|
|
298
|
+
_MAX_SECTION_ROWS = 12
|
|
299
|
+
_MAX_ROW_DEPTH = 3
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _outline_descendants(nodes: list) -> int:
|
|
303
|
+
"""How many rows a set of subtrees would have printed."""
|
|
304
|
+
return sum(1 + _outline_descendants(node.get("children") or []) for node in nodes)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _outline_row(
|
|
308
|
+
node: dict,
|
|
309
|
+
indent: str,
|
|
310
|
+
*,
|
|
311
|
+
show_via: bool = False,
|
|
312
|
+
depth: int = _MAX_ROW_DEPTH,
|
|
313
|
+
) -> list[str]:
|
|
314
|
+
"""One reading-path row and its subtree, to ``_MAX_ROW_DEPTH``. The id leads:
|
|
315
|
+
a reader who wants to *act* on a row needs it, and a label is a sentence."""
|
|
316
|
+
tag = node.get("kind") or node.get("status") or ""
|
|
317
|
+
parts = [f"{indent}{node.get('id')}"]
|
|
318
|
+
if tag:
|
|
319
|
+
parts.append(f"[{tag}]")
|
|
320
|
+
parts.append(str(node.get("label") or ""))
|
|
321
|
+
line = " ".join(part for part in parts if part)
|
|
322
|
+
if show_via and node.get("via"):
|
|
323
|
+
line += f" — on \"{node['via'].get('label')}\""
|
|
324
|
+
lines = [line]
|
|
325
|
+
children = node.get("children") or []
|
|
326
|
+
if not children:
|
|
327
|
+
return lines
|
|
328
|
+
if depth <= 0:
|
|
329
|
+
# Say how much was folded. A silently shortened chain reads as a chain
|
|
330
|
+
# that ends there, which is the one thing a reading path must not say.
|
|
331
|
+
lines.append(f"{indent} …({_outline_descendants(children)} deeper)")
|
|
332
|
+
return lines
|
|
333
|
+
for child in children:
|
|
334
|
+
lines.extend(
|
|
335
|
+
_outline_row(child, indent + " ", show_via=show_via, depth=depth - 1)
|
|
336
|
+
)
|
|
337
|
+
return lines
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
#: Subtree depth for the three sections that sit *beside* the argument rather
|
|
341
|
+
#: than in it — "How this was built", "Also cited", "Other threads". Mirrors the
|
|
342
|
+
#: backend constant.
|
|
343
|
+
_APPENDIX_ROW_DEPTH = 1
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _outline_section(
|
|
347
|
+
nodes: list,
|
|
348
|
+
indent: str,
|
|
349
|
+
*,
|
|
350
|
+
show_via: bool = False,
|
|
351
|
+
depth: int = _MAX_ROW_DEPTH,
|
|
352
|
+
) -> list[str]:
|
|
353
|
+
"""A capped run of sibling rows, with what was cut named."""
|
|
354
|
+
lines: list[str] = []
|
|
355
|
+
for node in nodes[:_MAX_SECTION_ROWS]:
|
|
356
|
+
lines.extend(_outline_row(node, indent, show_via=show_via, depth=depth))
|
|
357
|
+
remaining = len(nodes) - _MAX_SECTION_ROWS
|
|
358
|
+
if remaining > 0:
|
|
359
|
+
lines.append(f"{indent}…(+{remaining} more)")
|
|
360
|
+
return lines
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _outline_to_plain(outline: dict, indent: str = " ") -> list[str]:
|
|
364
|
+
"""The reading path as lines.
|
|
365
|
+
|
|
366
|
+
Mirrors ``jingwei_api/utils/reasoning_outline.py:outline_to_text`` — the
|
|
367
|
+
same six questions, in the same order, as the Reasoning workspace.
|
|
368
|
+
"""
|
|
369
|
+
if not isinstance(outline, dict):
|
|
370
|
+
return []
|
|
371
|
+
lines: list[str] = []
|
|
372
|
+
step = indent + " "
|
|
373
|
+
|
|
374
|
+
question = outline.get("keyQuestion")
|
|
375
|
+
if question:
|
|
376
|
+
heading = "Key question"
|
|
377
|
+
source = outline.get("keyQuestionSource")
|
|
378
|
+
if source:
|
|
379
|
+
heading += f" ({source})"
|
|
380
|
+
if outline.get("keyQuestionNeedsReview"):
|
|
381
|
+
heading += " — needs review"
|
|
382
|
+
lines.append(f"{indent}{heading}")
|
|
383
|
+
lines.append(f"{step}{question.get('id')} {question.get('label')}")
|
|
384
|
+
|
|
385
|
+
conclusions = outline.get("conclusions") or []
|
|
386
|
+
if conclusions:
|
|
387
|
+
heading = (
|
|
388
|
+
"Main conclusion"
|
|
389
|
+
if len(conclusions) == 1
|
|
390
|
+
else f"Conclusions ({len(conclusions)})"
|
|
391
|
+
)
|
|
392
|
+
source = conclusions[0].get("source")
|
|
393
|
+
if source:
|
|
394
|
+
heading += f" ({source})"
|
|
395
|
+
lines.append(f"{indent}{heading}")
|
|
396
|
+
# The conclusion is the one thing this output exists to say, so it is
|
|
397
|
+
# never folded away — only its subtree is depth-capped like any other.
|
|
398
|
+
for node in conclusions:
|
|
399
|
+
lines.extend(_outline_row(node, step))
|
|
400
|
+
for node in outline.get("comparedWith") or []:
|
|
401
|
+
lines.extend(_outline_row(node, f"{step}compared with: "))
|
|
402
|
+
|
|
403
|
+
for key, label in (
|
|
404
|
+
("reasons", "Reasons"),
|
|
405
|
+
("conditions", "Conditions"),
|
|
406
|
+
("challenges", "Challenges"),
|
|
407
|
+
):
|
|
408
|
+
nodes = outline.get(key) or []
|
|
409
|
+
if not nodes:
|
|
410
|
+
continue
|
|
411
|
+
lines.append(f"{indent}{label} ({len(nodes)})")
|
|
412
|
+
lines.extend(_outline_section(nodes, step, show_via=key == "challenges"))
|
|
413
|
+
|
|
414
|
+
evidence = outline.get("evidence") or []
|
|
415
|
+
if evidence:
|
|
416
|
+
lines.append(f"{indent}Evidence ({len(evidence)})")
|
|
417
|
+
for item in evidence[:_MAX_SECTION_ROWS]:
|
|
418
|
+
line = f"{step}{item.get('id')} {item.get('label')}"
|
|
419
|
+
used_by = [ref.get("id") for ref in item.get("usedBy") or []]
|
|
420
|
+
if used_by:
|
|
421
|
+
line += f" — cited by {', '.join(str(u) for u in used_by)}"
|
|
422
|
+
lines.append(line)
|
|
423
|
+
if len(evidence) > _MAX_SECTION_ROWS:
|
|
424
|
+
lines.append(f"{step}…(+{len(evidence) - _MAX_SECTION_ROWS} more)")
|
|
425
|
+
|
|
426
|
+
for key, label in (
|
|
427
|
+
("methodNotes", "How this was built"),
|
|
428
|
+
("alsoCited", "Also cited"),
|
|
429
|
+
("otherThreads", "Other threads"),
|
|
430
|
+
):
|
|
431
|
+
nodes = outline.get(key) or []
|
|
432
|
+
if not nodes:
|
|
433
|
+
continue
|
|
434
|
+
lines.append(f"{indent}{label} ({len(nodes)})")
|
|
435
|
+
lines.extend(_outline_section(nodes, step, depth=_APPENDIX_ROW_DEPTH))
|
|
436
|
+
|
|
437
|
+
loose = outline.get("looseEnds") or []
|
|
438
|
+
if loose:
|
|
439
|
+
lines.append(
|
|
440
|
+
f"{indent}Loose ends ({len(loose)}) — reachable from nothing the "
|
|
441
|
+
"conclusion argues"
|
|
442
|
+
)
|
|
443
|
+
for finding in loose[:_MAX_SECTION_ROWS]:
|
|
444
|
+
if finding.get("cause") == "circular":
|
|
445
|
+
members = " → ".join(
|
|
446
|
+
str(node.get("id")) for node in finding.get("nodes") or []
|
|
447
|
+
)
|
|
448
|
+
lines.append(f"{step}circular: {members}")
|
|
449
|
+
else:
|
|
450
|
+
node = finding.get("node") or {}
|
|
451
|
+
suffix = (
|
|
452
|
+
f" — parent {finding['ref']} is missing"
|
|
453
|
+
if finding.get("cause") == "broken_parent"
|
|
454
|
+
else " — nothing cites or depends on it"
|
|
455
|
+
)
|
|
456
|
+
lines.append(f"{step}{node.get('id')} {node.get('label')}{suffix}")
|
|
457
|
+
if len(loose) > _MAX_SECTION_ROWS:
|
|
458
|
+
lines.append(f"{step}…(+{len(loose) - _MAX_SECTION_ROWS} more)")
|
|
459
|
+
|
|
460
|
+
for diagnostic in outline.get("diagnostics") or []:
|
|
461
|
+
lines.append(f"{indent}! {diagnostic.get('message')}")
|
|
462
|
+
|
|
463
|
+
return lines
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _schema_to_plain(schema: dict) -> str:
|
|
467
|
+
"""Render the describe schema as compact plain text.
|
|
468
|
+
|
|
469
|
+
Layout mirrors the backend's ``schema_to_text`` (the agent-tool rendering
|
|
470
|
+
of the same schema), extended with the ``--lint`` findings section — pre-fix,
|
|
471
|
+
plain mode printed only counts, so ``--lint`` exited 1 without ever saying
|
|
472
|
+
why.
|
|
473
|
+
"""
|
|
474
|
+
lines: list[str] = [f"Model: {schema.get('filename') or '(unnamed)'}"]
|
|
475
|
+
|
|
476
|
+
stats = schema.get("stats") or {}
|
|
477
|
+
# `item_count` counts table rows, so a chart-only document reported
|
|
478
|
+
# "0 items" — corroborating the unplaced-item report it also emitted, and
|
|
479
|
+
# both were wrong. Say how many are drawn somewhere other than a grid.
|
|
480
|
+
off_grid_count = stats.get("off_grid_item_count") or 0
|
|
481
|
+
item_count = f"{stats.get('item_count', 0)} items"
|
|
482
|
+
if off_grid_count:
|
|
483
|
+
item_count += f" (+{off_grid_count} off-grid)"
|
|
484
|
+
counts = [
|
|
485
|
+
f"{stats.get('sheet_count', 0)} sheets",
|
|
486
|
+
f"{stats.get('block_count', 0)} blocks",
|
|
487
|
+
item_count,
|
|
488
|
+
f"{stats.get('context_count', 0)} contexts",
|
|
489
|
+
]
|
|
490
|
+
for key, noun in (
|
|
491
|
+
("status_count", "statuses"),
|
|
492
|
+
("scenario_count", "scenarios"),
|
|
493
|
+
("custom_dimension_count", "custom dims"),
|
|
494
|
+
("document_count", "documents"),
|
|
495
|
+
("deck_count", "decks"),
|
|
496
|
+
("source_count", "sources"),
|
|
497
|
+
):
|
|
498
|
+
if stats.get(key):
|
|
499
|
+
counts.append(f"{stats[key]} {noun}")
|
|
500
|
+
lines.append("Stats: " + ", ".join(counts))
|
|
501
|
+
|
|
502
|
+
sheets = schema.get("sheets") or []
|
|
503
|
+
if sheets:
|
|
504
|
+
lines.append("")
|
|
505
|
+
lines.extend(_sheet_lines(sheets))
|
|
506
|
+
|
|
507
|
+
definitions = schema.get("definitions") or {}
|
|
508
|
+
if any(definitions.get(key) for key, _ in _DIMENSION_ORDER):
|
|
509
|
+
lines.append("")
|
|
510
|
+
lines.extend(_definitions_lines(definitions))
|
|
511
|
+
|
|
512
|
+
unplaced = schema.get("unplaced_items") or []
|
|
513
|
+
if unplaced:
|
|
514
|
+
lines.append("")
|
|
515
|
+
lines.append(f"Defined but not placed ({len(unplaced)}):")
|
|
516
|
+
for it in unplaced:
|
|
517
|
+
lines.append(
|
|
518
|
+
f" {it.get('id')}" + _quoted_name(it.get("name"), it.get("id"))
|
|
519
|
+
)
|
|
520
|
+
lines.append(
|
|
521
|
+
" These items exist in <ItemDefinitions> but sit on no sheet, so "
|
|
522
|
+
"they are absent from the item list above, the viewer and every "
|
|
523
|
+
"export. `query` still answers for them. Place one with "
|
|
524
|
+
"`deepcell defs add-axis-member`."
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
off_grid = schema.get("off_grid_items") or []
|
|
528
|
+
if off_grid:
|
|
529
|
+
lines.append("")
|
|
530
|
+
lines.append(f"Placed, but off the grid ({len(off_grid)}):")
|
|
531
|
+
for it in off_grid:
|
|
532
|
+
lines.append(
|
|
533
|
+
f" {it.get('id')}" + _quoted_name(it.get("name"), it.get("id"))
|
|
534
|
+
)
|
|
535
|
+
lines.append(
|
|
536
|
+
" A block claims these — a chart, a sensitivity table or a text "
|
|
537
|
+
"block — and they render there. They are absent from the item "
|
|
538
|
+
"list above only because that list is read off table rows. "
|
|
539
|
+
"Nothing to fix."
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
documents = schema.get("documents") or []
|
|
543
|
+
if documents:
|
|
544
|
+
lines.append("")
|
|
545
|
+
lines.extend(_documents_lines(documents))
|
|
546
|
+
|
|
547
|
+
decks = schema.get("decks") or []
|
|
548
|
+
if decks:
|
|
549
|
+
lines.append("")
|
|
550
|
+
lines.extend(_decks_lines(decks))
|
|
551
|
+
|
|
552
|
+
sources = schema.get("sources") or []
|
|
553
|
+
if sources:
|
|
554
|
+
lines.append("")
|
|
555
|
+
lines.extend(_sources_lines(sources))
|
|
556
|
+
|
|
557
|
+
metadata = schema.get("metadata") or {}
|
|
558
|
+
if metadata:
|
|
559
|
+
lines.append("")
|
|
560
|
+
lines.append("Metadata:")
|
|
561
|
+
for k, v in metadata.items():
|
|
562
|
+
lines.append(f" {k}: {_summarize_meta_value(v)}")
|
|
563
|
+
|
|
564
|
+
reasoning = schema.get("reasoning")
|
|
565
|
+
if isinstance(reasoning, dict):
|
|
566
|
+
lines.append("")
|
|
567
|
+
counts_r = reasoning.get("counts") or {}
|
|
568
|
+
by_kind = reasoning.get("claim_count_by_kind") or {}
|
|
569
|
+
kind_str = (
|
|
570
|
+
" (" + ", ".join(f"{k} {v}" for k, v in by_kind.items()) + ")"
|
|
571
|
+
if by_kind
|
|
572
|
+
else ""
|
|
573
|
+
)
|
|
574
|
+
lines.append(
|
|
575
|
+
"Reasoning: "
|
|
576
|
+
f"{counts_r.get('claims', 0)} claims{kind_str}, "
|
|
577
|
+
f"{counts_r.get('assumptions', 0)} assumptions, "
|
|
578
|
+
f"{counts_r.get('evidence', 0)} evidence, "
|
|
579
|
+
f"{counts_r.get('arguments', 0)} arguments"
|
|
580
|
+
)
|
|
581
|
+
lines.extend(_outline_to_plain(reasoning.get("outline") or {}))
|
|
582
|
+
orphans = reasoning.get("orphans") or []
|
|
583
|
+
if orphans:
|
|
584
|
+
lines.append(
|
|
585
|
+
f" unreferenced nodes ({len(orphans)}): " + ", ".join(map(str, orphans))
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
findings = schema.get("findings")
|
|
589
|
+
if findings:
|
|
590
|
+
def _render(f: dict) -> str:
|
|
591
|
+
# Warnings are prefixed so a reader can tell at a glance which
|
|
592
|
+
# findings gate the exit code and which are advisory.
|
|
593
|
+
marker = "warn " if _is_warning(f) else ""
|
|
594
|
+
return (f" - {marker}{f.get('rule')}{rule_citation(f)}: "
|
|
595
|
+
f"{f.get('message')}")
|
|
596
|
+
|
|
597
|
+
lines.append("")
|
|
598
|
+
# `--since` splits the list in two; without it every finding is
|
|
599
|
+
# unstamped and this renders exactly as it always did.
|
|
600
|
+
pre_existing = [f for f in findings if f.get("pre_existing") is True]
|
|
601
|
+
if pre_existing:
|
|
602
|
+
introduced = [f for f in findings if f.get("pre_existing") is not True]
|
|
603
|
+
lines.append(
|
|
604
|
+
f"Findings ({len(findings)}) — {len(introduced)} new, "
|
|
605
|
+
f"{len(pre_existing)} pre-existing:"
|
|
606
|
+
)
|
|
607
|
+
lines += [_render(f) for f in introduced]
|
|
608
|
+
if not introduced:
|
|
609
|
+
lines.append(" (none new — every finding below predates the "
|
|
610
|
+
"baseline you named)")
|
|
611
|
+
lines.append("")
|
|
612
|
+
lines.append(f" Pre-existing ({len(pre_existing)}) — already there "
|
|
613
|
+
"before the baseline, not introduced by this change:")
|
|
614
|
+
lines += [_render(f) for f in pre_existing]
|
|
615
|
+
else:
|
|
616
|
+
lines.append(f"Findings ({len(findings)}):")
|
|
617
|
+
lines += [_render(f) for f in findings]
|
|
618
|
+
footer = cited_rules_footer(findings)
|
|
619
|
+
if footer:
|
|
620
|
+
lines.append(footer)
|
|
621
|
+
|
|
622
|
+
if schema.get("items_truncated"):
|
|
623
|
+
lines.append("")
|
|
624
|
+
lines.append(schema.get("note") or "Item list truncated.")
|
|
625
|
+
|
|
626
|
+
# Eval S1: the docstring said "use `deepcell query` for values" but the
|
|
627
|
+
# output never did — and describe is the orient-stage command, so agents
|
|
628
|
+
# verified numbers by hand-parsing `cat` XML. End with a copy-pasteable
|
|
629
|
+
# query built from this file's own refs (R4: verify by query-back).
|
|
630
|
+
def_items = definitions.get("items") or []
|
|
631
|
+
def_contexts = definitions.get("contexts") or []
|
|
632
|
+
example_item = str(def_items[0].get("id")) if def_items else "<item>"
|
|
633
|
+
example_ctx = str(def_contexts[0]) if def_contexts else "<context>"
|
|
634
|
+
lines.append("")
|
|
635
|
+
lines.append(
|
|
636
|
+
"Values: the definitions above are not the numbers — read cells back "
|
|
637
|
+
f"with `deepcell query {schema.get('filename') or '<file>'} "
|
|
638
|
+
f"{example_item} {example_ctx}` (rules R4)."
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
return "\n".join(lines)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _format_unrendered_values_warning(orphans: list[dict]) -> str:
|
|
645
|
+
"""Build the human warning shared with structural write commands.
|
|
646
|
+
|
|
647
|
+
One line per uncovered COORDINATE, matching the record the builder emits
|
|
648
|
+
(#1906): a cell stored under two statuses is one gap that one `<Block>`
|
|
649
|
+
closes, so both statuses go on the one line rather than printing it twice.
|
|
650
|
+
The repair is `defs add-axis-member`, which is what edits a live block's
|
|
651
|
+
axes — `--item-orders` / `--context-refs` are `add-block` flags.
|
|
652
|
+
"""
|
|
653
|
+
count = len(orphans)
|
|
654
|
+
plural = "" if count == 1 else "s"
|
|
655
|
+
lines = [
|
|
656
|
+
f"warning: {count} coordinate{plural} not shown on any sheet — they exist",
|
|
657
|
+
" in <Values> and feed the calc engine but render nowhere.",
|
|
658
|
+
" Examples:",
|
|
659
|
+
]
|
|
660
|
+
for orphan in orphans[:_MAX_ORPHAN_SAMPLES]:
|
|
661
|
+
statuses = orphan.get("status_refs") or (
|
|
662
|
+
[orphan["status_ref"]] if orphan.get("status_ref") else []
|
|
663
|
+
)
|
|
664
|
+
dims = "".join(
|
|
665
|
+
f" × {kv[0]}={kv[1]}" for kv in (orphan.get("custom_dimensions") or ())
|
|
666
|
+
)
|
|
667
|
+
lines.append(
|
|
668
|
+
" "
|
|
669
|
+
f"{orphan.get('item_ref', '?')} × {orphan.get('context_ref', '?')} × "
|
|
670
|
+
f"{', '.join(str(s) for s in statuses) or '-'}{dims}"
|
|
671
|
+
f" = {orphan.get('value', '')}"
|
|
672
|
+
)
|
|
673
|
+
remaining = count - _MAX_ORPHAN_SAMPLES
|
|
674
|
+
if remaining > 0:
|
|
675
|
+
lines.append(f" …({remaining} more)")
|
|
676
|
+
lines.extend([
|
|
677
|
+
" Widen a block's rows or columns to cover these item × context",
|
|
678
|
+
" pairs (`deepcell defs add-axis-member`), add a <Block>, or",
|
|
679
|
+
" remove the values, to silence this warning.",
|
|
680
|
+
])
|
|
681
|
+
return "\n".join(lines)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _format_status_diagnostics_warning(diagnostics: list[dict]) -> str:
|
|
685
|
+
"""Build the human warning for `RenderPlan.status_diagnostics` (#1371).
|
|
686
|
+
|
|
687
|
+
Reads the STRUCTURED record, not the prose `message`: grouping by `kind`
|
|
688
|
+
and naming coordinates is exactly what the message channel cannot do, and
|
|
689
|
+
the message is already printed verbatim under `describe`'s "Export notes".
|
|
690
|
+
Shared with `defs apply`, which is where a user *creates* a status clash
|
|
691
|
+
(`defs add-calc --status`, `defs update-context --status`) and so the
|
|
692
|
+
moment the warning is due — not a follow-up command they must know to run.
|
|
693
|
+
|
|
694
|
+
Deliberately a warning and not an exit code, per
|
|
695
|
+
`docs/status-resolution.md` §2.4a: these describe a document that opens and
|
|
696
|
+
renders, and failing hard would stop a working-if-malformed model from
|
|
697
|
+
being read.
|
|
698
|
+
"""
|
|
699
|
+
by_kind: dict[str, list[dict]] = {}
|
|
700
|
+
for diag in diagnostics:
|
|
701
|
+
by_kind.setdefault(str(diag.get("kind") or "unknown"), []).append(diag)
|
|
702
|
+
|
|
703
|
+
count = len(diagnostics)
|
|
704
|
+
plural = "" if count == 1 else "s"
|
|
705
|
+
lines = [
|
|
706
|
+
f"warning: {count} status diagnostic{plural} — the document renders, "
|
|
707
|
+
"but it",
|
|
708
|
+
" leaves a status question open. Not a lint finding; it does "
|
|
709
|
+
"not affect",
|
|
710
|
+
" --lint's exit code. See `deepcell guide verify/query-back`.",
|
|
711
|
+
]
|
|
712
|
+
for kind, group in by_kind.items():
|
|
713
|
+
label = _STATUS_DIAGNOSTIC_LABELS.get(kind, kind)
|
|
714
|
+
lines.append(f" {kind} ({len(group)}) — {label}")
|
|
715
|
+
for diag in group[:_MAX_STATUS_DIAGNOSTIC_SAMPLES]:
|
|
716
|
+
chosen = diag.get("chosen") or "-"
|
|
717
|
+
available = diag.get("available_statuses") or []
|
|
718
|
+
lines.append(
|
|
719
|
+
" "
|
|
720
|
+
f"{diag.get('item_ref', '?')} × {diag.get('context_ref', '?')}"
|
|
721
|
+
f" shows {chosen}"
|
|
722
|
+
+ (f"; also holds {', '.join(str(s) for s in available)}"
|
|
723
|
+
if available else "")
|
|
724
|
+
)
|
|
725
|
+
remaining = len(group) - _MAX_STATUS_DIAGNOSTIC_SAMPLES
|
|
726
|
+
if remaining > 0:
|
|
727
|
+
lines.append(f" …({remaining} more)")
|
|
728
|
+
return "\n".join(lines)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@click.command()
|
|
732
|
+
@click.argument("filename")
|
|
733
|
+
@click.option(
|
|
734
|
+
"--include-reasoning",
|
|
735
|
+
is_flag=True,
|
|
736
|
+
default=False,
|
|
737
|
+
help="Append a compact summary of the document reasoning graph.",
|
|
738
|
+
)
|
|
739
|
+
@click.option(
|
|
740
|
+
"--scenario",
|
|
741
|
+
"scenario_id",
|
|
742
|
+
default=None,
|
|
743
|
+
help="Scenario ID whose value overrides to apply.",
|
|
744
|
+
)
|
|
745
|
+
@click.option(
|
|
746
|
+
"--lint",
|
|
747
|
+
"lint_mode",
|
|
748
|
+
is_flag=True,
|
|
749
|
+
default=False,
|
|
750
|
+
help="Include canonical structural findings; exit 1 on error-level ones. "
|
|
751
|
+
"Warn-level findings print and exit 0 — read them.",
|
|
752
|
+
)
|
|
753
|
+
@click.option(
|
|
754
|
+
"--since",
|
|
755
|
+
"since_revision",
|
|
756
|
+
default=None,
|
|
757
|
+
metavar="REVISION",
|
|
758
|
+
help="With --lint: mark each finding pre-existing or new against this "
|
|
759
|
+
"revision. Use `HEAD~1` for the commit before yours, or a SHA from "
|
|
760
|
+
"`deepcell log`.",
|
|
761
|
+
)
|
|
762
|
+
@click.option(
|
|
763
|
+
"--measure",
|
|
764
|
+
"measure",
|
|
765
|
+
is_flag=True,
|
|
766
|
+
default=False,
|
|
767
|
+
help="With --lint: measure every deck slide that has no fit measurement "
|
|
768
|
+
"yet (one browser pass per slide in the export service) before "
|
|
769
|
+
"reporting. Slides are measured on the write that changes them; use "
|
|
770
|
+
"this for a deck that arrived by sync or upload, or was restyled.",
|
|
771
|
+
)
|
|
772
|
+
@click.option("--workspace", "workspace_slug", help="Override active workspace.")
|
|
773
|
+
@pass_ctx
|
|
774
|
+
def describe(
|
|
775
|
+
ctx: Ctx,
|
|
776
|
+
filename: str,
|
|
777
|
+
include_reasoning: bool,
|
|
778
|
+
scenario_id: str | None,
|
|
779
|
+
lint_mode: bool,
|
|
780
|
+
since_revision: str | None,
|
|
781
|
+
measure: bool,
|
|
782
|
+
workspace_slug: str | None,
|
|
783
|
+
) -> None:
|
|
784
|
+
"""Show a document's shape: sheets, the five dimensions, documents, decks.
|
|
785
|
+
|
|
786
|
+
The cheapest way to orient in a file you did not write — read this before
|
|
787
|
+
querying or editing, so refs are copied rather than guessed. It returns
|
|
788
|
+
the definitions and counts, not the values: use `deepcell query` for
|
|
789
|
+
values, `deepcell cat` for the raw XML.
|
|
790
|
+
|
|
791
|
+
Each sheet lists the five dimensions IT renders; "Definitions" then lists
|
|
792
|
+
every declared one (Item × Context × Status × Scenario × CustomDimensions),
|
|
793
|
+
which is what keys a cell. Documents report their heading structure and
|
|
794
|
+
decks their slides — the shape of each deliverable, not its prose — and
|
|
795
|
+
"Sources" lists the <Source> registry the evidence cites.
|
|
796
|
+
|
|
797
|
+
Add --lint for structural findings and --include-reasoning for the reading
|
|
798
|
+
path: the key question, the conclusion, and what argues for and against it
|
|
799
|
+
— the same walk, in the same order, as the Reasoning panel in the viewer.
|
|
800
|
+
|
|
801
|
+
--lint exits 1 only on error-level findings (unrendered_value,
|
|
802
|
+
empty_rendered_block, monetary_units, hardcoded_literal_in_calc,
|
|
803
|
+
deck_overflow, deck_underfilled, deck_slide_empty,
|
|
804
|
+
deck_bind_undeclared). Warn-level findings —
|
|
805
|
+
undeclared_status_archetype, undeclared_context_state,
|
|
806
|
+
percentage_not_a_fraction, deck_hero_figure_repeated and the other deck_*
|
|
807
|
+
codes — print with a `warn`
|
|
808
|
+
prefix and exit 0, so a clean exit is not "no findings". `deepcell guide
|
|
809
|
+
verify/lint` lists every code with its severity.
|
|
810
|
+
|
|
811
|
+
--lint reports the WHOLE model, not the part you just changed, so on a
|
|
812
|
+
scoped edit most findings are usually someone else's. Add `--since HEAD~1`
|
|
813
|
+
and each one is marked (pre-existing) or (new) — the exit code is
|
|
814
|
+
unchanged, so a model that was already failing still fails.
|
|
815
|
+
|
|
816
|
+
Deck slides are measured for fit on the write that changes them (the
|
|
817
|
+
`defs apply` / `deck` response carries the fill line), and --lint reports
|
|
818
|
+
the remembered result: `deck_overflow`, `deck_underfilled` and
|
|
819
|
+
`deck_slide_empty` all gate, and each underfill finding names the remedy
|
|
820
|
+
for that slide. A slide with no measurement — synced or uploaded —
|
|
821
|
+
reports `deck_slide_unmeasured`; add --measure to measure those first
|
|
822
|
+
(one browser pass per slide in the export service).
|
|
823
|
+
|
|
824
|
+
"Export notes" is where status problems surface — `status_unresolved` (a
|
|
825
|
+
cell holding several statuses that renders blank because nothing says
|
|
826
|
+
which) and `status_ambiguity` (it renders one of several). These are NOT
|
|
827
|
+
lint findings and do NOT affect the --lint exit code, so read them even
|
|
828
|
+
when --lint exits clean. See `deepcell guide verify/query-back`.
|
|
829
|
+
"""
|
|
830
|
+
slug = workspace_slug or ctx.require_workspace()
|
|
831
|
+
if since_revision and not lint_mode:
|
|
832
|
+
raise click.UsageError(
|
|
833
|
+
"--since only applies to --lint findings. Add --lint, or drop "
|
|
834
|
+
"--since."
|
|
835
|
+
)
|
|
836
|
+
if measure and not lint_mode:
|
|
837
|
+
raise click.UsageError(
|
|
838
|
+
"--measure only applies to --lint findings. Add --lint, or drop "
|
|
839
|
+
"--measure."
|
|
840
|
+
)
|
|
841
|
+
body: dict = {
|
|
842
|
+
"workspace_slug": slug,
|
|
843
|
+
"source_filename": filename,
|
|
844
|
+
"include_reasoning": include_reasoning,
|
|
845
|
+
"lint": lint_mode,
|
|
846
|
+
}
|
|
847
|
+
if measure:
|
|
848
|
+
body["measure"] = True
|
|
849
|
+
if since_revision:
|
|
850
|
+
body["since_revision"] = since_revision
|
|
851
|
+
if scenario_id:
|
|
852
|
+
body["scenario_id"] = scenario_id
|
|
853
|
+
|
|
854
|
+
response = ctx.client.post("/describe", json=body)
|
|
855
|
+
schema = response.get("schema", {}) if isinstance(response, dict) else {}
|
|
856
|
+
orphans = (
|
|
857
|
+
response.get("unrendered_values") or []
|
|
858
|
+
if isinstance(response, dict)
|
|
859
|
+
else []
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
if ctx.fmt == "plain" and isinstance(schema, dict):
|
|
863
|
+
print_plain(_schema_to_plain(schema))
|
|
864
|
+
else:
|
|
865
|
+
output(schema, ctx.fmt)
|
|
866
|
+
if isinstance(response, dict):
|
|
867
|
+
echo_version_history(response)
|
|
868
|
+
# Structural problems the render-plan build hit (unsupported block,
|
|
869
|
+
# dimension-expansion cap, a scenario that did not apply). Without
|
|
870
|
+
# these, `describe` reports a model as fully rendered while `to-excel`
|
|
871
|
+
# warns about the same document. Grouped under their own heading and
|
|
872
|
+
# kept out of the ⚠ shape (eval U6): printed bare next to the lint
|
|
873
|
+
# output, the flat-sheet note read as a finding and sent a worker
|
|
874
|
+
# chasing a lint code that did not exist.
|
|
875
|
+
conv_warnings = response.get("warnings") or []
|
|
876
|
+
if conv_warnings:
|
|
877
|
+
echo_info("Export notes (informational — not lint findings):")
|
|
878
|
+
for warning in conv_warnings:
|
|
879
|
+
echo_info(f" {warning}")
|
|
880
|
+
if orphans:
|
|
881
|
+
click.echo(_format_unrendered_values_warning(orphans), err=True)
|
|
882
|
+
# Issue #1371 — the same diagnostics printed as prose above, re-read from
|
|
883
|
+
# the STRUCTURED record and grouped by kind with their coordinates. The
|
|
884
|
+
# prose reads as one flat informational line per cell; a model that ships
|
|
885
|
+
# 32 `status_unresolved` cells needs to see "32 of one thing", which only
|
|
886
|
+
# the record supports. On stderr, next to the orphan warning, so it is not
|
|
887
|
+
# mistaken for part of the JSON/plain schema on stdout.
|
|
888
|
+
diagnostics = (
|
|
889
|
+
response.get("status_diagnostics") or []
|
|
890
|
+
if isinstance(response, dict)
|
|
891
|
+
else []
|
|
892
|
+
)
|
|
893
|
+
if diagnostics:
|
|
894
|
+
click.echo(_format_status_diagnostics_warning(diagnostics), err=True)
|
|
895
|
+
# Gate on errors only. A finding may declare `severity: "warn"` — the
|
|
896
|
+
# undeclared-semantics codes do, because every document written before
|
|
897
|
+
# `@archetype` existed omits it and gating would fail the whole corpus on
|
|
898
|
+
# day one. An absent severity still means error, so the original describe
|
|
899
|
+
# codes gate exactly as before. `deepcell guide verify/lint` carries the
|
|
900
|
+
# generated table of which codes are which.
|
|
901
|
+
if lint_mode and any(not _is_warning(f) for f in schema.get("findings") or []):
|
|
902
|
+
click.get_current_context().exit(1)
|