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.
Files changed (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. 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)