|
| 1 | +"""Step 1's call-graph argument must fit in a Windows command line. |
| 2 | +
|
| 3 | +The payload is passed to prompt_gen_expert_caller as a command-line argument. On |
| 4 | +this repository the ungated lists serialise to 1,031,711 characters -- 5,527 |
| 5 | +affected methods and 940 danger zones -- against a Windows CreateProcess limit of |
| 6 | +32,767. Every Step 1 run on Windows therefore died with WinError 206, fell back |
| 7 | +to the raw task, and left orchestration_prompt empty. Steps 2 and 3 then skipped |
| 8 | +issue and branch creation, and the run reported OK having done nothing. |
| 9 | +
|
| 10 | +That failure was silent for a month. It is the reason these tests assert on the |
| 11 | +SIZE of what is sent rather than only on the content: a correctness test would |
| 12 | +have passed throughout, because the values were right and merely undeliverable. |
| 13 | +
|
| 14 | +The receiver already truncated affected_methods to ten, so capping the sender |
| 15 | +changes nothing about the rendered prompt for that list. danger_zones it rendered |
| 16 | +in full, so the totals are sent alongside the sample and _render_sample states |
| 17 | +what was omitted -- a truncation the reader cannot see is worse than none, since |
| 18 | +the prompt then asserts something false about the codebase. |
| 19 | +""" |
| 20 | + |
| 21 | +import json |
| 22 | +import sys |
| 23 | +from pathlib import Path |
| 24 | + |
| 25 | +_PROJECT_ROOT = Path(__file__).resolve().parent.parent |
| 26 | +if str(_PROJECT_ROOT) not in sys.path: |
| 27 | + sys.path.insert(0, str(_PROJECT_ROOT)) |
| 28 | + |
| 29 | +from langgraph_engine.sdlc_pipeline.architecture.prompt_gen_expert_caller import _render_sample # noqa: E402 |
| 30 | + |
| 31 | +WINDOWS_CMDLINE_LIMIT = 32767 |
| 32 | + |
| 33 | +# Shaped like the real thing on this repository, big enough that an uncapped |
| 34 | +# payload cannot fit. Built rather than measured so the test does not depend on |
| 35 | +# how large the repository happens to be on the day it runs. |
| 36 | +MANY_METHODS = ["langgraph_engine.module_%d.Class%d.method_%d" % (i, i, i) for i in range(5527)] |
| 37 | +MANY_ZONES = ["langgraph_engine/danger/zone_%d.py" % i for i in range(940)] |
| 38 | + |
| 39 | + |
| 40 | +def _build_payload(danger_zones, affected_methods, sample=10): |
| 41 | + """Build the argument exactly as step1_task_analysis_node builds it.""" |
| 42 | + return json.dumps( |
| 43 | + { |
| 44 | + "risk_level": "high", |
| 45 | + "danger_zones": danger_zones[:sample], |
| 46 | + "danger_zones_total": len(danger_zones), |
| 47 | + "affected_methods": affected_methods[:sample], |
| 48 | + "affected_methods_total": len(affected_methods), |
| 49 | + } |
| 50 | + ) |
| 51 | + |
| 52 | + |
| 53 | +class TestThePayloadFitsInACommandLine: |
| 54 | + """The size property the outage was caused by.""" |
| 55 | + |
| 56 | + def test_a_large_graph_still_fits(self): |
| 57 | + payload = _build_payload(MANY_ZONES, MANY_METHODS) |
| 58 | + assert len(payload) < WINDOWS_CMDLINE_LIMIT, "payload is %d chars, over the %d Windows limit" % ( |
| 59 | + len(payload), |
| 60 | + WINDOWS_CMDLINE_LIMIT, |
| 61 | + ) |
| 62 | + |
| 63 | + def test_the_uncapped_payload_would_not_fit(self): |
| 64 | + """The half that proves the cap is what makes the difference. |
| 65 | +
|
| 66 | + Without this, the test above passes on any repository small enough to fit |
| 67 | + anyway, and would have passed throughout the outage. |
| 68 | + """ |
| 69 | + uncapped = json.dumps({"risk_level": "high", "danger_zones": MANY_ZONES, "affected_methods": MANY_METHODS}) |
| 70 | + assert len(uncapped) > WINDOWS_CMDLINE_LIMIT |
| 71 | + |
| 72 | + def test_the_totals_survive_the_cap(self): |
| 73 | + """A sample without its total cannot be reported honestly downstream.""" |
| 74 | + payload = json.loads(_build_payload(MANY_ZONES, MANY_METHODS)) |
| 75 | + assert payload["danger_zones_total"] == 940 |
| 76 | + assert payload["affected_methods_total"] == 5527 |
| 77 | + assert len(payload["danger_zones"]) == 10 |
| 78 | + assert len(payload["affected_methods"]) == 10 |
| 79 | + |
| 80 | + |
| 81 | +class TestTheRenderedSampleDeclaresWhatItOmitted: |
| 82 | + """A cap the reader cannot see makes the prompt assert something false.""" |
| 83 | + |
| 84 | + def test_a_truncated_list_states_the_true_total(self): |
| 85 | + rendered = _render_sample(MANY_ZONES[:10], total=940) |
| 86 | + assert "showing 10 of 940" in rendered |
| 87 | + |
| 88 | + def test_a_complete_list_carries_no_suffix(self): |
| 89 | + """The half that fails if the suffix were unconditional.""" |
| 90 | + rendered = _render_sample(["a", "b"], total=2) |
| 91 | + assert rendered == "a, b" |
| 92 | + assert "showing" not in rendered |
| 93 | + |
| 94 | + def test_an_absent_total_is_treated_as_complete(self): |
| 95 | + """An older caller sends no total; it must not be reported as truncated.""" |
| 96 | + rendered = _render_sample(["a", "b"], total=None) |
| 97 | + assert rendered == "a, b" |
| 98 | + assert "showing" not in rendered |
| 99 | + |
| 100 | + def test_an_empty_list_renders_as_none(self): |
| 101 | + assert _render_sample([], total=0) == "none" |
| 102 | + assert _render_sample(None, total=None) == "none" |
| 103 | + |
| 104 | + |
| 105 | +class TestEntriesAreDictsNotStrings: |
| 106 | + """The second bug, which the first one hid. |
| 107 | +
|
| 108 | + The analyser yields dicts carrying fqn / callers_count / risk. The previous |
| 109 | + code joined them directly, which raises TypeError on a dict. It never |
| 110 | + surfaced because the argument carrying them blew the Windows command-line |
| 111 | + limit and the caller died before reaching the join -- so fixing only the size |
| 112 | + would have swapped a WinError for a TypeError. |
| 113 | + """ |
| 114 | + |
| 115 | + def test_a_dict_entry_renders_its_fqn(self): |
| 116 | + entries = [{"fqn": "pkg.mod.Class.method", "callers_count": 42, "risk": "high"}] |
| 117 | + assert _render_sample(entries, total=1) == "pkg.mod.Class.method" |
| 118 | + |
| 119 | + def test_the_old_join_would_have_raised_on_these(self): |
| 120 | + """Pins why this is a separate defect rather than a style preference.""" |
| 121 | + entries = [{"fqn": "pkg.mod.Class.method", "callers_count": 42}] |
| 122 | + try: |
| 123 | + ", ".join(entries) |
| 124 | + except TypeError: |
| 125 | + return |
| 126 | + raise AssertionError("joining dicts must raise TypeError, or this test proves nothing") |
| 127 | + |
| 128 | + def test_a_plain_string_entry_still_works(self): |
| 129 | + """Older callers, and hot_nodes, may still send strings.""" |
| 130 | + assert _render_sample(["a.b.c"], total=1) == "a.b.c" |
| 131 | + |
| 132 | + def test_an_unexpected_shape_degrades_instead_of_raising(self): |
| 133 | + """Prompt assembly must not die because an entry grew a new shape.""" |
| 134 | + assert _render_sample([{"unexpected": 1}], total=1) != "" |
| 135 | + |
| 136 | + |
| 137 | +class TestTheNodeSendsTheCappedShape: |
| 138 | + """The cap must live in the node, not only in this test's helper.""" |
| 139 | + |
| 140 | + def test_the_node_source_caps_both_lists_and_sends_totals(self): |
| 141 | + source = (_PROJECT_ROOT / "langgraph_engine" / "sdlc_pipeline" / "nodes" / "task_orchestration.py").read_text( |
| 142 | + encoding="utf-8" |
| 143 | + ) |
| 144 | + assert "danger_zones_total" in source, "the node must send the danger_zones total" |
| 145 | + assert "affected_methods_total" in source, "the node must send the affected_methods total" |
| 146 | + assert "_CG_SAMPLE" in source, "the node must cap the lists before serialising" |
0 commit comments