confgraph 0.3.7__tar.gz → 0.3.9__tar.gz

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 (125) hide show
  1. {confgraph-0.3.7 → confgraph-0.3.9}/PKG-INFO +14 -1
  2. {confgraph-0.3.7 → confgraph-0.3.9}/README.md +13 -0
  3. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/interface.py +28 -5
  4. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/ospf.py +33 -3
  5. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/topology.py +12 -1
  6. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/__init__.py +99 -1
  7. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/base.py +50 -9
  8. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/eos_parser.py +9 -2
  9. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/ios_parser.py +73 -10
  10. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/iosxr_parser.py +169 -17
  11. confgraph-0.3.9/confgraph/parsers/junos_deletions.py +308 -0
  12. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/junos_hierarchy.py +133 -11
  13. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/junos_parser.py +89 -12
  14. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/nxos_parser.py +48 -13
  15. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/tombstones.py +15 -0
  16. confgraph-0.3.9/confgraph/utils/ospf.py +73 -0
  17. confgraph-0.3.9/dist-0.3.7/confgraph-0.3.7-py3-none-any.whl +0 -0
  18. confgraph-0.3.9/dist-0.3.7/confgraph-0.3.7.tar.gz +0 -0
  19. confgraph-0.3.9/dist-0.3.8/.gitignore +1 -0
  20. confgraph-0.3.9/dist-0.3.8/confgraph-0.3.8-py3-none-any.whl +0 -0
  21. confgraph-0.3.9/dist-0.3.8/confgraph-0.3.8.tar.gz +0 -0
  22. confgraph-0.3.9/dist-0.3.9/.gitignore +1 -0
  23. {confgraph-0.3.7 → confgraph-0.3.9}/pyproject.toml +1 -1
  24. {confgraph-0.3.7 → confgraph-0.3.9}/uv.lock +1 -1
  25. {confgraph-0.3.7 → confgraph-0.3.9}/.github/workflows/publish.yml +0 -0
  26. {confgraph-0.3.7 → confgraph-0.3.9}/.github/workflows/redos-scan.yml +0 -0
  27. {confgraph-0.3.7 → confgraph-0.3.9}/.gitignore +0 -0
  28. {confgraph-0.3.7 → confgraph-0.3.9}/.python-version +0 -0
  29. {confgraph-0.3.7 → confgraph-0.3.9}/CHANGELOG.md +0 -0
  30. {confgraph-0.3.7 → confgraph-0.3.9}/CLAUDE.md +0 -0
  31. {confgraph-0.3.7 → confgraph-0.3.9}/INTERFACE.md +0 -0
  32. {confgraph-0.3.7 → confgraph-0.3.9}/LICENSE +0 -0
  33. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/analysis/__init__.py +0 -0
  34. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/analysis/dependency_resolver.py +0 -0
  35. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/change_ir.py +0 -0
  36. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/cli.py +0 -0
  37. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/__init__.py +0 -0
  38. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/assets/cytoscape-dagre.min.js +0 -0
  39. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/assets/cytoscape.min.js +0 -0
  40. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/assets/dagre.min.js +0 -0
  41. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/builder.py +0 -0
  42. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/exporters/__init__.py +0 -0
  43. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/exporters/base.py +0 -0
  44. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/exporters/html.py +0 -0
  45. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/graph/exporters/json.py +0 -0
  46. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/loader.py +0 -0
  47. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/__init__.py +0 -0
  48. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/aaa.py +0 -0
  49. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/acl.py +0 -0
  50. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/banner.py +0 -0
  51. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/base.py +0 -0
  52. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/bfd.py +0 -0
  53. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/bgp.py +0 -0
  54. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/cdp.py +0 -0
  55. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/community_list.py +0 -0
  56. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/crypto.py +0 -0
  57. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/dhcp.py +0 -0
  58. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/dns.py +0 -0
  59. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/eem.py +0 -0
  60. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/eigrp.py +0 -0
  61. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/evpn.py +0 -0
  62. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/ipsla.py +0 -0
  63. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/isis.py +0 -0
  64. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/line.py +0 -0
  65. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/lldp.py +0 -0
  66. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/logging_config.py +0 -0
  67. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/mpls.py +0 -0
  68. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/multicast.py +0 -0
  69. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/nat.py +0 -0
  70. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/netflow.py +0 -0
  71. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/ntp.py +0 -0
  72. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/object_group.py +0 -0
  73. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/object_tracking.py +0 -0
  74. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/panos_zone.py +0 -0
  75. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/parsed_config.py +0 -0
  76. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/prefix_list.py +0 -0
  77. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/qos.py +0 -0
  78. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/rip.py +0 -0
  79. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/route_map.py +0 -0
  80. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/snmp.py +0 -0
  81. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/static_route.py +0 -0
  82. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/stp.py +0 -0
  83. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/vlan.py +0 -0
  84. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/vpc.py +0 -0
  85. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/vrf.py +0 -0
  86. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/models/vxlan.py +0 -0
  87. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/junos_groups.py +0 -0
  88. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/panos_parser.py +0 -0
  89. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/parsers/panos_xml.py +0 -0
  90. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/topology/__init__.py +0 -0
  91. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/topology/exporters.py +0 -0
  92. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/topology/graph.py +0 -0
  93. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/topology/ingest.py +0 -0
  94. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/utils/__init__.py +0 -0
  95. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/utils/escaping.py +0 -0
  96. {confgraph-0.3.7 → confgraph-0.3.9}/confgraph/utils/interface.py +0 -0
  97. {confgraph-0.3.7 → confgraph-0.3.9}/dist-0.3.7/.gitignore +0 -0
  98. {confgraph-0.3.7 → confgraph-0.3.9}/docs/ADDING_NEW_OS_SUPPORT.md +0 -0
  99. {confgraph-0.3.7 → confgraph-0.3.9}/docs/ARCHITECTURE.md +0 -0
  100. {confgraph-0.3.7 → confgraph-0.3.9}/docs/CLI_USAGE.md +0 -0
  101. {confgraph-0.3.7 → confgraph-0.3.9}/docs/EOS_PARSER_SUPPORT.md +0 -0
  102. {confgraph-0.3.7 → confgraph-0.3.9}/docs/IOSXR_PARSER_SUPPORT.md +0 -0
  103. {confgraph-0.3.7 → confgraph-0.3.9}/docs/IOS_PARSER_SUPPORT.md +0 -0
  104. {confgraph-0.3.7 → confgraph-0.3.9}/docs/JUNOS_PARSER_SUPPORT.md +0 -0
  105. {confgraph-0.3.7 → confgraph-0.3.9}/docs/NXOS_PARSER_SUPPORT.md +0 -0
  106. {confgraph-0.3.7 → confgraph-0.3.9}/docs/PANOS_PARSER_SUPPORT.md +0 -0
  107. {confgraph-0.3.7 → confgraph-0.3.9}/docs/PARSER_SUPPORT_MATRIX.md +0 -0
  108. {confgraph-0.3.7 → confgraph-0.3.9}/docs/bgp_cluster_view.png +0 -0
  109. {confgraph-0.3.7 → confgraph-0.3.9}/docs/default_view.png +0 -0
  110. {confgraph-0.3.7 → confgraph-0.3.9}/docs/specific_node_highlight.png +0 -0
  111. {confgraph-0.3.7 → confgraph-0.3.9}/docs/supported_os_types.md +0 -0
  112. {confgraph-0.3.7 → confgraph-0.3.9}/main.py +0 -0
  113. {confgraph-0.3.7 → confgraph-0.3.9}/samples/complex_router.ios +0 -0
  114. {confgraph-0.3.7 → confgraph-0.3.9}/samples/eos.txt +0 -0
  115. {confgraph-0.3.7 → confgraph-0.3.9}/samples/ios.txt +0 -0
  116. {confgraph-0.3.7 → confgraph-0.3.9}/samples/ios01.txt +0 -0
  117. {confgraph-0.3.7 → confgraph-0.3.9}/samples/ios_xe.txt +0 -0
  118. {confgraph-0.3.7 → confgraph-0.3.9}/samples/ios_xr.txt +0 -0
  119. {confgraph-0.3.7 → confgraph-0.3.9}/samples/iosxr_test.cfg +0 -0
  120. {confgraph-0.3.7 → confgraph-0.3.9}/samples/junos_test.cfg +0 -0
  121. {confgraph-0.3.7 → confgraph-0.3.9}/samples/nxos.txt +0 -0
  122. {confgraph-0.3.7 → confgraph-0.3.9}/samples/panos_sample.xml +0 -0
  123. {confgraph-0.3.7 → confgraph-0.3.9}/tools/redos_baseline.json +0 -0
  124. {confgraph-0.3.7 → confgraph-0.3.9}/tools/redos_scan.json +0 -0
  125. {confgraph-0.3.7 → confgraph-0.3.9}/tools/redos_scan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: confgraph
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: Local-first network config dependency mapper — parse, visualize, and lint vendor configs
5
5
  Project-URL: Homepage, https://github.com/verigraphs/confgraph
6
6
  Project-URL: Repository, https://github.com/verigraphs/confgraph
@@ -131,6 +131,19 @@ See [`docs/CLI_USAGE.md`](docs/CLI_USAGE.md) for all commands, options, and exam
131
131
 
132
132
  Contributions welcome — new parsers, bug fixes, additional protocol coverage. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) to get started.
133
133
 
134
+ ### Running the tests
135
+
136
+ ```bash
137
+ uv run --extra dev pytest
138
+ ```
139
+
140
+ **The `--extra dev` is not optional.** `pytest` is an optional dependency (see
141
+ `[project.optional-dependencies]`), so a bare `uv run pytest` falls back to
142
+ whatever `pytest` is on your `PATH` — which runs the suite against a different
143
+ environment and fails well over a thousand tests for reasons that have nothing
144
+ to do with your change. There is no error message saying so; it just looks like
145
+ you broke everything.
146
+
134
147
  ## License
135
148
 
136
149
  Apache 2.0
@@ -101,6 +101,19 @@ See [`docs/CLI_USAGE.md`](docs/CLI_USAGE.md) for all commands, options, and exam
101
101
 
102
102
  Contributions welcome — new parsers, bug fixes, additional protocol coverage. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) to get started.
103
103
 
104
+ ### Running the tests
105
+
106
+ ```bash
107
+ uv run --extra dev pytest
108
+ ```
109
+
110
+ **The `--extra dev` is not optional.** `pytest` is an optional dependency (see
111
+ `[project.optional-dependencies]`), so a bare `uv run pytest` falls back to
112
+ whatever `pytest` is on your `PATH` — which runs the suite against a different
113
+ environment and fails well over a thousand tests for reasons that have nothing
114
+ to do with your change. There is no error message saying so; it just looks like
115
+ you broke everything.
116
+
104
117
  ## License
105
118
 
106
119
  Apache 2.0
@@ -2,8 +2,9 @@
2
2
 
3
3
  from enum import Enum
4
4
  from ipaddress import IPv4Address, IPv4Interface, IPv6Interface
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, Field, field_validator
6
6
  from confgraph.models.base import BaseConfigObject
7
+ from confgraph.utils.ospf import canonical_area_id
7
8
 
8
9
 
9
10
  class InterfaceType(str, Enum):
@@ -355,7 +356,11 @@ class InterfaceConfig(BaseConfigObject):
355
356
  )
356
357
  ospf_area: str | None = Field(
357
358
  default=None,
358
- description="OSPF area ID",
359
+ description=(
360
+ "OSPF area ID, CANONICALIZED to the decimal spelling (CCR-0227) "
361
+ "so a '0.0.0.0' interface and a '0' interface are in the same "
362
+ "area here, as they are on hardware."
363
+ ),
359
364
  )
360
365
  ospf_cost: int | None = Field(
361
366
  default=None,
@@ -377,9 +382,22 @@ class InterfaceConfig(BaseConfigObject):
377
382
  default=None,
378
383
  description="OSPF network type ('point-to-point', 'broadcast', etc.)",
379
384
  )
380
- ospf_passive: bool = Field(
381
- default=False,
382
- description="OSPF passive interface",
385
+ ospf_passive: bool | None = Field(
386
+ default=None,
387
+ description=(
388
+ "OSPF passive interface. TRI-STATE (CCR-0222, the CCR-0165 "
389
+ "``activate`` precedent): True = this interface is passive "
390
+ "(an interface-level ``ip ospf passive-interface``, or the "
391
+ "process-level passive config back-filled by the M3 walk); "
392
+ "False = EXPLICITLY non-passive (``no ip ospf passive-interface`` "
393
+ "— the per-interface override of a process-level "
394
+ "``passive-interface default``); None = NOT STATED on the "
395
+ "interface, so the consumer falls back to the process-level "
396
+ "passive config. A bool default of False could not express the "
397
+ "override at all: the M3 back-fill clobbered it, a field-level "
398
+ "merge could never overlay it (the CCR-0211 disease), and no "
399
+ "Change-IR SET op was emitted for it."
400
+ ),
383
401
  )
384
402
  ospf_authentication: str | None = Field(
385
403
  default=None,
@@ -646,6 +664,11 @@ class InterfaceConfig(BaseConfigObject):
646
664
  or self.dynamic_address
647
665
  )
648
666
 
667
+ # CCR-0227: area-ID storage point 4 of 4.
668
+ _canonical_area = field_validator("ospf_area", mode="before")(
669
+ canonical_area_id
670
+ )
671
+
649
672
  class Config:
650
673
  """Pydantic model configuration."""
651
674
  use_enum_values = True
@@ -2,8 +2,9 @@
2
2
 
3
3
  from enum import Enum
4
4
  from ipaddress import IPv4Address, IPv4Network
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, Field, field_validator
6
6
  from confgraph.models.base import BaseConfigObject
7
+ from confgraph.utils.ospf import canonical_area_id
7
8
 
8
9
 
9
10
  class OSPFAreaType(str, Enum):
@@ -159,12 +160,24 @@ class OSPFInterfaceConfig(BaseModel):
159
160
  default=None, description="BFD detection multiplier"
160
161
  )
161
162
 
163
+ # CCR-0227: area-ID storage point 1 of 4.
164
+ _canonical_area = field_validator("area_id", mode="before")(
165
+ canonical_area_id
166
+ )
167
+
162
168
 
163
169
  class OSPFArea(BaseModel):
164
170
  """OSPF area configuration."""
165
171
 
166
172
  area_id: str = Field(
167
- ..., description="Area ID (e.g., '0', '0.0.0.1', '10')"
173
+ ...,
174
+ description=(
175
+ "Area ID, CANONICALIZED to the decimal spelling (CCR-0227): the "
176
+ "NX-OS/JunOS '0.0.0.0' and the IOS/IOS-XR '0' are the same area on "
177
+ "hardware and are the same key here. Dotted quads convert to their "
178
+ "32-bit decimal ('0.0.0.51' -> '51'); anything else is stored as "
179
+ "written."
180
+ ),
168
181
  )
169
182
  area_type: OSPFAreaType = Field(
170
183
  default=OSPFAreaType.NORMAL, description="Area type"
@@ -220,6 +233,11 @@ class OSPFArea(BaseModel):
220
233
  description="Prefix-list name filtering Type-3 LSAs sent out of this area (outbound from area)",
221
234
  )
222
235
 
236
+ # CCR-0227: area-ID storage point 2 of 4.
237
+ _canonical_area = field_validator("area_id", mode="before")(
238
+ canonical_area_id
239
+ )
240
+
223
241
  class Config:
224
242
  """Pydantic model configuration."""
225
243
  use_enum_values = True
@@ -326,7 +344,11 @@ class OSPFConfig(BaseConfigObject):
326
344
  )
327
345
  network_statements: list[tuple[IPv4Network, str]] = Field(
328
346
  default_factory=list,
329
- description="Network statements (network, wildcard, area) - IOS style",
347
+ description=(
348
+ "Network statements (network, area) - IOS style. The area half is "
349
+ "canonicalized exactly like every other area-ID storage point "
350
+ "(CCR-0227)."
351
+ ),
330
352
  )
331
353
  shutdown: bool = Field(
332
354
  default=False, description="OSPF process shutdown"
@@ -341,6 +363,14 @@ class OSPFConfig(BaseConfigObject):
341
363
  default=False, description="BFD enabled on all OSPF interfaces"
342
364
  )
343
365
 
366
+ # CCR-0227: area-ID storage point 3 of 4 — the area half of each
367
+ # statement. ``mode="after"`` so the tuples are already built; the
368
+ # network half is untouched.
369
+ @field_validator("network_statements", mode="after")
370
+ @classmethod
371
+ def _canonical_statement_areas(cls, value):
372
+ return [(net, canonical_area_id(area)) for net, area in value]
373
+
344
374
  class Config:
345
375
  """Pydantic model configuration."""
346
376
  use_enum_values = True
@@ -28,13 +28,24 @@ class PhysicalLink:
28
28
  "cdp" — from CDP neighbor data
29
29
  "lldp" — from LLDP neighbor data
30
30
  "mac-arp" — validated via MAC-ARP correlation (supplementary only)
31
+ "upload" — supplied by the operator (an export from their source
32
+ of truth / NMS / IPAM, or a topology file)
33
+ "synth" — INFERRED, not observed: synthesized from two devices
34
+ having interfaces on a shared L3 subnet
35
+
36
+ The "synth" value is load-bearing rather than merely informative, so a
37
+ producer must label an inferred link honestly. Consumers distinguish
38
+ supplied cabling from inference on this field alone — the simulator's
39
+ link-vs-config consistency checks deliberately say nothing about a "synth"
40
+ link, because a warning citing one would be the engine disputing its own
41
+ inference rather than disclosing an input it cannot trust.
31
42
  """
32
43
 
33
44
  device_a: str
34
45
  port_a: str # canonical long-form logical interface on device_a
35
46
  device_b: str
36
47
  port_b: str # canonical long-form logical interface on device_b
37
- source: str # "cdp" | "lldp" | "mac-arp"
48
+ source: str # "cdp" | "lldp" | "mac-arp" | "upload" | "synth"
38
49
  member_count: int = 1
39
50
 
40
51
 
@@ -108,7 +108,105 @@ from confgraph.parsers.panos_parser import PANOSParser
108
108
  # Persisted baseline snapshots are invalidated by the pbv move itself (it is a
109
109
  # digest-key component), which subsumes the Tier-A digest shift these fields
110
110
  # cause.
111
- PARSER_BEHAVIOR_VERSION: int = 10
111
+ # 11 (CCR-0222): ``InterfaceConfig.ospf_passive`` becomes tri-state
112
+ # (``bool | None``, default None) and the shared interface walk extracts BOTH
113
+ # polarities of the per-interface ``[no] ip ospf passive-interface`` override
114
+ # (NX-OS/EOS inherit it). Observable parse output moves three ways: every
115
+ # interface that says nothing now reads None rather than False; an interface
116
+ # exempted from a process-level ``passive-interface default`` reads an explicit
117
+ # False and gains a native SET op for it (False != the new default); and the
118
+ # ``no`` line it was emitting as an UnrecognizedBlock disappears, claimed by
119
+ # that op's line provenance (CCR-0203 ledger). No ParsedConfig JSON-schema
120
+ # change beyond the field's own type, so pbv is what invalidates the platform
121
+ # parse cache and entrp's digest keys.
122
+ # 12 (CCR-0227): OSPF area IDs are CANONICALIZED at parse to the decimal
123
+ # spelling, by one shared ``canonical_area_id`` applied via model validators on
124
+ # all four storage points (OSPFArea.area_id, OSPFInterfaceConfig.area_id,
125
+ # InterfaceConfig.ospf_area, the area half of OSPFConfig.network_statements).
126
+ # `0.0.0.0` and `0` are the same area on hardware; stored raw they were two
127
+ # keys, so every mixed-vendor estate (NX-OS/JunOS dotted, IOS/IOS-XR decimal)
128
+ # modelled one real area as two — no adjacency across the spellings and wrong
129
+ # ABR/inter-area computation. Observable parse output moves for every config
130
+ # that spells an area as a dotted quad, with NO ParsedConfig JSON-schema
131
+ # change — the sharp case this constant exists for. Decimal is canonical
132
+ # precisely so the existing tombstone/golden keys (`ospf:1:area:0:...`) do not
133
+ # move.
134
+ # 13 (CCR-0227 completion): the two OPS-ONLY area-scoped removals —
135
+ # ``no network A W area <aid>`` (and the EOS ``no network A/M area <aid>``
136
+ # twin) and ``no area <aid> range A M`` — build their LIST_REMOVE key
137
+ # directly in the parser rather than through the tombstone registry, and were
138
+ # left spelling the area as the proposal wrote it while 12 made storage
139
+ # canonical. A dotted withdrawal therefore addressed an area that no longer
140
+ # existed under that spelling and removed NOTHING, silently — including the
141
+ # dotted-baseline/dotted-proposal pair that worked before 12. The removal key
142
+ # and the refresh-suppression map on both sides of each walk are now
143
+ # canonical. Observable parse output moves (those op paths) with no schema
144
+ # change. Bumped rather than riding 12 — unreleased though 12 is, the rule in
145
+ # this module's header is per-commit and not optional for a qualifying
146
+ # change, and deciding otherwise would mean re-deriving "was the predecessor
147
+ # released?" at every review.
148
+ # 14 (CCR-0217): the JunOS brace branch of ``unsupported_lines`` is fail-closed
149
+ # on the parse OUTCOME — a document routed to the brace tokenizer that yields
150
+ # an EMPTY canonical tree while carrying at least one non-blank
151
+ # comment-stripped line discloses those lines as UnrecognizedBlocks instead of
152
+ # returning silence. The hole was the document with no JunOS idiom in it at all
153
+ # (Cisco ``no`` commands, an IOS ``interface`` block): zero set-family lines
154
+ # routes it brace-ward, every statement is discarded at EOF for want of a ``;``
155
+ # or ``{``, and the result read "no recognized configuration change" for text
156
+ # that plainly requested one — the last silent arm of CCR-0210's fail-closed
157
+ # story, modulo the CCR-0236 mixed-document residual. Observable parse output
158
+ # moves for every such document, with NO ParsedConfig JSON-schema change — the
159
+ # sharp case this constant exists for. Comment-only and whitespace-only
160
+ # documents carry no content and stay silent; ``inactive:``-only documents
161
+ # parse non-empty and keep their existing tag disclosure.
162
+ # 15 (CCR-0214): JunOS ``delete`` / ``deactivate`` statements on MODELLED
163
+ # paths compile to native change ops instead of being disclosed. JunOS emitted
164
+ # no deletion artifact of any kind before this — no tombstones (the grammar is
165
+ # Cisco syntax throughout) and no ops — so a withdrawal, the most outage-prone
166
+ # change class on the OS, could only ever be reported as content the engine
167
+ # could not read. The modelled set is one declarative table
168
+ # (``parsers/junos_deletions.py``): a BGP neighbour, a whole BGP group, a
169
+ # static route, an interface unit. Every path outside it stays on the CCR-0210
170
+ # disclosure channel, so nothing became silent and nothing is half-applied;
171
+ # ``deactivate`` compiles to the same op as ``delete`` because an inactive
172
+ # subtree makes no contribution to the running configuration, which is the
173
+ # only configuration the model holds. Observable parse output moves for any
174
+ # JunOS document carrying one of those statements —
175
+ # ``native_change_ops`` gains entries and ``unrecognized_blocks`` loses the
176
+ # matching lines — with NO ParsedConfig JSON-schema change. No BASELINE moves:
177
+ # a saved configuration states no deletion verb (the brace idiom has none),
178
+ # and the ``inactive:`` statement tag is untouched and still disclosed.
179
+ # 16 (CCR-0214, validation round 1 finding R1): a JunOS removal that the SAME
180
+ # document later re-states no longer compiles — it falls back to the CCR-0210
181
+ # disclosure channel. JunOS applies candidate statements in order, so
182
+ # ``delete protocols bgp group RR`` followed by ``set protocols bgp group RR …``
183
+ # rebuilds the group rather than removing it, and the same idiom moves a static
184
+ # route to a new next-hop. Those are the two commonest JunOS rewrite idioms, and
185
+ # compiling the deletion alone reported a confident teardown of something the
186
+ # device would still have — measured in validation as a CRITICAL full-teardown
187
+ # verdict on a group rebuild. The guard is order-aware (``set`` strictly AFTER
188
+ # the deletion line, at an equal or deeper path): the reverse order really is a
189
+ # removal on a device, so it still compiles. Observable parse output moves for
190
+ # any JunOS document mixing a removal with a later restatement of the same
191
+ # path — ``native_change_ops`` loses that op and ``unrecognized_blocks`` gains
192
+ # the line back — with NO ParsedConfig JSON-schema change, and no baseline
193
+ # motion (a saved configuration states no deletion verb).
194
+ # 17 (CCR-0239 + CCR-0240, bump landed late — caught at the v0.3.9 release
195
+ # audit, 2026-11-06): both landings changed parse output without moving this
196
+ # constant, so PBV-16 parse-cache blobs minted before them could be served as
197
+ # hits carrying the pre-fix models. CCR-0239: NX-OS named OSPF instance tags
198
+ # (``ip router ospf CORE area …``) enroll their interfaces — previously the
199
+ # named-instance spelling bound nothing, so those interfaces were
200
+ # OSPF-invisible. CCR-0240: IOS-XR OSPF passive resolves at all four scopes
201
+ # (process default, area, interface ``passive enable`` AND ``passive
202
+ # disable``) — previously the process-scope default was hardwired False and
203
+ # ``passive disable`` was not in the vocabulary, so suppressed interfaces
204
+ # formed phantom adjacencies while exempted uplinks read as suppressed.
205
+ # Observable parse output moves for any NX-OS config using named instance
206
+ # tags and any IOS-XR config using process/area-scope passive — BASELINES
207
+ # included (both spellings exist in saved configurations). No ParsedConfig
208
+ # JSON-schema change.
209
+ PARSER_BEHAVIOR_VERSION: int = 17
112
210
 
113
211
  __all__ = [
114
212
  "BaseParser",
@@ -956,16 +956,23 @@ class BaseParser(ABC):
956
956
  # (explicitly named in passive_interfaces or non_passive_interfaces,
957
957
  # or whose ospf_process_id matches). Never infer passive for L2 ports
958
958
  # or interfaces not in any OSPF process — correctness over coverage.
959
+ #
960
+ # CCR-0222: every stamp here is guarded on ``ospf_passive is None``.
961
+ # The field is tri-state, and a non-None value means the INTERFACE
962
+ # block itself spoke (`[no] ip ospf passive-interface`) — which on
963
+ # hardware overrides anything the process says. Without the guard
964
+ # this walk clobbers the explicit False and the override vanishes
965
+ # again, one layer down.
959
966
  if pc.ospf_instances and pc.interfaces:
960
967
  intf_by_name = {i.name: i for i in pc.interfaces}
961
968
  for ospf in pc.ospf_instances:
962
969
  for name in ospf.passive_interfaces:
963
970
  intf = intf_by_name.get(name)
964
- if intf:
971
+ if intf and intf.ospf_passive is None:
965
972
  intf.ospf_passive = True
966
973
  for name in ospf.non_passive_interfaces:
967
974
  intf = intf_by_name.get(name)
968
- if intf:
975
+ if intf and intf.ospf_passive is None:
969
976
  intf.ospf_passive = False
970
977
  # For default-passive, only mark interfaces that belong to
971
978
  # this process (ospf_process_id matches or listed in an area)
@@ -975,6 +982,8 @@ class BaseParser(ABC):
975
982
  area_intfs.update(area.interfaces)
976
983
  non_passive_set = set(ospf.non_passive_interfaces)
977
984
  for intf in pc.interfaces:
985
+ if intf.ospf_passive is not None:
986
+ continue # the interface block already decided
978
987
  if intf.name in non_passive_set:
979
988
  continue # already handled above
980
989
  if intf.name in area_intfs or intf.ospf_process_id == ospf.process_id:
@@ -1036,6 +1045,27 @@ class BaseParser(ABC):
1036
1045
  ("bfd_multiplier", "bfd_multiplier"),
1037
1046
  )
1038
1047
 
1048
+ @staticmethod
1049
+ def _is_model_default(obj, field_name: str, value) -> bool:
1050
+ """True when *value* is still *field_name*'s model-declared default.
1051
+
1052
+ The single "has this field been set?" test for the back-fill walks
1053
+ (CCR-0222). Identity first so the singletons (``None``, ``False``)
1054
+ match exactly; equality only within the same type, so an explicitly
1055
+ parsed ``0`` is never read as a default ``False``.
1056
+ """
1057
+ field_info = type(obj).model_fields.get(field_name)
1058
+ if field_info is None:
1059
+ return value is None
1060
+ default = (
1061
+ field_info.default_factory()
1062
+ if field_info.default_factory is not None
1063
+ else field_info.default
1064
+ )
1065
+ if value is default:
1066
+ return True
1067
+ return type(value) is type(default) and value == default
1068
+
1039
1069
  def _backfill_ospf_interface_settings(self, pc: ParsedConfig) -> None:
1040
1070
  """Carry ``area > interface`` OSPF settings out to ``InterfaceConfig``.
1041
1071
 
@@ -1077,14 +1107,25 @@ class BaseParser(ABC):
1077
1107
  continue
1078
1108
  # Never clobber a value the interface block itself set.
1079
1109
  #
1080
- # Compared with `is`, not `in (None, False)`: in Python
1081
- # `0 == False`, so a membership test would read an
1082
- # explicitly parsed ZERO as "unset" and overwrite it.
1083
- # `ip ospf priority 0` is a real, meaningful value — it
1084
- # says "never become DR" — and silently promoting it to a
1085
- # back-filled 33 would invert the operator's intent.
1110
+ # "Unset" means "still holds the field's MODEL-DECLARED
1111
+ # default", read off the model rather than hard-coded as
1112
+ # None-or-False (CCR-0222): `ospf_passive` is tri-state
1113
+ # with default None, so a False there is an EXPLICIT
1114
+ # `no ip ospf passive-interface` and must survive, while
1115
+ # a False in a default-False field (`ospf_mtu_ignore`)
1116
+ # is still unset. Behaviour-identical for every other
1117
+ # row — no default-None field in the table can hold
1118
+ # False, and no default-False field can hold None.
1119
+ #
1120
+ # Compared with `is` before `==`, not `in (None, False)`:
1121
+ # in Python `0 == False`, so a plain equality test would
1122
+ # read an explicitly parsed ZERO as "unset" and overwrite
1123
+ # it. `ip ospf priority 0` is a real, meaningful value —
1124
+ # it says "never become DR" — and silently promoting it
1125
+ # to a back-filled 33 would invert the operator's intent.
1126
+ # The type check keeps `0` distinct from `False`.
1086
1127
  current = getattr(intf, dst_field, None)
1087
- if current is not None and current is not False:
1128
+ if not self._is_model_default(intf, dst_field, current):
1088
1129
  continue
1089
1130
  setattr(intf, dst_field, value)
1090
1131
 
@@ -14,6 +14,7 @@ from confgraph.parsers.base import (
14
14
  from confgraph.models.base import OSType, UnrecognizedBlock
15
15
  from confgraph.models.line import LineType
16
16
  from confgraph.models.ospf import OSPFConfig
17
+ from confgraph.utils.ospf import canonical_area_id
17
18
  from confgraph.models.bgp import BGPConfig, BGPNeighborAF
18
19
  from confgraph.models.prefix_list import PrefixListConfig, PrefixListEntry
19
20
  from confgraph.models.static_route import StaticRoute
@@ -306,7 +307,10 @@ class EOSParser(IOSParser):
306
307
  ospf_area_children[0].text,
307
308
  )
308
309
  if am:
309
- intf_cfg.ospf_area = am.group(1)
310
+ # CCR-0227: assignment to an already-built model skips
311
+ # pydantic validation — canonicalize as the field
312
+ # validator would.
313
+ intf_cfg.ospf_area = canonical_area_id(am.group(1))
310
314
 
311
315
  # VARP: "ip virtual-router address <ip>". A device emits ONE LINE
312
316
  # PER ADDRESS, so accumulate rather than overwrite
@@ -397,7 +401,10 @@ class EOSParser(IOSParser):
397
401
  m = re.match(r"^\s+network\s+(\S+)\s+area\s+(\S+)\s*$", nc.text)
398
402
  if not m:
399
403
  continue
400
- prefix_str, area_id = m.group(1), m.group(2)
404
+ # CCR-0227: this appends into a LIVE model's list, which runs
405
+ # no field validator — canonicalize here as the validator on
406
+ # OSPFConfig.network_statements would.
407
+ prefix_str, area_id = m.group(1), canonical_area_id(m.group(2))
401
408
  try:
402
409
  net = IPv4Network(prefix_str, strict=False)
403
410
  except ValueError:
@@ -10,6 +10,7 @@ from confgraph.utils.interface import normalize_interface_name
10
10
  # confgraph imports, so this cannot cycle (the other change_ir names in this file
11
11
  # stay lazily imported at their call sites, unchanged).
12
12
  from confgraph.change_ir import _native_deletion_path
13
+ from confgraph.utils.ospf import canonical_area_id
13
14
  from confgraph.models.base import OSType
14
15
  from confgraph.models.vrf import VRFConfig
15
16
  from confgraph.models.interface import (
@@ -1266,7 +1267,7 @@ class IOSParser(BaseParser):
1266
1267
  ospf_hello_interval = None
1267
1268
  ospf_dead_interval = None
1268
1269
  ospf_network_type = None
1269
- ospf_passive = False
1270
+ ospf_passive = None
1270
1271
  ospf_authentication = None
1271
1272
  ospf_authentication_key = None
1272
1273
  ospf_message_digest_keys = {}
@@ -1380,6 +1381,28 @@ class IOSParser(BaseParser):
1380
1381
  if intf_obj.find_child_objects(r"^\s+ip\s+ospf\s+mtu-ignore"):
1381
1382
  ospf_mtu_ignore = True
1382
1383
 
1384
+ # ip ospf passive-interface / no ip ospf passive-interface
1385
+ #
1386
+ # CCR-0222: BOTH polarities are value-bearing state, so the
1387
+ # negation parses to an explicit False — it is the per-interface
1388
+ # override of a process-level `passive-interface default`, the
1389
+ # NX-OS hardening pattern, and on hardware the interface-level
1390
+ # word is final. Hence the tri-state field: None means the
1391
+ # interface said nothing and the process-level config decides.
1392
+ # `$`-anchored both ways (disjoint from any operand-bearing
1393
+ # spelling) with last-line-wins, exactly like every sibling
1394
+ # `ip ospf *` scalar above. This is the SHARED walk — NX-OS and
1395
+ # EOS inherit it through super().parse_interfaces(); the
1396
+ # process-level `passive-interface` spelling is parsed by the
1397
+ # OSPF-process walks and is untouched by this.
1398
+ ospf_passive_children = intf_obj.find_child_objects(
1399
+ r"^\s+(?:no\s+)?ip\s+ospf\s+passive-interface\s*$"
1400
+ )
1401
+ if ospf_passive_children:
1402
+ ospf_passive = not re.match(
1403
+ r"^\s*no\s+", ospf_passive_children[-1].text
1404
+ )
1405
+
1383
1406
  # Tunnel attributes
1384
1407
  tunnel_source = None
1385
1408
  tunnel_destination = None
@@ -1893,6 +1916,11 @@ class IOSParser(BaseParser):
1893
1916
  "ospf_dead_interval": r"^\s+ip\s+ospf\s+dead-interval\s+",
1894
1917
  "ospf_network_type": r"^\s+ip\s+ospf\s+network\s+",
1895
1918
  "ospf_mtu_ignore": r"^\s+ip\s+ospf\s+mtu-ignore\s*$",
1919
+ # CCR-0222: both polarities — the SET op for an explicit False must
1920
+ # carry the `no ip ospf passive-interface` line, which is also what
1921
+ # claims that negation for the CCR-0203 ledger (consumed, not
1922
+ # disclosed).
1923
+ "ospf_passive": r"^\s+(?:no\s+)?ip\s+ospf\s+passive-interface\s*$",
1896
1924
  "cdp_enabled": r"^\s+(?:no\s+)?cdp\s+enable\s*$",
1897
1925
  "lldp_transmit": r"^\s+(?:no\s+)?lldp\s+transmit\s*$",
1898
1926
  "lldp_receive": r"^\s+(?:no\s+)?lldp\s+receive\s*$",
@@ -4952,6 +4980,18 @@ class IOSParser(BaseParser):
4952
4980
  """
4953
4981
  from confgraph.change_ir import ChangeOp, Verb
4954
4982
 
4983
+ # CCR-0227: areas are STORED canonical, so both sides of this walk must
4984
+ # speak the canonical spelling — the removal key, which the merger
4985
+ # compares against stored ``network_statements``, and the caller's
4986
+ # positive map, whose keys come from its own walk (IOS builds them raw
4987
+ # from the regex, EOS canonical). Normalizing both HERE, where
4988
+ # everything the two spellings must never disagree on already lives,
4989
+ # keeps suppression and removal from splitting on the spelling.
4990
+ positive_net_last_line = {
4991
+ (net, canonical_area_id(area)): line
4992
+ for (net, area), line in positive_net_last_line.items()
4993
+ }
4994
+
4955
4995
  for nnc in ospf_obj.find_child_objects(r"^\s+no\s+network\s+"):
4956
4996
  nnm = re.match(child_pattern, nnc.text)
4957
4997
  if not nnm:
@@ -4960,7 +5000,7 @@ class IOSParser(BaseParser):
4960
5000
  if extracted is None:
4961
5001
  continue
4962
5002
  rem_net, rem_area = extracted
4963
- rem_key = (str(rem_net), rem_area)
5003
+ rem_key = (str(rem_net), canonical_area_id(rem_area))
4964
5004
  if positive_net_last_line.get(rem_key, -1) > nnc.linenum:
4965
5005
  # CCR-0203: suppressed-by-refresh is CONSUMED (understood and
4966
5006
  # deliberately dropped), not blind — claim it or the fallback
@@ -5186,16 +5226,28 @@ class IOSParser(BaseParser):
5186
5226
  # (area_id, prefix) reappears as a positive range line LATER in
5187
5227
  # the block (refresh → re-add wins → device truth and legacy
5188
5228
  # parity).
5229
+ #
5230
+ # ONE key builder for both walks (CCR-0227): the area half is
5231
+ # canonicalized, because the merger looks the area up by its
5232
+ # STORED id, and the suppression map and the removal must agree
5233
+ # on the spelling or a refresh stops suppressing its own removal.
5234
+ # Returns None on an unparseable mask — dropped on BOTH walks, as
5235
+ # before.
5236
+ def _range_key(m) -> "tuple[str, str] | None":
5237
+ try:
5238
+ prefix = IPv4Network(f"{m.group(2)}/{m.group(3)}")
5239
+ except ValueError:
5240
+ return None
5241
+ return canonical_area_id(m.group(1)), str(prefix)
5242
+
5189
5243
  positive_range_last_line: dict[tuple[str, str], int] = {}
5190
5244
  for rc in ospf_obj.find_child_objects(r"^\s+area\s+\S+\s+range\s+"):
5191
5245
  rm = re.match(r"^\s+area\s+(\S+)\s+range\s+(\S+)\s+(\S+)", rc.text)
5192
5246
  if not rm:
5193
5247
  continue
5194
- try:
5195
- rng_pfx = IPv4Network(f"{rm.group(2)}/{rm.group(3)}")
5196
- except ValueError:
5248
+ rkey = _range_key(rm)
5249
+ if rkey is None:
5197
5250
  continue
5198
- rkey = (rm.group(1), str(rng_pfx))
5199
5251
  positive_range_last_line[rkey] = max(
5200
5252
  positive_range_last_line.get(rkey, -1), rc.linenum
5201
5253
  )
@@ -5205,11 +5257,9 @@ class IOSParser(BaseParser):
5205
5257
  )
5206
5258
  if not nrm:
5207
5259
  continue
5208
- try:
5209
- rng_pfx = IPv4Network(f"{nrm.group(2)}/{nrm.group(3)}")
5210
- except ValueError:
5260
+ rkey = _range_key(nrm)
5261
+ if rkey is None:
5211
5262
  continue
5212
- rkey = (nrm.group(1), str(rng_pfx))
5213
5263
  if positive_range_last_line.get(rkey, -1) > nrc.linenum:
5214
5264
  self._claim_negation(nrc) # CCR-0203: suppressed == consumed
5215
5265
  continue # re-added later in the block — removal suppressed
@@ -5383,6 +5433,19 @@ class IOSParser(BaseParser):
5383
5433
  def _queue_ospf_negation(verb, path_tail, child):
5384
5434
  # CCR-0110 Phase E4/E5: queue the native op only; op-primary
5385
5435
  # parsers no longer persist the deprecated no_commands string.
5436
+ #
5437
+ # CCR-0227: the area segment of an area-scoped path comes from
5438
+ # the proposal TEXT, while the positive member ops key off the
5439
+ # stored (canonical) ``area.area_id`` — so canonicalize it
5440
+ # here, at the one point every OSPF negation op is built. A
5441
+ # `no area 0.0.0.1 stub` must address the same area a
5442
+ # `no area 1 stub` does, because the device does.
5443
+ if len(path_tail) > 1 and path_tail[0] == "area":
5444
+ path_tail = (
5445
+ "area",
5446
+ canonical_area_id(path_tail[1]),
5447
+ *path_tail[2:],
5448
+ )
5386
5449
  self._pending_native_ospf_ops.append(
5387
5450
  ChangeOp(
5388
5451
  verb=verb,