Skip to content

fix(const): refuse a multi-bit proto in AppType._dispatch rather than resolving it LSB-first (#759) - #783

Merged
JarryShaw merged 1 commit into
mainfrom
fix/759-apptype-dispatch-multibit-proto
Sep 25, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/759-apptype-dispatch-multibit-proto

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Please follow the guide below

What is the purpose of your pull request?

Tick the commit type your subject line carries.

  • fix — corrects a defect
  • feat — adds a feature
  • perf — changes performance, not behaviour
  • refactor — changes neither behaviour nor performance
  • test — tests only
  • docs — documentation only
  • ci — workflows or build tooling
  • chore — anything else

Description of your pull request and other information

Fixes #759. _dispatch resolved a composite proto by its lowest set bit, so any composite containing tcp dispatched into the TCP registry. It now raises ProtocolError from pcapkit.utilities.exceptions — a ValueError subclass, so get/get_all's documented Raises: still holds, and loud rather than quiet because nothing catches it (unlike _missing_'s guard, which ConstEnumBuiltinParityTests.test_the_exception_is_not_an_in_library_one requires to stay a non-BaseError).

Blast radius, re-derived on fe80b8525. Sweeping all 10,625 multi-transport members through get(m.port, proto=m.proto): 0 exceptions, 0 mints, 46 answers naming a service other than the member's own, at 20 distinct ports. Those 46 are three different things, and only the last is a wrong answer: 44 differ only in that the member is not its port's canonical, which a single-bit lookup does too and which get documents; 23 — the UDP-declared half — came back as a member of the TCP registry, so type() and .proto were wrong whatever the service string said; and 2 named a service UDP does not answer for the port at all. Those two are the whole blast radius: port 888, accessbuilder → TCP's cddbp, and port 999, puprouter → TCP's garcon against UDP's applix. #759's body found the first and generalised from it. After the fix: 10,625 refusals, 0 answers.

Population, since four figures here are easy to confuse. 12,391 declared members (TCP 6147, UDP 6143, SCTP 91, DCCP 10); of those 10,625 carry a multi-bit .proto and 1,766 a single-bit one; they sit on 12,341 distinct (registry, port) pairs (TCP 6121, UDP 6119, SCTP 91, DCCP 10), the other 50 sharing a port with another service as IANA's three on TCP/80 do. The invariance digest covers those 12,341 rows — one per (registry, port), as registry|port|resolved service|int(resolved proto), each looked up by passing the registry's own single transport bit whatever the member's .proto holds: 3a457683c7b00609b06526aa02e3c361a910fa4a0e22d25ca16b4ab01acc053a, identical either side of the fix and identical again with proto passed as a name. So it is the argument that is single-bit, not the member set — an earlier revision of this description labelled it "12,341 single-bit ports", which is a set that does not exist. Restricted to the 1,766 single-bit-.proto members it is 1,763 rows (three share a port), digest 308715ae6642a91be547d73e1c07f84bed7f27b7158951e831201c936807814b, also identical either side. All four counts are asserted in the test.

Regeneration. The change is in the crawler's BASE template; the const file is regenerated from it against IANA's live CSV (sha256 34328ed0…, re-fetched and unchanged), which reproduces all five committed files byte-identically before the change, so the diff is only the guard and its comment. Members unchanged. Siblings byte-identical, md5 before == after: be2b6a48 tcp.py, 6e169b8a udp.py, 39a7749d sctp.py, 556f8403 dccp.py.

Tests. tests/const/test_const_apptype_split_unit.py 15 → 19 tests, 808 → 892 subtests, agreeing under python -m unittest (19 OK). The two refusal tests fail against the pre-fix const file under both runners; the two invariance tests (single-bit resolution, and an AST pin on all ten library call sites passing one transport protocol) pass either side by design. Coverage of the changed file 51.752% → 51.843%: +4 statements, +2 branch arcs, 0 new misses. pylint reports the same 14 findings as main for these two files, mypy and isort clean. make test not run in full — targeted selections only (tests/const, tests/vendor, tests/project = 251 tests / 2382 subtests, plus the transport/schema/corekit/foundation callers = 111 / 188).

Label-worthy: this regenerates a const table, and it is a public-contract change — AppType.get(port, proto=<composite>) previously returned a member and now raises. No in-library caller passes a composite; register_apptype reads a member's composite proto but tests it with in and never reaches the lookup.

@JarryShaw JarryShaw added fix Pull requests that fix a defect (fix: subject prefix) const Regenerated IANA or vendor constant tables; members keep their numeric values breaking Breaks public-facing behaviour or API (apply alongside the type label) review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review at 8261139fb (sonnet, a different model from the author): the code fix is verified correct and safe, but three published claims are wrong, so this goes to review: needs-changes. None of them touches the fix itself.

1. The "12,341 declared single-bit ports" figure is wrong, and I re-derived it myself rather than take the reviewer's word. Measured on fe80b8525 with tree provenance printed:

total member rows : 12391    (TCP 6147 UDP 6143 SCTP 91 DCCP 10)
  multi-bit proto : 10625
  single-bit proto: 1766
distinct (reg, port, svc) single-bit: 1766

So the single-bit set is 1,766, not 12,341 — and 12,341 is 50 short of the total 12,391, not of the single-bit subset, which suggests the figure was a total with something excluded and then mislabelled. That makes digest 3a457683c7b00609b06526aa02e3c361a910fa4a0e22d25ca16b4ab01acc053a unverifiable as described: it may well be a sound invariance check, but not over the set the body names. Fix the count and say plainly what the digest covers.

2. tests/const/test_const_apptype_split_unit.py:406 cites a class that does not exist. ConstBuiltinParityTests is nowhere in the tree; the real class is ConstEnumBuiltinParityTests at tests/const/test_const_enum_builtin_parity.py:186. Comment-only, but a wrong cross-reference in the one docstring that explains why ProtocolError is safe here and not in _missing_.

3. The changelog checkbox is unticked with no annotation. House form is N/A — changelog centralised in #657.

One overstatement worth correcting while you are in there. The body says only the UDP half of the 46 is #759's cross-registry dispatch. Measured: of the 23 UDP-origin mismatches, only 2 (ports 888 and 999) actually return a different final service than UDP's own single-bit lookup would; the other 21 land on TCP mechanically but coincide with UDP's canonical string, exactly like the TCP-origin cases. The 23/23 split is right, the characterisation of what each half means is not.

Verified good, independently derived. The ten call sites are confirmed by direct read before the reviewer consulted your AST test — transport.py:187 fed by six literal _make_port(…, Enum_TransportProtocol.<x>) calls, plus three per schema file (_dispatch + two get), each bound to a literal member; your AST test independently pins the same 10 lookups + 6 feeders. register_apptype (protocols.py:831-846) reads code.proto, tests with in, fans into both registries, and never reaches _dispatch. ProtocolError(BaseError, ValueError) propagates untouched: all three proto-bearing calls sit outside the local try/except ValueError, which wraps only owner._missing_ and re-raises any BaseError regardless. The sys.tracebacklimit mechanism is real at exceptions.py:191-200 and your save/restore matches QuietExceptionTests.setUp/tearDown's idiom exactly. The 46 / 0 exceptions / 20 ports / 23-23 split all reproduced. Both failing tests confirmed failing against the pre-fix const file — ProtocolError not raised, and 10,625 unexpected answers — and the two invariance pins judged to earn their place rather than pad. The pre-existing PORT_9899_sctp leak at :213 is confirmed present identically on fe80b8525, and your svc == 'unknown' filter is sound rather than papering over it: it excludes any mint by symptom, order-independently. breaking agreed, with no other undisclosed contract change found. CI 27 success / 3 in progress / 0 failures.

Not verified by the reviewer: the live-IANA byte-identical regeneration, and the coverage/pylint deltas — out of its budget, not disputed.

@JarryShaw JarryShaw added review: needs-changes Cross-review at the current head says changes are required; see the verdict comment and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
@JarryShaw
JarryShaw force-pushed the fix/759-apptype-dispatch-multibit-proto branch from 8261139 to 515a826 Compare September 25, 2026 13:26
@JarryShaw JarryShaw added review: pending No verdict for the current head - never reviewed, or the head moved since the last one and removed review: needs-changes Cross-review at the current head says changes are required; see the verdict comment labels Sep 25, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

All three corrected at 515a82633, and the reconciliation is right — 12,341 was a real number that I said was wrong. Verified independently from the committed const files:

tcp   6121 distinct ports of 6147 members
udp   6119 distinct ports of 6143
sctp    91 of 91
dccp    10 of 10
        ----
      12341 distinct (registry, port)   vs 12391 members   difference exactly 50

So 12,341 is the distinct (registry, port) count, and 50 members share a port with another. My correction was about the label — "declared single-bit ports" — not the value: the single-bit-.proto population is a different set at 1,766, which is what I measured. Both figures are sound; only the caption was wrong. Good call adding the second digest (308715ae…, 1,763 rows over the 1,766 members, three sharing a port) so the two sets are distinguishable, and better still asserting all four counts in the test instead of narrating them — the relationship is now enforced in CI rather than trusted.

The blast radius is now stated honestly, and pinned. Of the 46: 44 are get's documented behaviour (the member is simply not its port's canonical); 23 returned a member of the wrong registry; and 2 returned a service UDP's own lookup does not give — UDP/888 accessbuilder → TCP's cddbp, and UDP/999 puprouter → TCP's garcon against UDP's applix. Re-derived two independent ways and agreeing, with assertEqual(divergent, [('UDP', 888, …), ('UDP', 999, …)]) holding it. "2 wrong answers in 10,625 lookups, 23 wrong objects" is the sentence a reader should leave with, and #759's body generalising from 888 alone is what produced the looser framing.

ConstEnumBuiltinParityTests.test_the_exception_is_not_an_in_library_one is the right citation and naming the method is better than the class. Changelog annotated.

Two things done right that I did not ask for. You re-fetched IANA before regenerating, confirmed the CSV byte-identical to your baseline (34328ed0…, 1,157,052 bytes), and kept the siblings byte-identical — so the NOTE comment landing in the crawler template did not smuggle in drift. And you caught that main moved to 0419c1c97 under you, recognised that conflict-free is not safe when #782 touches the file that renders your template and the guard that audits your test file, and ran the merge: 124 passed / 1892 subtests plus 100 / 524 on the tier guard, no interaction.

Label back to review: pending for the new head; re-review dispatching shortly — holding it one tick on host load rather than firing into a box at 3.6.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Round-two cross-review at 515a82633 (sonnet, a different model from the author): GOOD TO GO.

All three required changes confirmed applied, and the reviewer independently recomputed the four counts rather than accepting either my figures or the author's: 12,391 members / 10,625 multi-bit / 1,766 single-bit / 12,341 distinct (registry, port) — 6,121 TCP + 6,119 UDP + 91 + 10 — difference exactly 50. That is a third derivation agreeing with mine and the test's.

The const-file delta really is comment-only. Diffing 8261139fb..515a82633 on both apptype files, every changed line is a # comment; filter comments out and nothing remains. So regenerating from the template to carry the NOTE could not have moved a member, and the unchanged 51.843% coverage follows. Sibling md5s identical across both rounds.

The divergent pin has teeth. divergent is built from live TCP.get(port).svc != cls.get(port).svc comparisons over all 46 (:598, :684) and asserted at :625 — not hardcoded — so a third diverging port would genuinely fail. And I confirmed the four counts are asserted, not narrated: :677 12,391, :680 10,625, :681 1,766, :697 12,341, plus :578 sweeping 10,625.

One honest limit worth recording. The reviewer could not reproduce the exact digest hex 3a457683… or 308715ae… — it got the right row counts (12,341 and 1,763) but a different hash, not knowing your row serialisation. Following that up: grep -c "hashlib\|sha256" on the test file is 0, so neither digest is computed or asserted anywhere — they are prose in a docstring. That is fine, because the count and divergent assertions are the real regression protection, but a reader should not mistake those hex strings for something CI enforces. If you want them enforced, that is a separate small change; I am not asking for it here.

The merge check was redone rather than trusted. Merged 515a82633 into current 0419c1c97 in a scratch worktree — clean, same three-file / 554-line diff — and ran with pcapkit.__file__ printed to prove the tree: tests/vendor + tests/const 124 passed / 1892 subtests, tests/test_tier_guard.py 100 passed / 524 subtests. Both match your figures exactly. That mattered because #782 touches the file that renders your template and the guard that audits your test file.

Settled in round one and not re-run: the ten call sites, register_apptype never reaching _dispatch, ProtocolError propagation, the tracebacklimit save/restore, the two pre-fix failures, the PORT_9899_sctp leak, and breaking.

Six CI legs still outstanding (21 success, 3 expected skips, 0 failures), so this is a review verdict rather than a merge signal. Host load ran 3.08 → 4.84 during the review from other agents, so it was deliberately kept to the requested checks.

@JarryShaw JarryShaw added review: good-to-go Cross-review at the current head says ready; CI state is separate and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
Comment thread pcapkit/const/reg/apptype/apptype.py Outdated
# ignored, so nothing there is ambiguous to begin with.
namespaces = show_flag_values(proto)
if len(namespaces) > 1:
raise ProtocolError('%r names %d transport protocols, and so %d registries of %s; '

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not f-string?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this region is a str.format() template in the vendor generator, not hand-written source. pcapkit/vendor/reg/apptype/apptype.py emits the const file through .format() — you can see the delimiters in the same template at :243, __registries__: 'dict[TransportProtocol, Type[{NAME}]]' = {{}}, where the empty dict has to be written {{}} to survive expansion.

So emitting an f-string means writing f'{{proto!r}} names {{len(namespaces)}} …' in the template: every brace doubled, and the generator line stops reading like the code it produces.

Measured, so the local-versus-global convention is explicit:

scope raise …Error(f'… raise …Error('…' % …)
pcapkit/ whole tree 605 2
const/reg/apptype/apptype.py on origin/main 0 6

So the repo convention really is f-strings, and this generated file is the exception — all six of its pre-existing raises are %-style (:92, :2308, :2368, :2378, :2478, :2484), and the new one matched them rather than being the only f-string in the file.

Happy to switch it if you would rather the emitted code follow the tree convention — it is one raise, so four doubled braces in the template. Say which and I will make it match.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea, id like to keep f-string convention across the library. only use % substitution when inevitable.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recorded, in your words: "yea, id like to keep f-string convention across the library. only use % substitution when inevitable."

It is not inevitable here — the doubled-brace form survives the generator's .format() intact. Verified before routing it, template in and expanded code out:

raise ProtocolError(f'{{proto!r}} names {{len(namespaces)}} transport protocols, and so '   <- in the template
                    f'{{len(namespaces)}} registries of {{cls.__name__}}; look the port up '
                    'under one transport protocol at a time')

raise ProtocolError(f'{proto!r} names {len(namespaces)} transport protocols, and so '        <- emitted
                    f'{len(namespaces)} registries of {cls.__name__}; look the port up '
                    'under one transport protocol at a time')
expanded raise parses as valid Python: True

So the new raise converts, alongside the loop flattening, in the same amend.

Scoping the other six deliberately, rather than quietly. This file has 6 pre-existing %-style raises — :92, :2308, :2368, :2378, :2478, :2484 — all of which your convention also wants converted. I am leaving them out of #783 and filing them as their own issue, because #783 is the multi-bit-proto refusal and a const diff that also sweeps style across a 12,391-member generated file is much harder to review for the thing it is actually doing.

Say the word if you would rather have all seven in one pass here and I will fold them in instead.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting myself on one detail above. I said this region is a str.format() template. It is not — BASE at pcapkit/vendor/reg/apptype/apptype.py:93 is a lambda returning an f-string:

BASE = lambda NAME, DOCS, FLAG, TABLE, MISS, MODL: f'''\

The brace-doubling conclusion is unchanged, since an f-string escapes braces the same way — and :243's = {{}} is still the evidence for it. But anyone following my earlier wording would go looking for a .format() call that is not there. The generated raise and the flattening are unaffected.

Comment thread pcapkit/const/reg/apptype/apptype.py Outdated
raise ProtocolError('%r names %d transport protocols, and so %d registries of %s; '
'look the port up under one transport protocol at a time'
% (proto, len(namespaces), len(namespaces), cls.__name__))
for namespace in namespaces:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if already raised on multiple namespaces, then why the loop on one element list?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and it is a real simplification — the loop cannot iterate more than once.

After the len(namespaces) > 1 raise directly above it, namespaces is length 0 or 1, so the for is an if wearing a for. Both surviving lengths measured:

undefined  int=0   show_flag_values=[]     len=0
tcp        int=1   show_flag_values=[1]    len=1
tcp|udp    int=3   show_flag_values=[1, 2] len=2   <- raises above

The 0-length case is live, not theoretical — it is the default argument reaching the delegating path:

AppType.get(80, proto=undefined)
  -> ValueError: <TransportProtocol.undefined: 0> names no transport protocol registry of AppType

so the fall-through to the trailing ValueError has to stay. What the loop was doing was making a one-or-zero case look like an n-case, which reads as if a composite could still be resolved here — the opposite of what the raise above it decided.

Flattening to:

if namespaces:
    subclass = cls.__registries__.get(TransportProtocol(namespaces[0]))
    if subclass is not None:
        return subclass
raise ValueError(...)

Applying that to the generator template and regenerating, then re-posting the revision. Flipping to review: needs-changes until it lands.

@JarryShaw JarryShaw added review: needs-changes Cross-review at the current head says changes are required; see the verdict comment and removed review: good-to-go Cross-review at the current head says ready; CI state is separate labels Sep 25, 2026
@JarryShaw
JarryShaw force-pushed the fix/759-apptype-dispatch-multibit-proto branch from 515a826 to 7caec43 Compare September 25, 2026 14:38
… resolving it LSB-first (#759)

`AppType._dispatch` resolved a `proto` naming several transport protocols by
taking its lowest set bit: `show_flag_values` iterates LSB-first and `tcp` is the
lowest declared bit, so every composite containing it dispatched into the TCP
registry whatever else it named. `TransportProtocol` is an `aenum.IntFlag` and a
member carries the whole set IANA assigned the service, so `tcp | udp` is an
ordinary value to read off one and an ordinary thing to pass back in.

Re-measured on `fe80b8525`, sweeping all 10,625 multi-transport members through
`get(m.port, proto=m.proto)`: 0 exceptions, 0 mints, and 46 answers naming a
service other than the member's own, at 20 distinct ports. Those 46 are not all
defects. 44 differ only in that the member is not its port's canonical, which a
single-bit lookup does too and which `get` documents; 23 -- the UDP-declared
half -- came back as a member of the *TCP* registry, so the type and `proto` were
wrong whatever the service string said; and 2 named a service UDP does not answer
for the port at all. Those two are the whole blast radius: port 888, where
`accessbuilder` resolved to TCP's `cddbp`, and port 999, where `puprouter`
resolved to TCP's `garcon` against UDP's `applix`. All of it silent, and
undetectable to a caller checking equality, since `AppType.__eq__` compares on
`port` alone. Identical counts at `83b58ebda` and `932cb48d1`, so long-standing
rather than a regression.

It now raises `ProtocolError` from `pcapkit.utilities.exceptions` -- a
`ValueError` subclass, so the documented contract of `get` and `get_all` still
holds, and a loud error rather than a quiet one because nothing catches it.
Refusing costs no caller: the ten library call sites into the lookup each name
one transport protocol, and `register_apptype`, the one place that reads a
member's composite `proto`, tests it with `in` and never reaches the lookup.

The change is in the `BASE` template of `pcapkit/vendor/reg/apptype/apptype.py`,
which generates `pcapkit/const/reg/apptype/apptype.py`. The const file's matching
edit was applied by hand rather than by a crawl, because regenerating fetches
IANA's live registry and would rewrite unrelated member rows; expanding `BASE`
and diffing the `_dispatch` region against the const file gives 78 lines each and
0 diff lines, so it is byte-identical to what regeneration emits. `tcp.py`,
`udp.py`, `sctp.py` and `dccp.py` are untouched.
@JarryShaw
JarryShaw force-pushed the fix/759-apptype-dispatch-multibit-proto branch from 7caec43 to 9aada6a Compare September 25, 2026 14:41
@JarryShaw

Copy link
Copy Markdown
Owner Author

Delta cross-review at 9aada6af5 (sonnet, a different model from the author): GOOD TO GO — with one refutation that was aimed at my own brief, not at the code.

What it re-derived independently, not accepted:

  • The flattening is exactly equivalent. It built both the old for-tail and the new if-tail as standalone functions and swept every TransportProtocol value 0–15 — all 16 combinations of the four declared bits — plus undeclared bits 16/17/31/32. Every result byte-identical. -1 raises inside show_flag_values itself, before reaching this code, identically either way.
  • The f-string emits a byte-identical message, checked for 2, 3 and 4 namespaces, and against the live exception from get(888, proto=tcp|udp).
  • The hand-edited const file matches what BASE now emits — 78 lines each, 0 diff lines, independently reproducing my own count.
  • pylint reproduced exactly: 9.90 → 9.89, 6 → 7 R0801 hunks, and it located the split at line 2413 — the one line where the template has {{proto!r}} and the output has {proto!r}. Diffing the two logs shows no other finding changed. mypy: both files clean (the 111 errors are pre-existing, in unrelated import-followed modules).

The refutation, and it was right. My brief said the branch sits on top of current origin/main. It does not — its parent is fe80b8525, four commits behind ef859f776. I confirmed that, and confirmed none of the four touch these files, so I did not rebase (that would void the verdict for nothing). Instead I merged into current main in a scratch worktree and ran the tests on the merge result — because green and conflict-free is not the same as safe:

merged tree f38403b9c  (carries both this fix and #784's escape_key)
tests.const.test_const_apptype_split_unit          Ran 19 tests  OK
tests.vendor.test_vendor_reg_apptype_generator_unit Ran  6 tests  OK
tests/project (isort gate + dependency gates)      Ran 127 tests  OK

One thing it flagged worth keeping: the # NOTE: block above _dispatch is not stale — it describes the old LSB-first behaviour in past tense as justification for the refusal, not as current behaviour. And consider-using-f-string is still earned, for the six remaining % raises I am filing separately.

Setting review: good-to-go. Unpublished and unmerged — yours to take.

@JarryShaw JarryShaw added review: good-to-go Cross-review at the current head says ready; CI state is separate and removed review: needs-changes Cross-review at the current head says changes are required; see the verdict comment labels Sep 25, 2026
@JarryShaw
JarryShaw merged commit f41e5f8 into main Sep 25, 2026
31 checks passed
@JarryShaw
JarryShaw deleted the fix/759-apptype-dispatch-multibit-proto branch September 25, 2026 16:21
@JarryShaw JarryShaw removed the review: good-to-go Cross-review at the current head says ready; CI state is separate label Sep 25, 2026
JarryShaw added a commit that referenced this pull request Sep 25, 2026
)

The generated `pcapkit.const.reg.apptype.apptype` module had 6 `raise
ValueError(...)` calls still using `%` formatting, left that way by #783 so
its own diff over a 12,391-member file stayed reviewable. Per the
maintainer's f-string convention (#783), they now match the other 605
f-string raises tree-wide:

- `TransportProtocol._missing_`: `'%r is not a valid %s' % (...)` ->
  `f'{value!r} is not a valid {cls.__name__}'`
- `AppType.__new__`: the "holds no members" guard
- `AppType._dispatch`: the non-port `key` guard and the trailing
  "names no transport protocol registry" guard
- `AppType._missing_`: both range/registry guards (same message as the
  first bullet)

Each message was verified byte-identical old vs new (same repr()/str()
semantics for %r/!r and %s/{}), and exercised through its real call path
(`AppType.get('http', proto=tcp)`, `AppType.get(80, proto=undefined)`,
`extend_enum(AppType, ...)`, out-of-range `AppType(...)`) with matching
live exception text before and after.

The edit is in the `BASE` template of `pcapkit/vendor/reg/apptype/apptype.py`
(braces doubled, as an f-string template emitting f-strings) and applied by
hand to the const file rather than by a crawl, to avoid rewriting the
IANA-derived member rows. Expanding `BASE` with dummy TABLE/MISS and diffing
against the const file shows exactly 2 non-equal regions -- the docstring's
list-table and the range-row tail of `_missing_`, both TABLE/MISS-dependent
-- and 0 elsewhere.

Left unconverted, and said why in the PR: the three %-formatted dunders
(`__new__`, `__repr__`, `__str__`) the issue also mentions -- out of the
issue's stated raise-only scope, and `__new__`'s format string sets every
member's underlying `StrEnum` value across all ~12,391 real members, a much
larger blast radius than an error path. The module-level
`consider-using-f-string` pylint disable stays: those dunders and the
`extend_enum(..., '%d' % value, ...)` calls still use `%`.

`tests/const/test_const_enum_builtin_parity.py::test_every_bespoke_template_
carries_the_guard` (GitHub issue #647) pinned all four bespoke vendor
templates' guards against one shared `%`-style literal, so converting
`reg.apptype.apptype`'s copy broke it in CI (7 legs). `BESPOKE_TEMPLATES` is
now a dict keyed to each template's own guard text -- the other three still
raise with `%` (#796 tracks sweeping them) -- rather than an `or` of both
forms, which would let a genuinely deleted guard pass. Proved by deleting the
guard from `pcapkit.vendor.tcp.flags` locally: the test failed with the
expected subTest and message, then the file was restored and the tree
verified clean.

Build/test: `tests/const/test_const_apptype_split_unit.py` (19),
`tests/vendor/test_vendor_reg_apptype_generator_unit.py` (6), and
`tests/const/test_const_enum_builtin_parity.py` (20, `requests` importable so
neither guard test skips) all pass under both pytest and plain unittest.
mypy clean before and after. pylint: 0 new findings in either touched
apptype file; R0801 unchanged at 575 tree-wide.

Closes #792
JarryShaw added a commit that referenced this pull request Sep 25, 2026
)

The generated `pcapkit.const.reg.apptype.apptype` module had 6 `raise
ValueError(...)` calls still using `%` formatting, left that way by #783 so
its own diff over a 12,391-member file stayed reviewable. Per the
maintainer's f-string convention (#783), they now match the other 605
f-string raises tree-wide:

- `TransportProtocol._missing_`: `'%r is not a valid %s' % (...)` ->
  `f'{value!r} is not a valid {cls.__name__}'`
- `AppType.__new__`: the "holds no members" guard
- `AppType._dispatch`: the non-port `key` guard and the trailing
  "names no transport protocol registry" guard
- `AppType._missing_`: both range/registry guards (same message as the
  first bullet)

Each message was verified byte-identical old vs new (same repr()/str()
semantics for %r/!r and %s/{}), and exercised through its real call path
(`AppType.get('http', proto=tcp)`, `AppType.get(80, proto=undefined)`,
`extend_enum(AppType, ...)`, out-of-range `AppType(...)`) with matching
live exception text before and after.

The edit is in the `BASE` template of `pcapkit/vendor/reg/apptype/apptype.py`
(braces doubled, as an f-string template emitting f-strings) and applied by
hand to the const file rather than by a crawl, to avoid rewriting the
IANA-derived member rows. Expanding `BASE` with dummy TABLE/MISS and diffing
against the const file shows exactly 2 non-equal regions -- the docstring's
list-table and the range-row tail of `_missing_`, both TABLE/MISS-dependent
-- and 0 elsewhere.

Left unconverted, and said why in the PR: the three %-formatted dunders
(`__new__`, `__repr__`, `__str__`) the issue also mentions -- out of the
issue's stated raise-only scope, and `__new__`'s format string sets every
member's underlying `StrEnum` value across all ~12,391 real members, a much
larger blast radius than an error path. The module-level
`consider-using-f-string` pylint disable stays: those dunders and the
`extend_enum(..., '%d' % value, ...)` calls still use `%`.

`tests/const/test_const_enum_builtin_parity.py::test_every_bespoke_template_
carries_the_guard` (GitHub issue #647) pinned all four bespoke vendor
templates' guards against one shared `%`-style literal, so converting
`reg.apptype.apptype`'s copy broke it in CI (7 legs). `BESPOKE_TEMPLATES` is
now a dict keyed to each template's own guard text -- the other three still
raise with `%` (#798 tracks sweeping them, the `%`-formatted dunders, and
dropping the disable) -- rather than an `or` of both forms, which would let a
genuinely deleted guard pass. Proved by deleting the guard from
`pcapkit.vendor.tcp.flags` locally: the test failed with the expected
subTest and message, then the file was restored and the tree verified clean.

Build/test: `tests/const/test_const_apptype_split_unit.py` (19),
`tests/vendor/test_vendor_reg_apptype_generator_unit.py` (6), and
`tests/const/test_const_enum_builtin_parity.py` (20, `requests` importable so
neither guard test skips) all pass under both pytest and plain unittest.
mypy clean before and after. pylint: 0 new findings in either touched
apptype file; R0801 unchanged at 575 tree-wide.

Closes #792
JarryShaw added a commit that referenced this pull request Sep 25, 2026
) (#797)

The generated `pcapkit.const.reg.apptype.apptype` module had 6 `raise
ValueError(...)` calls still using `%` formatting, left that way by #783 so
its own diff over a 12,391-member file stayed reviewable. Per the
maintainer's f-string convention (#783), they now match the other 605
f-string raises tree-wide:

- `TransportProtocol._missing_`: `'%r is not a valid %s' % (...)` ->
  `f'{value!r} is not a valid {cls.__name__}'`
- `AppType.__new__`: the "holds no members" guard
- `AppType._dispatch`: the non-port `key` guard and the trailing
  "names no transport protocol registry" guard
- `AppType._missing_`: both range/registry guards (same message as the
  first bullet)

Each message was verified byte-identical old vs new (same repr()/str()
semantics for %r/!r and %s/{}), and exercised through its real call path
(`AppType.get('http', proto=tcp)`, `AppType.get(80, proto=undefined)`,
`extend_enum(AppType, ...)`, out-of-range `AppType(...)`) with matching
live exception text before and after.

The edit is in the `BASE` template of `pcapkit/vendor/reg/apptype/apptype.py`
(braces doubled, as an f-string template emitting f-strings) and applied by
hand to the const file rather than by a crawl, to avoid rewriting the
IANA-derived member rows. Expanding `BASE` with dummy TABLE/MISS and diffing
against the const file shows exactly 2 non-equal regions -- the docstring's
list-table and the range-row tail of `_missing_`, both TABLE/MISS-dependent
-- and 0 elsewhere.

Left unconverted, and said why in the PR: the three %-formatted dunders
(`__new__`, `__repr__`, `__str__`) the issue also mentions -- out of the
issue's stated raise-only scope, and `__new__`'s format string sets every
member's underlying `StrEnum` value across all ~12,391 real members, a much
larger blast radius than an error path. The module-level
`consider-using-f-string` pylint disable stays: those dunders and the
`extend_enum(..., '%d' % value, ...)` calls still use `%`.

`tests/const/test_const_enum_builtin_parity.py::test_every_bespoke_template_
carries_the_guard` (GitHub issue #647) pinned all four bespoke vendor
templates' guards against one shared `%`-style literal, so converting
`reg.apptype.apptype`'s copy broke it in CI (7 legs). `BESPOKE_TEMPLATES` is
now a dict keyed to each template's own guard text -- the other three still
raise with `%` (#798 tracks sweeping them, the `%`-formatted dunders, and
dropping the disable) -- rather than an `or` of both forms, which would let a
genuinely deleted guard pass. Proved by deleting the guard from
`pcapkit.vendor.tcp.flags` locally: the test failed with the expected
subTest and message, then the file was restored and the tree verified clean.

Build/test: `tests/const/test_const_apptype_split_unit.py` (19),
`tests/vendor/test_vendor_reg_apptype_generator_unit.py` (6), and
`tests/const/test_const_enum_builtin_parity.py` (20, `requests` importable so
neither guard test skips) all pass under both pytest and plain unittest.
mypy clean before and after. pylint: 0 new findings in either touched
apptype file; R0801 unchanged at 575 tree-wide.

Closes #792
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit
(relative to its parent, 4233555) now names all 20 bullets it carries, not
just the 8 this session added on top of the 10 already there -- a first
draft of this message named only its own 8 and left the other 10 silent,
which a cross-review caught. Six are breaking, matching the crediting PRs'
own `breaking` label in each case:

- #754 -- AppType split into per-transport registries; the 1,004 portless
  and 704 transportless rows stop being members.
- #764 -- an out-of-range port in `AppType.get` is refused, not minted.
- #778 -- `@final` enforced at runtime on `Info`/`Schema`.
- #575 -- four `.get()`-backed enum fields fall through to
  `_unregistered_member` instead of minting; 14 of 23 sample captures
  change output.
- #759 -- `AppType._dispatch` on a multi-transport `proto` now raises
  `ProtocolError` instead of silently resolving to whichever transport
  owns the lowest set bit.
- #805 -- `FieldBase.length` on a negative resolved length now raises
  `ProtocolError` instead of letting a bare `struct.error` escape.
  A second cross-review caught both: their crediting PRs (#783, #811) both
  carry GitHub's own `breaking` label, and neither bullet said so.

- #772 (with #790's docstring reword), #766, #787, #794 (with #791's
  citation repoint), #792/#798, #802, #779 (via #782, a further
  #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one
  bullet each), #796, #800 -- the other 14, non-breaking.

Also restores a measurement an earlier round in this same diff dropped
while updating an adjacent one: the #692 entry's "mypy is unmoved at 112
errors" silently lost its "and pylint ... 364 messages" half when
`EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence.
Restored to the last value earlier rounds signed off on rather than
re-measured, since this branch's own `pcapkit/` tree predates several
since-merged PRs and a fresh run would not be measuring the same thing the
original round measured. Unmoved, and not silently dropped this time:
`93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES`
44 to 43 in the several other places that already carried the fix.

On which PRs get a bullet: there is no clean "user-facing only" rule --
#766 and #791 are pure CI/test/lint-comment entries that are in, while
#773 and #763 are the same kind of thing and are out. The real pattern
across this file's 46 commits is closer to "each round's author judged it
worth a reader's time," which is inconsistent by construction. This round
leaves that inconsistency as found rather than trying to retrofit a rule,
but did add #779 (via #782) on reconsideration -- its own PR body names it
as sharing #766's hazard, and pre-existing precedent already treats that
hazard's instances as bullet-worthy.

`changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0.
`test_changelog_md.py` 47 passed.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit
(relative to its parent, 4233555) now names all 20 bullets it carries, not
just the 8 this session added on top of the 10 already there -- a first
draft of this message named only its own 8 and left the other 10 silent,
which a cross-review caught. Six are breaking, matching the crediting PRs'
own `breaking` label in each case:

- #754 -- AppType split into per-transport registries; the 1,004 portless
  and 704 transportless rows stop being members.
- #764 -- an out-of-range port in `AppType.get` is refused, not minted.
- #778 -- `@final` enforced at runtime on `Info`/`Schema`.
- #575 -- four `.get()`-backed enum fields fall through to
  `_unregistered_member` instead of minting; 14 of 23 sample captures
  change output.
- #759 -- `AppType._dispatch` on a multi-transport `proto` now raises
  `ProtocolError` instead of silently resolving to whichever transport
  owns the lowest set bit.
- #805 -- `FieldBase.length` on a negative resolved length now raises
  `ProtocolError` instead of letting a bare `struct.error` escape.
  A second cross-review caught both: their crediting PRs (#783, #811) both
  carry GitHub's own `breaking` label, and neither bullet said so.

- #772 (with #790's docstring reword), #766, #787, #794 (with #791's
  citation repoint), #792/#798, #802, #779 (via #782, a further
  #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one
  bullet each), #796, #800 -- the other 14, non-breaking.

Also restores a measurement an earlier round in this same diff dropped
while updating an adjacent one: the #692 entry's "mypy is unmoved at 112
errors" silently lost its "and pylint ... 364 messages" half when
`EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence.
Restored to the last value earlier rounds signed off on rather than
re-measured, since this branch's own `pcapkit/` tree predates several
since-merged PRs and a fresh run would not be measuring the same thing the
original round measured. Unmoved, and not silently dropped this time:
`93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES`
44 to 43 in the several other places that already carried the fix.

On which PRs get a bullet: there is no clean "user-facing only" rule --
#766 and #791 are pure CI/test/lint-comment entries that are in, while
#773 and #763 are the same kind of thing and are out. The real pattern
across this file's 46 commits is closer to "each round's author judged it
worth a reader's time," which is inconsistent by construction. This round
leaves that inconsistency as found rather than trying to retrofit a rule,
but did add #779 (via #782) on reconsideration -- its own PR body names it
as sharing #766's hazard, and pre-existing precedent already treats that
hazard's instances as bullet-worthy.

`changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0.
`test_changelog_md.py` 47 passed.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit
(relative to its parent, 4233555) now names all 20 bullets it carries, not
just the 8 this session added on top of the 10 already there -- a first
draft of this message named only its own 8 and left the other 10 silent,
which a cross-review caught. Six are breaking, matching the crediting PRs'
own `breaking` label in each case:

- #754 -- AppType split into per-transport registries; the 1,004 portless
  and 704 transportless rows stop being members.
- #764 -- an out-of-range port in `AppType.get` is refused, not minted.
- #778 -- `@final` enforced at runtime on `Info`/`Schema`.
- #575 -- four `.get()`-backed enum fields fall through to
  `_unregistered_member` instead of minting; 14 of 23 sample captures
  change output.
- #759 -- `AppType._dispatch` on a multi-transport `proto` now raises
  `ProtocolError` instead of silently resolving to whichever transport
  owns the lowest set bit.
- #805 -- `FieldBase.length` on a negative resolved length now raises
  `ProtocolError` instead of letting a bare `struct.error` escape.
  A second cross-review caught both: their crediting PRs (#783, #811) both
  carry GitHub's own `breaking` label, and neither bullet said so.

- #772 (with #790's docstring reword), #766, #787, #794 (with #791's
  citation repoint), #792/#798, #802, #779 (via #782, a further
  #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one
  bullet each), #796, #800 -- the other 14, non-breaking.

Also restores a measurement an earlier round in this same diff dropped
while updating an adjacent one: the #692 entry's "mypy is unmoved at 112
errors" silently lost its "and pylint ... 364 messages" half when
`EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence.
Restored to the last value earlier rounds signed off on rather than
re-measured, since this branch's own `pcapkit/` tree predates several
since-merged PRs and a fresh run would not be measuring the same thing the
original round measured. Unmoved, and not silently dropped this time:
`93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES`
44 to 43 in the several other places that already carried the fix.

On which PRs get a bullet: there is no clean "user-facing only" rule --
#766 and #791 are pure CI/test/lint-comment entries that are in, while
#773 and #763 are the same kind of thing and are out. The real pattern
across this file's 46 commits is closer to "each round's author judged it
worth a reader's time," which is inconsistent by construction. This round
leaves that inconsistency as found rather than trying to retrofit a rule,
but did add #779 (via #782) on reconsideration -- its own PR body names it
as sharing #766's hazard, and pre-existing precedent already treats that
hazard's instances as bullet-worthy.

`changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0.
`test_changelog_md.py` 47 passed.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…s in #817 and #821

Two bullets, both non-breaking, appended after the #800 entry in merge
order. Bullet count 128 to 130 (`grep -cE '^\* \*\*'`).

- #804 (PR #817) -- the three `__repr__` methods #798 left `%`-formatted
  are f-strings now, dropping `consider-using-f-string` from both const
  modules and both vendor templates; the other bespoke templates in
  `{const,vendor}/{ftp,http}/` still carry the disable, so #804's claim
  holds for this pair only, not for those directories.

- #682 (PR #821) -- `TCP.__proto__` no longer binds `httpv1.HTTP` directly
  for ports 80/8080; both repoint to the generic HTTP proxy `_guess_version`
  identifies through, which only became reliable once #800/#814 landed.
  `udp.py` already pointed there, so that side of the PR is prose-only
  (its port rows and docstring), not a code change, and the entry says so.
  Protochain over the 23 sample captures is *not* byte-identical: 9 frames
  in `options-transport.pcap` go `Raw` to `HTTP/2`, all 231 HTTP/1.1 frames
  are unaffected, and `_guess_version`'s entry count goes 0 to 252.

  Not marked `**a breaking change to**`: PR #821's own labels are
  `bug,fix,docs,test`, no `breaking`, unlike #759/#783 and #805/#811 last
  round, whose crediting PRs did carry it. The entry does say what a
  `breaking`-blind reader would still want to know -- TCP:80/8080 traffic
  that is neither valid HTTP/1 nor preface-carrying now reaches
  `_guess_version`'s fall-through arm instead of the direct `httpv1` bind's
  unconditional `Raw`, which is where the 12 (of 252) fall-throughs the PR
  measured come from.

`util/changelog_md.py` regenerated `CHANGELOG.md`, first pass, no
line-spanning literal this round; `--check` exit 0.
`test_changelog_md.py` 47 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaks public-facing behaviour or API (apply alongside the type label) const Regenerated IANA or vendor constant tables; members keep their numeric values fix Pull requests that fix a defect (fix: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

const(apptype): per-registry __canonical__ divergence makes get(port, proto=<multi-bit>) return a different service

1 participant