diff --git a/.github/workflows/tunnel-canary.yml b/.github/workflows/tunnel-canary.yml index 9395250b0..360bea3f4 100644 --- a/.github/workflows/tunnel-canary.yml +++ b/.github/workflows/tunnel-canary.yml @@ -29,9 +29,10 @@ jobs: if [ -n "$err" ]; then echo "curl_stderr=$err" fi - # Ephemeral tunnels (pinggy-free, etc.) expire; DNS/connect 000 is inventory, not dual-gate. - if [ "${code:-000}" = "000" ] || [ "$curl_rc" -ne 0 ]; then - echo "::notice::tunnel unreachable (code=${code:-000} rc=$curl_rc); skip canary (not dual-gate)" + # Ephemeral tunnels expire. DNS/connect 000 is inventory, not dual-gate. + # A live host that does not advertise /health (404) is the same class. + if [ "${code:-000}" = "000" ] || [ "$curl_rc" -ne 0 ] || [ "$code" = "404" ]; then + echo "::notice::tunnel canary skipped (code=${code:-000} rc=$curl_rc); not dual-gate" exit 0 fi [ "$code" = "200" ] || { echo "::error::tunnel health $code"; exit 1; } diff --git a/archwiz/context_relationships/seed_merger.py b/archwiz/context_relationships/seed_merger.py index 3009f5426..c935166ff 100755 --- a/archwiz/context_relationships/seed_merger.py +++ b/archwiz/context_relationships/seed_merger.py @@ -15,20 +15,28 @@ except ImportError: # Supports direct script use. from compiler import CompilationError, load_json -MERGER_ID = "archwiz.context_relationships.seed_merger@1.2" +MERGER_ID = "archwiz.context_relationships.seed_merger@1.3" # Line spans are observations of the same symbol, not identity. Collectors # disagree when one parse includes a decorator or trailing blank and another -# does not. Missing observed_at must not fail the historical backfill. +# does not. Historical seeds may store the same span as int or numeric string. +# Missing observed_at must not fail the historical backfill. SPAN_ATTRIBUTE_KEYS = {"line", "start_line", "end_line"} -def node_reference(record: Mapping[str, Any]) -> str: - kind = record.get("kind") - external_id = record.get("external_id") - if not isinstance(kind, str) or not isinstance(external_id, str): - raise CompilationError("seed node must contain string kind and external_id") - return f"{kind}:{external_id}" +def span_line(value: Any) -> int | None: + """Coerce a span observation to int. Bool is not a line number.""" + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str): + text = value.strip() + if text.isdigit() or (text.startswith("-") and text[1:].isdigit()): + return int(text) + return None def merge_attributes( @@ -44,11 +52,18 @@ def merge_attributes( timestamps_available = isinstance(existing_observed_at, str) and isinstance(incoming_observed_at, str) for key, value in right.items(): if key in merged and merged[key] != value: - if key in SPAN_ATTRIBUTE_KEYS and isinstance(merged[key], int) and isinstance(value, int): - if key == "end_line": - merged[key] = max(merged[key], value) - else: - merged[key] = min(merged[key], value) + if key in SPAN_ATTRIBUTE_KEYS: + left_line = span_line(merged[key]) + right_line = span_line(value) + if left_line is None and right_line is None: + continue + if left_line is None: + merged[key] = right_line + continue + if right_line is None: + merged[key] = left_line + continue + merged[key] = max(left_line, right_line) if key == "end_line" else min(left_line, right_line) continue if not timestamps_available: raise CompilationError(f"node {node_ref} has contradictory attribute {key!r}") @@ -59,6 +74,14 @@ def merge_attributes( return merged +def node_reference(record: Mapping[str, Any]) -> str: + kind = record.get("kind") + external_id = record.get("external_id") + if not isinstance(kind, str) or not isinstance(external_id, str): + raise CompilationError("seed node must contain string kind and external_id") + return f"{kind}:{external_id}" + + def canonical_github_blob_url(url: str, owner: str, name: str, ref: str) -> str: """Rewrite blob locators onto the seed ref. File identity is the path, not the blob SHA.""" marker = f"https://github.com/{owner}/{name}/blob/" diff --git a/tests/test_context_relationship_seed_merger.py b/tests/test_context_relationship_seed_merger.py index 4cdeba7e1..15e4ea887 100644 --- a/tests/test_context_relationship_seed_merger.py +++ b/tests/test_context_relationship_seed_merger.py @@ -184,3 +184,33 @@ def test_merge_seeds_unions_symbol_spans_without_observed_at(): assert merged["nodes"][0]["attributes"]["end_line"] == 120 assert merged["nodes"][0]["attributes"]["line"] == 36 + + +def test_merge_seeds_coerces_string_end_line_without_observed_at(): + first = { + **BASE, + "nodes": [ + { + "kind": "symbol", + "external_id": "multi-ai-cli/backends/deepseek.py:DeepSeekBackend:37", + "attributes": {"name": "DeepSeekBackend", "line": 37, "end_line": 80}, + } + ], + "edges": [], + } + second = { + **BASE, + "nodes": [ + { + "kind": "symbol", + "external_id": "multi-ai-cli/backends/deepseek.py:DeepSeekBackend:37", + "attributes": {"name": "DeepSeekBackend", "line": "37", "end_line": "140"}, + } + ], + "edges": [], + } + + merged, _ = merge_seeds(first, second) + + assert merged["nodes"][0]["attributes"]["end_line"] == 140 + assert merged["nodes"][0]["attributes"]["line"] == 37