fix: address PRizm round-2 review findings from internal port @W-22196528@

Ports the 14 applicable round-2 fixes from the internal PRizm review to the
external afv-library source. (plugin.json finding #13 is internal-only and
does not apply here.)

Accepted fixes:
  1  bdt_analyze.py select_definition: isinstance(index, int) -> type(index) is int
     (reject bool subclass of int).
  2  bdt_analyze.py cmd_formula: precompute upstream fields_produced map to
     eliminate O(consumed x upstream) recomputation.
  3  bdt_analyze.py topo_order: replace sort-on-every-iteration with deque-based
     Kahn (preserves deterministic order).
  4  test_bdt_analyze.py test_all_subcommands_work_on_api_input: wrap loop in
     self.subTest for per-iteration failure reporting.
  5  test_bdt_analyze.py test_invalid_json_raises: register addCleanup BEFORE
     write_text so cleanup runs even if write fails.
  6  SKILL.md: remove misleading "strip __c suffix" field-trace troubleshooting
     advice; replace with source-DMO passthrough guidance.
  7  SKILL.md: rewrite cycle error message to direct user to Data Cloud viewer
     (re-export will not fix a cycle in the BDT definition).
  8  SKILL.md: add explicit --definition N flag documentation and list every
     subcommand that accepts it.
  9  SKILL.md: refine I2 routing guidance — pick the earliest formula/
     computeRelative node, not the output mapping.
  11 bdt-function-catalog.md: clean floor() wording ("always rounds down on the
     number line").
  12 bdt-node-catalog.md: businessType enum — assert list is canonical/complete;
     instruct skill to surface+flag any unknown value.
  14 append_and_split.json: change split subject from fragile
     CustomerFullName__c split-on-space to ChannelOrderKey__c split-on-hyphen;
     avoids name-parsing pitfall (middle names / multi-space).
  15 window_and_aggregate.json: use the computed OrderRank__c via a new
     FIRST_ORDER_AMOUNT formula node (case when rank=1 then amount else 0)
     feeding AGG_BY_ACCOUNT; illustrates the rank+formula+aggregate idiom.
  16 joins_and_filters.json: add explicit IS_NOT_NULL filter expression on
     GrandTotalAmount__c (defense-in-depth beyond GREATER_THAN 0).

Rejected finding (rationale posted as PR comment):
  10 bdt-node-catalog.md split section — reviewer claimed split is a
     pipeline-branching node. Canonical sources (core-262
     SplitParametersInputRepresentation.java + Salesforce help DITA
     c360_a_batch_transform_split.xml) both describe split as a
     string-splitting operation. Current docs are correct; not changing.

Tests: 92/92 passing.
This commit is contained in:
Gaurav Bajpai 2026-04-24 10:55:18 +05:30
parent db1aca7221
commit 67d919947f
No known key found for this signature in database
GPG Key ID: 4EE015E4DD141C47
8 changed files with 96 additions and 69 deletions

View File

@ -48,7 +48,7 @@ Before any further work, validate by running `python bdt_analyze.py summary <pat
### 3.1 Multi-definition payloads
If the user provides a Connect API payload with a `definitions[]` array containing multiple entries, first run `python bdt_analyze.py definitions <path>` to list them. Then offer the user a choice — *"The payload has 3 definitions: MyTransform, OtherTransform, ThirdTransform. Which do you want me to explain?"* — and invoke subsequent subcommands with `--definition N` (0-indexed). Editor exports and single-definition payloads can be explained directly without this step; running `definitions` on them still succeeds and shows one row, so it is safe to run on any input if you are unsure.
**Multi-definition payloads.** If the user provides a Connect API payload with a `definitions[]` array containing multiple entries, first run `python bdt_analyze.py definitions <path>` to list them. Then offer the user a choice — *"The payload has 3 definitions: MyTransform, OtherTransform, ThirdTransform. Which do you want me to explain?"* — and invoke subsequent subcommands with `--definition N` (0-indexed). For example: `python bdt_analyze.py summary <path> --definition 1`. All analysis subcommands (summary, sources, outputs, stages, nodes, node, lineage, field-trace, formula) accept the `--definition` flag. Editor exports and single-definition payloads can be explained directly without this step; running `definitions` on them still succeeds and shows one row, so it is safe to run on any input if you are unsure.
## 4. Explanation Flow (Job A — Progressive Disclosure)
@ -86,7 +86,7 @@ When the user asks a follow-up question, route it to the right subcommand using
| "What DMOs/DLOs does this read from?" (L4) | `sources <path>` |
| "What fields does OUTPUT N produce?" (L5) | `node <path> OUTPUT_N` |
| "What does this formula mean?" (I1) | `formula <path> X` |
| "Why would F be null/zero/unexpected?" (I2) | `field-trace <path> F` **then** `formula <path> <defining-node>` |
| "Why would F be null/zero/unexpected?" (I2) | `field-trace <path> F` to locate the defining node(s), **then** `formula <path> <defining-node>` on the formula or computeRelative node that computes the field (not the output mapping — pick the earliest formula/computeRelative node in the field-trace output). |
| "What does this filter do?" (I3) | `node <path> X` |
After running the subcommand(s), translate the output into plain English. For I1/I2 you **must** consult `references/bdt-function-catalog.md` (and `references/bdt-window-functions.md` if it's a `computeRelative` node) before narrating.
@ -119,7 +119,7 @@ After running the subcommand(s), translate the output into plain English. For I1
|---|---|
| Exit 3: `Invalid JSON at line X` | "This file isn't valid JSON at line X. Could you re-export from the BDT viewer?" Then paste the exact error. |
| Exit 3: `Expected top-level 'nodes' object` | "This doesn't look like a BDT export — a BDT has a top-level `nodes` object. Is this the right file?" |
| Exit 3: `Cycle or broken reference detected` | "This BDT has a dependency cycle (or a reference to a node that doesn't exist). A valid BDT shouldn't cycle — could you re-export and share? The unresolved nodes are: \<list\>." |
| Exit 3: `Cycle or broken reference detected` | "This BDT has a dependency cycle or references a non-existent node. You'll need to edit the BDT in the Data Cloud viewer to fix the circular dependency or broken reference — re-exporting won't help because the cycle exists in the BDT definition itself. The problematic nodes are: \<list from stderr\>." |
| Exit 2: `No node named 'X'` | "I don't see a node named `X` in this BDT. Did you mean one of: \<list of available names the script printed on stderr\>?" |
| Exit 2: `Field 'F' is not defined by any node` | "That field isn't defined anywhere in this BDT. Closest matches from the fields I can see: \<list from stderr\>. Is it possibly a field from the source DMO that's not pulled in?" |
| User's BDT uses an action type not in the catalog | "This BDT uses `<action>`, which isn't in my reference material. Here's what its parameters look like: \<raw params\>. I can describe the graph structure around it, but I can't vouch for the exact semantics of this action type." |
@ -158,7 +158,7 @@ Common issues and how to address them:
| `ModuleNotFoundError: No module named 'bdt_analyze'` | The script path is wrong. The script lives at `scripts/bdt_analyze.py` inside the skill directory. Run it by absolute path (the Bash tool invocation should include the full path from the repo root). |
| BDT file path contains spaces (e.g. `"default B2C (Prod).json"`) | Quote the path when calling the script: `python bdt_analyze.py summary "default B2C (Prod).json"`. |
| Script output is very large for a big BDT (Mode C) | Use `--limit 50` on the `nodes` subcommand, or recommend Mode B (layered) instead of Mode C. |
| Field trace returns no match despite the field clearly appearing in the BDT | The field may only appear qualified (e.g., `SalesOrder.ssot__Id__c`). Try the qualified form. Also try stripping the `__c` suffix. |
| Field trace returns no match despite the field clearly appearing in the BDT | The field may only appear qualified (e.g., `SalesOrder.ssot__Id__c`). Try the qualified form. If still no match, the field may be passed through from a source DMO but not explicitly defined in the BDT — in that case field-trace cannot locate it; narrate "this field originates from the load node's source DMO" instead. Do NOT attempt to trace it by stripping the `__c` suffix — that would risk matching a different field with a similar name. |
| User says output "doesn't match what I see in BDT viewer" | Verify the version in the JSON header matches the org's current release. Canonical schema was synced against `core-264`, but older/newer BDTs may use variants not yet in the reference. Flag as "version drift" and narrate best-effort. |
## 11. Example Interactions

View File

@ -6,7 +6,7 @@
"sources": [],
"parameters": {
"dataset": {"name": "WebOrders__dlo", "type": "dataLakeObject"},
"fields": ["OrderId__c", "Amount__c", "Channel__c", "CustomerFullName__c"],
"fields": ["OrderId__c", "Amount__c", "ChannelOrderKey__c"],
"sampleDetails": {"type": "TopN", "sortBy": []}
}
},
@ -15,7 +15,7 @@
"sources": [],
"parameters": {
"dataset": {"name": "StoreOrders__dlo", "type": "dataLakeObject"},
"fields": ["OrderId__c", "Amount__c", "Channel__c", "CustomerFullName__c"],
"fields": ["OrderId__c", "Amount__c", "ChannelOrderKey__c"],
"sampleDetails": {"type": "TopN", "sortBy": []}
}
},
@ -25,55 +25,53 @@
"parameters": {
"allowImplicitDisjointSchema": false,
"fieldMappings": [
{"targetField": "OrderId__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "OrderId__c"}, {"node": "LOAD_STORE_ORDERS", "field": "OrderId__c"}]},
{"targetField": "Amount__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "Amount__c"}, {"node": "LOAD_STORE_ORDERS", "field": "Amount__c"}]},
{"targetField": "Channel__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "Channel__c"}, {"node": "LOAD_STORE_ORDERS", "field": "Channel__c"}]},
{"targetField": "CustomerFullName__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "CustomerFullName__c"}, {"node": "LOAD_STORE_ORDERS", "field": "CustomerFullName__c"}]}
{"targetField": "OrderId__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "OrderId__c"}, {"node": "LOAD_STORE_ORDERS", "field": "OrderId__c"}]},
{"targetField": "Amount__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "Amount__c"}, {"node": "LOAD_STORE_ORDERS", "field": "Amount__c"}]},
{"targetField": "ChannelOrderKey__c", "sources": [{"node": "LOAD_WEB_ORDERS", "field": "ChannelOrderKey__c"}, {"node": "LOAD_STORE_ORDERS", "field": "ChannelOrderKey__c"}]}
]
}
},
"SPLIT_CUSTOMER_NAME": {
"SPLIT_CHANNEL_KEY": {
"action": "split",
"sources": ["APPEND_ALL_ORDERS"],
"parameters": {
"sourceField": "CustomerFullName__c",
"delimiter": " ",
"sourceField": "ChannelOrderKey__c",
"delimiter": "-",
"targetFields": [
{"name": "CustomerFirstName__c", "label": "Customer First Name"},
{"name": "CustomerLastName__c", "label": "Customer Last Name"}
{"name": "Channel__c", "label": "Channel"},
{"name": "OrderNumber__c", "label": "Order Number"}
]
}
},
"OUTPUT_ORDERS": {
"action": "outputD360",
"sources": ["SPLIT_CUSTOMER_NAME"],
"sources": ["SPLIT_CHANNEL_KEY"],
"parameters": {
"name": "OrdersWithCustomerName__dlm",
"name": "OrdersWithChannel__dlm",
"type": "dataModelObject",
"writeMode": "OVERWRITE",
"fieldsMappings": [
{"sourceField": "OrderId__c", "targetField": "OrderId__c"},
{"sourceField": "Amount__c", "targetField": "Amount__c"},
{"sourceField": "Channel__c", "targetField": "Channel__c"},
{"sourceField": "CustomerFirstName__c", "targetField": "FirstName__c"},
{"sourceField": "CustomerLastName__c", "targetField": "LastName__c"}
{"sourceField": "OrderId__c", "targetField": "OrderId__c"},
{"sourceField": "Amount__c", "targetField": "Amount__c"},
{"sourceField": "Channel__c", "targetField": "Channel__c"},
{"sourceField": "OrderNumber__c", "targetField": "OrderNumber__c"}
]
}
}
},
"ui": {
"nodes": {
"LOAD_WEB_ORDERS": {"label": "Web Orders", "type": "LOAD_DATASET", "top": 100, "left": 100},
"LOAD_STORE_ORDERS": {"label": "Store Orders", "type": "LOAD_DATASET", "top": 260, "left": 100},
"APPEND_ALL_ORDERS": {"label": "Union", "type": "APPEND", "top": 180, "left": 260},
"SPLIT_CUSTOMER_NAME": {"label": "Split full name", "type": "SPLIT", "top": 180, "left": 420},
"OUTPUT_ORDERS": {"label": "Orders with name", "type": "OUTPUT", "top": 180, "left": 580}
"LOAD_WEB_ORDERS": {"label": "Web Orders", "type": "LOAD_DATASET", "top": 100, "left": 100},
"LOAD_STORE_ORDERS": {"label": "Store Orders", "type": "LOAD_DATASET", "top": 260, "left": 100},
"APPEND_ALL_ORDERS": {"label": "Union", "type": "APPEND", "top": 180, "left": 260},
"SPLIT_CHANNEL_KEY": {"label": "Split channel key", "type": "SPLIT", "top": 180, "left": 420},
"OUTPUT_ORDERS": {"label": "Orders + channel", "type": "OUTPUT", "top": 180, "left": 580}
},
"connectors": [
{"source": "LOAD_WEB_ORDERS", "target": "APPEND_ALL_ORDERS"},
{"source": "LOAD_STORE_ORDERS", "target": "APPEND_ALL_ORDERS"},
{"source": "APPEND_ALL_ORDERS", "target": "SPLIT_CUSTOMER_NAME"},
{"source": "SPLIT_CUSTOMER_NAME", "target": "OUTPUT_ORDERS"}
{"source": "LOAD_WEB_ORDERS", "target": "APPEND_ALL_ORDERS"},
{"source": "LOAD_STORE_ORDERS", "target": "APPEND_ALL_ORDERS"},
{"source": "APPEND_ALL_ORDERS", "target": "SPLIT_CHANNEL_KEY"},
{"source": "SPLIT_CHANNEL_KEY", "target": "OUTPUT_ORDERS"}
]
}
}

View File

@ -25,9 +25,10 @@
"parameters": {
"filterExpressions": [
{"type": "TEXT", "field": "ssot__Status__c", "operator": "EQUAL", "operands": ["Active"]},
{"type": "NUMBER", "field": "ssot__GrandTotalAmount__c", "operator": "GREATER_THAN", "operands": ["0"]}
{"type": "NUMBER", "field": "ssot__GrandTotalAmount__c", "operator": "GREATER_THAN", "operands": ["0"]},
{"type": "NUMBER", "field": "ssot__GrandTotalAmount__c", "operator": "IS_NOT_NULL", "operands": []}
],
"filterBooleanLogic": "1 AND 2"
"filterBooleanLogic": "1 AND 2 AND 3"
}
},
"JOIN_ORDER_ACCOUNT": {

View File

@ -23,7 +23,7 @@
"label": "Order Rank",
"formulaExpression": "row_number()",
"type": "NUMBER",
"businessType": "Number",
"businessType": "NUMBER",
"precision": 18,
"scale": 0,
"defaultValue": ""
@ -31,13 +31,33 @@
]
}
},
"FIRST_ORDER_AMOUNT": {
"action": "formula",
"sources": ["RANK_ORDERS"],
"parameters": {
"expressionType": "SQL",
"fields": [
{
"name": "FirstOrderAmount__c",
"label": "First Order Amount (per account)",
"formulaExpression": "case when OrderRank__c = 1 then ssot__GrandTotalAmount__c else 0 end",
"type": "NUMBER",
"businessType": "NUMBER",
"precision": 18,
"scale": 2,
"defaultValue": "0"
}
]
}
},
"AGG_BY_ACCOUNT": {
"action": "aggregate",
"sources": ["RANK_ORDERS"],
"sources": ["FIRST_ORDER_AMOUNT"],
"parameters": {
"groupings": ["ssot__AccountId__c"],
"aggregations": [
{"action": "SUM", "name": "TotalAmount__c", "source": "ssot__GrandTotalAmount__c"},
{"action": "SUM", "name": "FirstOrderTotal__c", "source": "FirstOrderAmount__c"},
{"action": "COUNT", "name": "OrderCount__c", "source": "ssot__Id__c"}
],
"nodeType": "STANDARD"
@ -53,6 +73,7 @@
"fieldsMappings": [
{"sourceField": "ssot__AccountId__c", "targetField": "AccountId__c"},
{"sourceField": "TotalAmount__c", "targetField": "TotalAmount__c"},
{"sourceField": "FirstOrderTotal__c", "targetField": "FirstOrderAmount__c"},
{"sourceField": "OrderCount__c", "targetField": "OrderCount__c"}
]
}
@ -60,14 +81,16 @@
},
"ui": {
"nodes": {
"LOAD_ORDERS": {"label": "Sales Orders", "type": "LOAD_DATASET", "top": 100, "left": 100},
"RANK_ORDERS": {"label": "Rank by account", "type": "COMPUTE_RELATIVE", "top": 100, "left": 260},
"AGG_BY_ACCOUNT": {"label": "Totals per account", "type": "AGGREGATE", "top": 100, "left": 420},
"OUTPUT_SUMMARY": {"label": "Account Summary", "type": "OUTPUT", "top": 100, "left": 580}
"LOAD_ORDERS": {"label": "Sales Orders", "type": "LOAD_DATASET", "top": 100, "left": 100},
"RANK_ORDERS": {"label": "Rank by account", "type": "COMPUTE_RELATIVE", "top": 100, "left": 260},
"FIRST_ORDER_AMOUNT": {"label": "Extract first order amount", "type": "FORMULA", "top": 100, "left": 420},
"AGG_BY_ACCOUNT": {"label": "Totals per account", "type": "AGGREGATE", "top": 100, "left": 580},
"OUTPUT_SUMMARY": {"label": "Account Summary", "type": "OUTPUT", "top": 100, "left": 740}
},
"connectors": [
{"source": "LOAD_ORDERS", "target": "RANK_ORDERS"},
{"source": "RANK_ORDERS", "target": "AGG_BY_ACCOUNT"},
{"source": "RANK_ORDERS", "target": "FIRST_ORDER_AMOUNT"},
{"source": "FIRST_ORDER_AMOUNT", "target": "AGG_BY_ACCOUNT"},
{"source": "AGG_BY_ACCOUNT", "target": "OUTPUT_SUMMARY"}
]
}

View File

@ -34,7 +34,7 @@
|---|---|---|
| `abs(n)` | number → number | Absolute value (strips sign). |
| `ceiling(n)` | number → number | Round up, away from zero for negatives. |
| `floor(n)` | number → number | Round toward negative infinity (down on the number line). For negatives, rounds away from zero (e.g., `floor(-2.3) = -3`). |
| `floor(n)` | number → number | Round toward negative infinity. Always rounds down on the number line: `floor(2.7) = 2`, `floor(-2.3) = -3`. |
| `exp(n)` | number → number | e raised to n. |
| `log(base, n)` | number → number | Logarithm of n in the given base. |
| `max(a, b, …)` | number → number | Largest value. |

View File

@ -211,8 +211,9 @@ semantics — for that, see `computeRelative`.
- `label` — user-visible label.
- `formulaExpression` — SFSQL (or DCSQL) expression. See `bdt-function-catalog.md`.
- `type` — enum `DataType`: `TEXT`, `NUMBER`, `BOOLEAN`, `DATE_ONLY`, `DATETIME`.
- `businessType` — a user-facing business-semantic type name. Enum `BusinessTypeEnum`
canonical values:
- **`businessType`** — a user-facing business-semantic type name. Enum `BusinessTypeEnum`
**canonical values** (complete list; matches `BusinessTypeEnum.java` in core-262 as of the
capture date at the top of this file):
- `"TEXT"`, `"NUMBER"`, `"BOOLEAN"`
- `"EMAIL"`, `"PHONE"`, `"URL"` — text-valued with semantic meaning
- `"PERCENT"`, `"CURRENCY"` — number-valued with semantic meaning
@ -222,8 +223,9 @@ semantics — for that, see `computeRelative`.
Note: `businessType` values map to underlying `type` (`DataType`) values. E.g.,
`businessType: "PERCENT"` is stored as `type: "NUMBER"`; `businessType: "DATE"` is stored
as `type: "DATETIME"`; `businessType: "EMAIL"` is stored as `type: "TEXT"`. The canonical
mapping lives in `BusinessTypeEnum.java`.
as `type: "DATETIME"`; `businessType: "EMAIL"` is stored as `type: "TEXT"`. If the skill
ever encounters a `businessType` value outside this list, that is a sign the upstream BDT
schema has evolved — surface the raw value in narration and flag it as undocumented.
- `precision` — integer precision (default 10 for numbers; characters for text).
- `scale` — decimal places; only for NUMBER.
- `defaultValue` — value when the expression yields NULL.

View File

@ -128,7 +128,7 @@ class DataTransform:
"""Pick which definition the accessor properties (and the rest of the
API surface) operate on. Raises `BdtNotFoundError` if `index` is out
of range."""
if not isinstance(index, int) or index < 0 or index >= len(self.definitions):
if type(index) is not int or index < 0 or index >= len(self.definitions):
raise BdtNotFoundError(
f"Definition index {index} out of range; payload has "
f"{len(self.definitions)} definitions "
@ -296,31 +296,32 @@ class DataTransform:
def topo_order(self) -> List[str]:
"""Kahn's algorithm. Raises BdtInputError on cycles."""
# Adjacency: for each node, its forward edges (consumers) and in-degree
from collections import deque
in_degree: Dict[str, int] = {name: 0 for name in self.nodes}
forward: Dict[str, List[str]] = {name: [] for name in self.nodes}
for name, n in self.nodes.items():
for s in n.sources:
if s not in self.nodes:
# Broken reference — counted as in-edge so the dependent node stays blocked
# (we surface broken refs via a separate method; topo should not crash here).
# Broken reference — count as in-edge so the dependent node stays blocked
in_degree[name] += 1
continue
forward[s].append(name)
in_degree[name] += 1
# Start with all zero-in-degree nodes in deterministic order (sorted)
ready = sorted([name for name, d in in_degree.items() if d == 0])
# Sort zero-in-degree nodes once for deterministic starting order
ready = deque(sorted([name for name, d in in_degree.items() if d == 0]))
order: List[str] = []
while ready:
# Pop deterministically so output is stable across runs
ready.sort()
current = ready.pop(0)
current = ready.popleft()
order.append(current)
for consumer in sorted(forward[current]):
# Collect newly-ready nodes, sort them once, append
newly_ready = []
for consumer in forward[current]:
in_degree[consumer] -= 1
if in_degree[consumer] == 0:
ready.append(consumer)
newly_ready.append(consumer)
for node in sorted(newly_ready):
ready.append(node)
if len(order) != len(self.nodes):
stuck = [n for n in self.nodes if n not in order]
@ -1309,14 +1310,15 @@ def cmd_formula(bdt: "DataTransform", args) -> str:
# For each consumed field, find the node that defines it (if any) — give
# the LLM ready context for I2-style reasoning. Hoist `bdt.upstream(name)`
# out of the loop — it's a pure function of `name` and walks the graph.
# Pre-compute fields_produced for every upstream node once (was O(consumed × upstream))
upstream_names = bdt.upstream(name)
upstream_fields_map = {up_name: fields_produced(bdt.nodes[up_name]) for up_name in upstream_names}
upstream_defs = {}
for f in consumed:
# Prefer exact match; fall back to unqualified
matches = []
for up_name in upstream_names:
up = bdt.nodes[up_name]
prod = fields_produced(up)
prod = upstream_fields_map[up_name]
if f in prod or f.rsplit(".", 1)[-1] in prod:
matches.append({
"node": up_name,
@ -1324,7 +1326,7 @@ def cmd_formula(bdt: "DataTransform", args) -> str:
"label": up.ui_label,
})
if matches:
upstream_defs[f] = matches[-1] # closest definer (last in topo order)
upstream_defs[f] = matches[-1]
payload = {
"node": name,

View File

@ -38,8 +38,8 @@ class TestBadInput(unittest.TestCase):
def test_invalid_json_raises(self):
p = FIXTURES / "_tmp_invalid.json"
p.write_text("{not valid json")
self.addCleanup(p.unlink, missing_ok=True)
p.write_text("{not valid json")
with self.assertRaises(bdt_analyze.BdtInputError) as cm:
bdt_analyze.DataTransform.from_path(p)
self.assertIn("Invalid JSON", str(cm.exception))
@ -711,15 +711,16 @@ class TestInputShapeDetection(unittest.TestCase):
for subcmd, extra_arg in [("summary", None), ("stages", None), ("nodes", None),
("sources", None), ("outputs", None),
("lineage", "OUTPUT_X"), ("node", "LOAD_X")]:
argv = [subcmd, str(FIXTURES / "api_input_single.json")]
if extra_arg: argv.append(extra_arg)
out = io.StringIO(); err = io.StringIO()
try:
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = bdt_analyze.main(argv)
except SystemExit as e:
code = e.code
self.assertEqual(code, 0, f"Subcommand {subcmd} failed: {err.getvalue()}")
with self.subTest(subcmd=subcmd):
argv = [subcmd, str(FIXTURES / "api_input_single.json")]
if extra_arg: argv.append(extra_arg)
out = io.StringIO(); err = io.StringIO()
try:
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = bdt_analyze.main(argv)
except SystemExit as e:
code = e.code
self.assertEqual(code, 0, f"Subcommand {subcmd} failed: {err.getvalue()}")
class TestMultiDefinition(unittest.TestCase):