delimit-cli 4.4.0 → 4.5.1

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.
@@ -2095,6 +2095,7 @@ def delimit_memory_store(
2095
2095
  content: str,
2096
2096
  tags: Optional[Union[str, List[str]]] = None,
2097
2097
  context: Optional[str] = None,
2098
+ hot_load: bool = False,
2098
2099
  ) -> Dict[str, Any]:
2099
2100
  """Store a memory entry for future retrieval.
2100
2101
 
@@ -2105,6 +2106,12 @@ def delimit_memory_store(
2105
2106
  content: The content to remember.
2106
2107
  tags: Optional categorization tags.
2107
2108
  context: Optional context about when/why this was stored.
2109
+ hot_load: LED-1165 Phase 2 #5: when True, mark the entry for
2110
+ one-way projection into the Claude Code auto-memory
2111
+ `MEMORY.md` hot-load index. Existing entries default to False
2112
+ (durable in delimit_memory, not projected). The projection
2113
+ writer that consumes this flag is shipped separately as PR-B;
2114
+ this PR only persists the flag.
2108
2115
  """
2109
2116
  # LED-193: memory_store is now free (basic store)
2110
2117
  try:
@@ -2112,7 +2119,10 @@ def delimit_memory_store(
2112
2119
  except ValueError as e:
2113
2120
  return _with_next_steps("memory_store", {"error": str(e)})
2114
2121
  from backends.memory_bridge import store
2115
- return _with_next_steps("memory_store", _safe_call(store, content=content, tags=tags, context=context))
2122
+ return _with_next_steps(
2123
+ "memory_store",
2124
+ _safe_call(store, content=content, tags=tags, context=context, hot_load=hot_load),
2125
+ )
2116
2126
 
2117
2127
 
2118
2128
  @mcp.tool()
@@ -2129,6 +2139,50 @@ def delimit_memory_recent(limit: int = 5) -> Dict[str, Any]:
2129
2139
  return _with_next_steps("memory_recent", _safe_call(get_recent, limit=limit))
2130
2140
 
2131
2141
 
2142
+ @mcp.tool()
2143
+ def delimit_memory_index(
2144
+ target_path: str = "",
2145
+ dry_run: bool = False,
2146
+ limit: int = 200,
2147
+ ) -> Dict[str, Any]:
2148
+ """Project delimit_memory hot entries into Claude Code's MEMORY.md.
2149
+
2150
+ LED-1165 Phase 2 #5 PR-B. One-way projection of all delimit_memory
2151
+ entries flagged `hot_load=True` into a managed section of the
2152
+ target Markdown file. Lets Claude Code see hot entries on session
2153
+ start without making delimit_memory dependent on Anthropic's
2154
+ auto-memory format.
2155
+
2156
+ Behavior:
2157
+ - If target_path's file has `<!-- delimit:start -->` /
2158
+ `<!-- delimit:end -->` markers, ONLY the content between them
2159
+ is replaced. Anything outside the markers is preserved.
2160
+ - If markers are missing, the managed section is APPENDED to the
2161
+ end of the file (existing content is never touched).
2162
+ - If the file doesn't exist, it's created with just the section.
2163
+ - **One-way projection only.** Reading MEMORY.md back into
2164
+ delimit_memory is explicitly out of scope (Anthropic owns the
2165
+ auto-memory format; format-drift risk).
2166
+
2167
+ Args:
2168
+ target_path: file to write. Empty = default
2169
+ ~/.claude/projects/-root/memory/MEMORY.md.
2170
+ dry_run: True returns the rendered content size without writing.
2171
+ limit: cap on entries projected. Default 200.
2172
+
2173
+ Returns:
2174
+ {target, dry_run, entries, wrote_chars or would_write_chars,
2175
+ had_existing_block, had_existing_file, preserved_user_content}
2176
+ """
2177
+ from backends.memory_bridge import project_to_memory_md
2178
+ from pathlib import Path
2179
+ target = Path(target_path) if target_path else None
2180
+ return _with_next_steps(
2181
+ "memory_index",
2182
+ _safe_call(project_to_memory_md, target_path=target, dry_run=dry_run, limit=limit),
2183
+ )
2184
+
2185
+
2132
2186
  # ─── Vault ──────────────────────────────────────────────────────────────
2133
2187
 
2134
2188
  @mcp.tool()
@@ -5463,26 +5517,309 @@ def delimit_ledger_done(item_id: str, note: str = "", venture: str = "") -> Dict
5463
5517
  return _with_next_steps("ledger_done", result)
5464
5518
 
5465
5519
 
5520
+ @mcp.tool()
5521
+ def delimit_ledger_bulk(
5522
+ item_ids: str,
5523
+ action: str,
5524
+ dry_run: bool = True,
5525
+ note: str = "",
5526
+ new_status: str = "",
5527
+ new_priority: str = "",
5528
+ tag: str = "",
5529
+ venture: str = "",
5530
+ ) -> Dict[str, Any]:
5531
+ """Apply one action to many ledger items in a single call.
5532
+
5533
+ LED-1145 Phase 1 PR-B. Default `dry_run=True` returns what would change
5534
+ without writing — callers must explicitly pass `dry_run=False` to apply.
5535
+ Per-item failures don't block other items in the batch.
5536
+
5537
+ Allowed actions:
5538
+ - archive -> sets status="archived" (soft, replay-preserving)
5539
+ - mark_done -> sets status="done"
5540
+ - cancel -> sets status="cancelled"
5541
+ - set_status -> sets status to `new_status`
5542
+ (one of open/in_progress/blocked/done/cancelled/archived/completed)
5543
+ - set_priority -> sets priority to `new_priority`
5544
+ (one of P0/P1/P2/P3)
5545
+ - add_tag -> appends `tag` if not already present (idempotent)
5546
+
5547
+ NO hard delete. Items remain in the append-only JSONL forever; archive is
5548
+ a status transition that can be reversed via set_status.
5549
+
5550
+ Args:
5551
+ item_ids: comma-separated LED ids (e.g. "LED-915,LED-916,LED-918")
5552
+ or a JSON array of strings.
5553
+ action: one of the actions above.
5554
+ dry_run: True (default) returns `would_change`; False applies and
5555
+ returns `changed`.
5556
+ note: optional note attached to every successful update event.
5557
+ new_status: required when action="set_status".
5558
+ new_priority: required when action="set_priority".
5559
+ tag: required when action="add_tag".
5560
+ venture: project name or path. Auto-detects if empty.
5561
+ """
5562
+ from ai.ledger_manager import bulk_action
5563
+ project = _resolve_venture(venture)
5564
+ result = bulk_action(
5565
+ item_ids=item_ids,
5566
+ action=action,
5567
+ dry_run=dry_run,
5568
+ note=note or None,
5569
+ new_status=new_status or None,
5570
+ new_priority=new_priority or None,
5571
+ tag=tag or None,
5572
+ project_path=project,
5573
+ )
5574
+ return _with_next_steps("ledger_bulk", result)
5575
+
5576
+
5577
+ @mcp.tool()
5578
+ def delimit_ledger_auto_close_external(
5579
+ venture: str = "",
5580
+ dry_run: bool = True,
5581
+ max_items: int = 200,
5582
+ ) -> Dict[str, Any]:
5583
+ """Walk open ledger items, find ones linked to a GitHub issue/PR, and
5584
+ close LEDs whose external counterpart already resolved.
5585
+
5586
+ LED-1145 Phase 2 #1. Fixes the "ledger drifts from external reality"
5587
+ problem (e.g., LED-1023 stayed open even though goharbor/harbor#23089
5588
+ merged 16 days earlier).
5589
+
5590
+ Detection scans description / context / last_note / tags for:
5591
+ - https://github.com/<owner>/<repo>/(issues|pull)/<num>
5592
+ - <owner>/<repo>#<num> (short form)
5593
+ - gh:<owner>/<repo>/<num> (explicit tag form)
5594
+
5595
+ Action map (per LED-1146 deliberation):
5596
+ - PR with merged=true → mark_done with merge SHA in note
5597
+ - issue/PR closed with state_reason="completed" → mark_done with closed_at
5598
+ - issue/PR closed with state_reason="not_planned" or no reason → archive
5599
+ - state="open" → leave alone
5600
+ - gh API error / 404 → leave alone, recorded in `errors`
5601
+
5602
+ Implementation re-uses bulk_action() under the hood; nothing new on the
5603
+ write path. dry_run=True (default) returns a plan; dry_run=False applies.
5604
+
5605
+ Args:
5606
+ venture: project name or path. Auto-detects if empty.
5607
+ dry_run: True (default) returns a plan without writing.
5608
+ max_items: hard cap on items processed in one call (default 200).
5609
+ When the candidate set exceeds this, the response is `truncated=True`.
5610
+ """
5611
+ from ai.ledger_manager import auto_close_linked_external
5612
+ project = _resolve_venture(venture)
5613
+ result = auto_close_linked_external(
5614
+ project_path=project,
5615
+ dry_run=dry_run,
5616
+ max_items=max_items,
5617
+ )
5618
+ return _with_next_steps("ledger_auto_close_external", result)
5619
+
5620
+
5621
+ @mcp.tool()
5622
+ def delimit_ledger_groom(
5623
+ venture: str = "",
5624
+ stale_days: int = 30,
5625
+ dup_min_count: int = 3,
5626
+ max_per_category: int = 50,
5627
+ ) -> Dict[str, Any]:
5628
+ """Read-only grooming proposal: surfaces stale, duplicate, and
5629
+ garbage-venture items for the founder to review.
5630
+
5631
+ LED-1145 Phase 2 #2. Risky operations (mass-cancellation, dedup-merge)
5632
+ must NOT be a single atomic action — this tool only PROPOSES; the
5633
+ founder applies via `delimit_ledger_bulk` after review. Each proposal
5634
+ in the response includes a copy-pasteable `ready_to_apply` invocation.
5635
+
5636
+ Categories detected:
5637
+ - stale_open: status open|in_progress|blocked AND updated_at older
5638
+ than `stale_days`. Suggested action: archive.
5639
+ - duplicate_titles: groups of >= `dup_min_count` items sharing the
5640
+ same normalised title prefix (50 chars, [BRACKETED] prefixes
5641
+ stripped, lowercased). Suggested: keep the most-recent item;
5642
+ archive the others.
5643
+ - garbage_venture: items in tmp* / test_* / venture_<letter> /
5644
+ custom-venture buckets. Suggested action: archive.
5645
+
5646
+ Categories NOT detected here (separate tools / future PRs):
5647
+ - linked-external resolved → use `delimit_ledger_auto_close_external`
5648
+ - P0 inflation review
5649
+ - cross-venture orphan cleanup
5650
+
5651
+ Args:
5652
+ venture: project name or path. Auto-detects if empty.
5653
+ stale_days: threshold for stale_open detector (default 30).
5654
+ dup_min_count: minimum group size for duplicate_titles (default 3).
5655
+ max_per_category: cap per category in the response (default 50).
5656
+ """
5657
+ from ai.ledger_manager import groom_proposal
5658
+ project = _resolve_venture(venture)
5659
+ result = groom_proposal(
5660
+ project_path=project,
5661
+ stale_days=stale_days,
5662
+ dup_min_count=dup_min_count,
5663
+ max_per_category=max_per_category,
5664
+ )
5665
+ return _with_next_steps("ledger_groom", result)
5666
+
5667
+
5668
+ @mcp.tool()
5669
+ def delimit_ledger_auto_cancel_stale(
5670
+ venture: str = "",
5671
+ threshold_days: int = 0,
5672
+ dry_run: bool = True,
5673
+ max_items: int = 200,
5674
+ ) -> Dict[str, Any]:
5675
+ """Auto-archive open ledger items dormant past the stale-TTL threshold.
5676
+
5677
+ LED-1145 Phase 2 #4. Composes the stale-detector logic with
5678
+ `bulk_action(action="archive")` from Phase 1 PR-B. Same dry_run-default
5679
+ safety pattern as `delimit_ledger_auto_close_external` — caller passes
5680
+ `dry_run=False` to apply.
5681
+
5682
+ Distinct from `delimit_ledger_groom`'s stale_open category:
5683
+ - The threshold is stricter (default 60 days vs groom's 30)
5684
+ - It auto-applies on dry_run=False (groom is purely propose)
5685
+ - Intended for nightly automation / scripted cleanup
5686
+
5687
+ Items go through bulk_action(archive) so the no-hard-delete invariant
5688
+ is preserved — the JSONL append-only log keeps the full record.
5689
+
5690
+ Args:
5691
+ venture: Project name or path. Auto-detects if empty.
5692
+ threshold_days: dormancy threshold in days. 0 = read default
5693
+ (60 from STALE_TTL_DEFAULT_DAYS or DELIMIT_STALE_TTL_DAYS env).
5694
+ Pass an int to override.
5695
+ dry_run: True (default) returns the plan; False applies via
5696
+ bulk_action(archive).
5697
+ max_items: cap items processed per call. When the candidate list
5698
+ exceeds this, response includes truncated=True so the caller
5699
+ can run again to drain.
5700
+ """
5701
+ from ai.ledger_manager import auto_cancel_stale
5702
+ project = _resolve_venture(venture)
5703
+ threshold = threshold_days if threshold_days > 0 else None
5704
+ result = auto_cancel_stale(
5705
+ project_path=project,
5706
+ threshold_days=threshold,
5707
+ dry_run=dry_run,
5708
+ max_items=max_items,
5709
+ )
5710
+ return _with_next_steps("ledger_auto_cancel_stale", result)
5711
+
5712
+
5713
+ @mcp.tool()
5714
+ def delimit_ledger_health(
5715
+ venture: str = "",
5716
+ stale_days: int = 30,
5717
+ dup_min_count: int = 3,
5718
+ ) -> Dict[str, Any]:
5719
+ """One-shot ledger health check — composes list_items + groom_proposal
5720
+ + the P0 quota helper into a single traffic-light verdict and a list
5721
+ of concrete next actions.
5722
+
5723
+ LED-1145 capstone — closes the loop on the entire ledger-tooling
5724
+ refactor. Designed for nightly/weekly review or session-start status
5725
+ snapshot. Returns:
5726
+ - totals (unresolved / open / in_progress / blocked)
5727
+ - p0 (count vs quota + health)
5728
+ - stale (count >stale_days + health)
5729
+ - duplicates (group count + total items + health)
5730
+ - garbage_venture (count + health)
5731
+ - overall_health (worst-of: green / yellow / red)
5732
+ - next_actions: pre-formatted list of {reason, tool, args, follow_up}
5733
+
5734
+ All Phase 1+2 tools are referenced in the suggested actions, so the
5735
+ response is self-contained for an AI agent that wants to act on it.
5736
+
5737
+ Args:
5738
+ venture: project name or path. Auto-detects if empty.
5739
+ stale_days: stale-detector threshold passed to groom_proposal.
5740
+ dup_min_count: duplicate-detector threshold passed to groom_proposal.
5741
+ """
5742
+ from ai.ledger_manager import health_summary
5743
+ project = _resolve_venture(venture)
5744
+ result = health_summary(
5745
+ project_path=project,
5746
+ stale_days=stale_days,
5747
+ dup_min_count=dup_min_count,
5748
+ )
5749
+ return _with_next_steps("ledger_health", result)
5750
+
5751
+
5466
5752
  @mcp.tool()
5467
5753
  def delimit_ledger_list(
5468
5754
  venture: str = "",
5469
5755
  ledger: str = "both",
5470
5756
  status: str = "",
5471
5757
  priority: str = "",
5758
+ status_in: str = "",
5759
+ priority_in: str = "",
5760
+ tags_contains_all: str = "",
5761
+ text: str = "",
5762
+ linked_external_id: str = "",
5763
+ created_before: str = "",
5764
+ created_after: str = "",
5765
+ updated_before: str = "",
5766
+ updated_after: str = "",
5767
+ sort: str = "updated_at",
5768
+ order: str = "desc",
5769
+ fields: str = "",
5472
5770
  limit: int = 20,
5771
+ cursor: str = "",
5473
5772
  ) -> Dict[str, Any]:
5474
5773
  """List ledger items for a venture/project.
5475
5774
 
5775
+ LED-1145 Phase 1 PR-A: extended with multi-value filters, sort + projection,
5776
+ and cursor pagination. Single-value `status` / `priority` remain for
5777
+ backward compatibility — old callers continue to work unchanged.
5778
+
5476
5779
  Args:
5477
5780
  venture: Project name or path. Auto-detects if empty.
5478
- ledger: "ops", "strategy", or "both".
5479
- status: Filter by status - "open", "done", "in_progress", or empty for all.
5480
- priority: Filter by priority - "P0", "P1", "P2", or empty for all.
5481
- limit: Max items to return.
5781
+ ledger: "ops" | "strategy" | "both".
5782
+ status: single-value status filter (back-compat).
5783
+ priority: single-value priority filter (back-compat).
5784
+ status_in: comma-separated statuses (e.g. "open,blocked,in_progress").
5785
+ priority_in: comma-separated priorities (e.g. "P0,P1").
5786
+ tags_contains_all: comma-separated tags; item must contain ALL.
5787
+ text: case-insensitive substring match against title + description.
5788
+ linked_external_id: substring match in description / tags / context
5789
+ (e.g. a github URL, a Linear ticket id, a Discord thread).
5790
+ created_before / created_after / updated_before / updated_after:
5791
+ ISO-8601 timestamp boundaries (e.g. "2026-04-01T00:00:00Z").
5792
+ sort: "updated_at" | "created_at" | "priority". Default updated_at.
5793
+ order: "asc" | "desc". Default desc.
5794
+ fields: response projection. "" or "*" = full (default, back-compat).
5795
+ "slim" = id+title+status+priority+type+venture+updated_at only.
5796
+ CSV of field names = those only. Unknown field names → ERROR.
5797
+ limit: page size (default 20).
5798
+ cursor: opaque pagination token from a prior call's `next_cursor`.
5799
+ Cursor is invalidated when filters change between calls.
5482
5800
  """
5483
5801
  from ai.ledger_manager import list_items
5484
5802
  project = _resolve_venture(venture)
5485
- result = list_items(ledger=ledger, status=status or None, priority=priority or None, limit=limit, project_path=project)
5803
+ result = list_items(
5804
+ ledger=ledger,
5805
+ status=status or None,
5806
+ priority=priority or None,
5807
+ status__in=status_in or None,
5808
+ priority__in=priority_in or None,
5809
+ tags__contains_all=tags_contains_all or None,
5810
+ text=text or None,
5811
+ linked_external_id=linked_external_id or None,
5812
+ created_before=created_before or None,
5813
+ created_after=created_after or None,
5814
+ updated_before=updated_before or None,
5815
+ updated_after=updated_after or None,
5816
+ sort=sort,
5817
+ order=order,
5818
+ fields=fields or None,
5819
+ limit=limit,
5820
+ cursor=cursor or None,
5821
+ project_path=project,
5822
+ )
5486
5823
  return _with_next_steps("ledger_list", result)
5487
5824
 
5488
5825
 
@@ -7903,7 +8240,11 @@ def delimit_changelog(old_spec: str = "", new_spec: str = "", format: str = "mar
7903
8240
  def delimit_notify(channel: str = "webhook", message: str = "",
7904
8241
  webhook_url: str = "", subject: str = "",
7905
8242
  event_type: str = "", to: str = "",
7906
- from_account: str = "") -> Dict[str, Any]:
8243
+ from_account: str = "",
8244
+ draft_kind: str = "",
8245
+ draft_payload: Optional[Union[str, Dict[str, Any]]] = None,
8246
+ draft_target: Optional[Union[str, Dict[str, Any]]] = None,
8247
+ led_ref: str = "") -> Dict[str, Any]:
7907
8248
  """Send a notification (Pro).
7908
8249
 
7909
8250
  IMPORTANT - AUTO-TRIGGER RULE:
@@ -7932,9 +8273,80 @@ def delimit_notify(channel: str = "webhook", message: str = "",
7932
8273
  Send to any address - leave empty for default.
7933
8274
  from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
7934
8275
  (e.g. 'notifications@example.com'). Email only.
8276
+
8277
+ Optional inbox-executor binding (LED-1129 Phase 1, no auto-execution yet):
8278
+ draft_kind: One of github_comment, social_post, ledger_done,
8279
+ notify_routing_update, deploy_publish_prevalidated_artifact.
8280
+ When set, registers a signed draft in the local SQLite registry
8281
+ so a future executor can match founder Ship-it replies against it.
8282
+ draft_payload: The action contents (e.g. {"body": "..."} for github_comment).
8283
+ JSON string or dict. Required when draft_kind is set.
8284
+ draft_target: Where the action lands (e.g. {"repo":"x/y","issue":1}).
8285
+ JSON string or dict. Required when draft_kind is set.
8286
+ led_ref: Optional LED-XXXX tag tying the draft to its tracking item.
8287
+ Surfaced in subject-line matching by the executor.
8288
+
8289
+ Returns the notify result. When a draft was registered, also includes
8290
+ a `draft` block: {draft_id, draft_kind, signature, registered}.
7935
8291
  """
7936
8292
  from ai.notify import send_notification
7937
- return _with_next_steps("notify", _safe_call(
8293
+
8294
+ draft_meta: Optional[Dict[str, Any]] = None
8295
+ if draft_kind:
8296
+ # LED-1129 Phase 1: register a signed draft alongside the email
8297
+ # send. Phase 2 will wire the executor to consume these.
8298
+ try:
8299
+ from ai.inbox_drafts import (
8300
+ DraftKind,
8301
+ insert_draft,
8302
+ sign_draft,
8303
+ )
8304
+
8305
+ # Validate kind against the allowlist enum.
8306
+ try:
8307
+ DraftKind(draft_kind)
8308
+ except ValueError:
8309
+ return _with_next_steps("notify", {
8310
+ "error": (
8311
+ f"draft_kind must be one of "
8312
+ f"{[k.value for k in DraftKind]}; got '{draft_kind}'"
8313
+ ),
8314
+ })
8315
+
8316
+ # Coerce string args to dicts for callers that pass JSON strings.
8317
+ payload = _coerce_dict_arg(draft_payload, "draft_payload",
8318
+ string_key="body") if draft_payload is not None else None
8319
+ target = _coerce_dict_arg(draft_target, "draft_target",
8320
+ string_key="target") if draft_target is not None else None
8321
+
8322
+ if payload is None or target is None:
8323
+ return _with_next_steps("notify", {
8324
+ "error": "draft_kind requires both draft_payload and draft_target",
8325
+ })
8326
+
8327
+ signed = sign_draft(
8328
+ draft_kind=draft_kind,
8329
+ target=target,
8330
+ payload=payload,
8331
+ )
8332
+ insert_draft(signed, led_ref=(led_ref or None))
8333
+ draft_meta = {
8334
+ "draft_id": signed.draft_id,
8335
+ "draft_kind": signed.draft_kind,
8336
+ "signature": signed.signature,
8337
+ "registered": True,
8338
+ "led_ref": led_ref or None,
8339
+ }
8340
+ except Exception as e:
8341
+ # Draft registration must not break the notify itself —
8342
+ # the email still goes out, the draft just isn't tracked.
8343
+ # Log the failure in the response so callers can audit.
8344
+ draft_meta = {
8345
+ "registered": False,
8346
+ "error": f"{type(e).__name__}: {e}",
8347
+ }
8348
+
8349
+ result = _safe_call(
7938
8350
  send_notification,
7939
8351
  channel=channel,
7940
8352
  message=message,
@@ -7943,7 +8355,10 @@ def delimit_notify(channel: str = "webhook", message: str = "",
7943
8355
  event_type=event_type,
7944
8356
  to=to,
7945
8357
  from_account=from_account,
7946
- ))
8358
+ )
8359
+ if draft_meta is not None:
8360
+ result["draft"] = draft_meta
8361
+ return _with_next_steps("notify", result)
7947
8362
 
7948
8363
 
7949
8364
  @mcp.tool()
@@ -100,6 +100,14 @@ class OpenAPIDiffEngine:
100
100
 
101
101
  def _compare_paths(self, old_paths: Dict, new_paths: Dict):
102
102
  """Compare API paths/endpoints."""
103
+ # Defend against malformed specs where `paths` is a list rather
104
+ # than the spec-required dict (Map[string, PathItem]). Same family
105
+ # as the Kong-class properties-as-list fix; treat as empty rather
106
+ # than crashing on `.keys()`.
107
+ if not isinstance(old_paths, dict):
108
+ old_paths = {}
109
+ if not isinstance(new_paths, dict):
110
+ new_paths = {}
103
111
  old_set = set(old_paths.keys())
104
112
  new_set = set(new_paths.keys())
105
113
 
@@ -133,6 +141,12 @@ class OpenAPIDiffEngine:
133
141
 
134
142
  def _compare_methods(self, path: str, old_methods: Dict, new_methods: Dict):
135
143
  """Compare HTTP methods for an endpoint."""
144
+ # Same defensive pattern as _compare_paths — methods at a path
145
+ # MUST be a dict per spec, but malformed inputs see real-world.
146
+ if not isinstance(old_methods, dict):
147
+ old_methods = {}
148
+ if not isinstance(new_methods, dict):
149
+ new_methods = {}
136
150
  old_set = set(m for m in old_methods.keys() if m in self.HTTP_METHODS)
137
151
  new_set = set(m for m in new_methods.keys() if m in self.HTTP_METHODS)
138
152
 
@@ -327,9 +341,11 @@ class OpenAPIDiffEngine:
327
341
  ))
328
342
  elif old_body and new_body:
329
343
  # Compare content types
330
- old_content = old_body.get("content", {})
331
- new_content = new_body.get("content", {})
332
-
344
+ raw_old_content = old_body.get("content", {})
345
+ raw_new_content = new_body.get("content", {})
346
+ old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
347
+ new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
348
+
333
349
  for content_type in old_content.keys() & new_content.keys():
334
350
  self._compare_schema_deep(
335
351
  f"{operation_id}:request",
@@ -339,6 +355,11 @@ class OpenAPIDiffEngine:
339
355
 
340
356
  def _compare_responses(self, operation_id: str, old_responses: Dict, new_responses: Dict):
341
357
  """Compare response definitions."""
358
+ # Defend against malformed specs where `responses` is a list.
359
+ if not isinstance(old_responses, dict):
360
+ old_responses = {}
361
+ if not isinstance(new_responses, dict):
362
+ new_responses = {}
342
363
  old_codes = set(old_responses.keys())
343
364
  new_codes = set(new_responses.keys())
344
365
 
@@ -360,9 +381,11 @@ class OpenAPIDiffEngine:
360
381
  new_resp = new_responses[code]
361
382
 
362
383
  if "content" in old_resp or "content" in new_resp:
363
- old_content = old_resp.get("content", {})
364
- new_content = new_resp.get("content", {})
365
-
384
+ raw_old_content = old_resp.get("content", {})
385
+ raw_new_content = new_resp.get("content", {})
386
+ old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
387
+ new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
388
+
366
389
  for content_type in old_content.keys() & new_content.keys():
367
390
  self._compare_schema_deep(
368
391
  f"{operation_id}:{code}",
@@ -414,10 +437,22 @@ class OpenAPIDiffEngine:
414
437
 
415
438
  # Compare object properties
416
439
  if old_type == "object":
417
- old_props = old_schema.get("properties", {})
418
- new_props = new_schema.get("properties", {})
419
- old_required = set(old_schema.get("required", []))
420
- new_required = set(new_schema.get("required", []))
440
+ raw_old_props = old_schema.get("properties", {})
441
+ raw_new_props = new_schema.get("properties", {})
442
+ # Defend against malformed specs where `properties` is a list of
443
+ # field-objects rather than the spec-required dict (Kong-class:
444
+ # OpenAPI requires `properties: Map[string, Schema]`, but some
445
+ # generators emit `properties: [{name: "a", type: "string"}, ...]`).
446
+ # Treat as empty rather than crashing on `.keys()`.
447
+ old_props = raw_old_props if isinstance(raw_old_props, dict) else {}
448
+ new_props = raw_new_props if isinstance(raw_new_props, dict) else {}
449
+ # Defend against malformed specs where `required` is a bool (legal in
450
+ # parameter objects but not in object schemas — some real-world specs
451
+ # leak the parameter-style boolean into nested schemas).
452
+ raw_old_required = old_schema.get("required", [])
453
+ raw_new_required = new_schema.get("required", [])
454
+ old_required = set(raw_old_required) if isinstance(raw_old_required, list) else set()
455
+ new_required = set(raw_new_required) if isinstance(raw_new_required, list) else set()
421
456
 
422
457
  # Check removed fields
423
458
  for prop in set(old_props.keys()) - set(new_props.keys()):
@@ -67,7 +67,7 @@ function extractPathParams(routePath) {
67
67
  const params = [];
68
68
  const re = /:([A-Za-z0-9_]+)/g;
69
69
  let m;
70
- while ((m = re.exec(routePath)) !== null) { # nosec B-exec_usage: AST exec of a sandboxed Express route extractor on parsed code
70
+ while ((m = re.exec(routePath)) !== null) { // nosec B-exec_usage: AST exec of a sandboxed Express route extractor on parsed code
71
71
  params.push(m[1]);
72
72
  }
73
73
  return params;