Skip to content

fix(gate-25): the request-NAME arm matched a JSON syntax the extraction removed (#430) - #431

Merged
rubenvdlinde merged 1 commit into
mainfrom
fix/gate-25-newman-name-arm-and-url-signature
Aug 13, 2026
Merged

fix(gate-25): the request-NAME arm matched a JSON syntax the extraction removed (#430)#431
rubenvdlinde merged 1 commit into
mainfrom
fix/gate-25-newman-name-arm-and-url-signature

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes #430.

d33cd36 (#425) rebuilt gate-25's Newman haystack from the raw bytes of each collection into a newline-joined list of extracted values. That correctly stopped a Postman description standing in for a request. It did not update is_covered, whose second Newman arm still matches JSON key syntax against a haystack that is no longer JSON:

re.search(rf'"name"\s*:\s*"[^"]*\b{re.escape(method)}\b', newman)

Confirmed rather than read, on docudesk's own collections:

AFTER  name-arm fires for method 'versions'?  False
BEFORE name-arm fires for method 'versions'?  True

What it cost — measured, eighteen core apps, identical trees

fa555a2 vs a316aa5, bin/hydra-gates --full, only the package moving:

gate-25
total 239 → 280
apps PASS → FAIL 9
of the 41 new findings, carried by the request-NAME arm and nothing else 36
of the 41, unclosable by any correct app change 25

Three causes, three arms, three controls

1. The name arm. _newman_evidence now tags every extracted value <field>:<value>. The name arm reads ^name: lines, the url arm reads url-bearing lines — so the discriminator _newman_paths' own docstring claims ("which string, not whether it is one") is actually applied. Control: a collection where the method word appears in a url and in a request body and in no request name is still a finding.

2. _url_signature deleted placeholders and joined the survivors.

/api/pos-transactions/{id}/confirm   ->   "api/pos-transactions/confirm"

Not a path — no correct collection url can contain it. 16 endpoints were reported "missing a contract test" while the app's own collection held a request for exactly that route. Now a regex with placeholders as wildcard segments. Control: …/{id}/confirm covers confirm and leaves cancel reported — which "truncate at the first placeholder" cannot do, and pipelinq has five POS operations on one id.

3. _ROUTE_ENTRY_RE could not read a nested array. \[[^\[\]]*?\] is a bracket pair that forbids brackets, so every route declaring 'requirements' => [...] fell through to the name-only sweep and was recorded with an empty url — an arm nothing can satisfy. 9 endpoints across the fleet. The url was in the file all along:

['name' => 'page#deepLink', 'url' => '/{deepLink}', 'verb' => 'GET',
 'requirements' => ['deepLink' => '(?!api(?:/|$)).+']],

Recovered by balancing brackets over a php_masked copy, so a bracket inside a requirement regex is not counted as structure. Control: a 'requirements' key literally named url must not be read as the route's path.

Two tightenings came with it — measured, and neither is optional

  • A trailing placeholder is now a required segment. Keeping it lenient (as _url_signature was) is unshippable next to cause 3: four openregister SPA page routes acquired a parsed url for the first time and were immediately answered by …/api/registers/…. A parser repair that converts findings into silence is the wrong trade whichever way the count moves. Fleet cost of this arm alone: one endpoint, zaakafhandelapp users#me, previously "covered" because the two letters me occur somewhere in the collection.
  • The left edge is anchored on the app base — after /apps/<id>, after a } ({{baseUrl}}/{{app}}/api/…, zaakafhandelapp), after a quote, or at the start of the path. /registers/{id} and /api/registers/{id} are different endpoints and the second contains the first.

Plus one correctness fix with zero fleet effect, pinned so it stays: a route name registered under several urls (a 'postfix' entry — six of the eighteen apps do it, 13 times in zaakafhandelapp) is covered by a test for any of them, not for whichever entry the parser kept last.

Re-measured — same protocol, third column

app before fa555a2 after a316aa5 now (this PR)
openregister 66 69 71
procest 116 120 116
shillinq 52 53 51
hermiq 5 5 5
softwarecatalog 0 5 4
zaakafhandelapp 0 1 2
openconnector 0 4 1
doriath 0 3 1
launchpad 0 9 0
pipelinq 0 5 0
decidesk 0 3 0
docudesk 0 1 0
opencatalogi 0 1 0
openbuild 0 1 0
larpingapp · nldesign · portaliq · scholiq 0 0 0
total 239 280 251

Every app measured on the same commit in all three columns (out/<app>/head.sha == head.now.sha, checked; no drift).

vs before: 13 findings appear, 1 disappears.

The one that disappears is shillinq periodClose#aiFlags — a medial-placeholder false positive that pre-dates #425, answered by …/api/period-close/{{period_id_open…}}/ai-flags.

The 13 that appear were each read by hand and have no request, no matching request name and no PHPUnit call: softwarecatalog's four settings#get*UserGroups (the only mention in tests/ is a docblock route table at SettingsControllerUserGroupsConfigAuthTest.php:47; the body tests getUserGroupsConfig()), openregister ui#reports / ui#configurations / ui#endpoints / ui#entities / ui#tables, zaakafhandelapp users#me and resultaten#pages, doriath publicShell#page, openconnector synchronizations#deleteLog.

Acceptance is the two measured lists, not the count

THE 16 FALSE POSITIVES (a Postman request for exactly that route exists):
  ✅ gone  docudesk templateVersions#versions        ✅ gone  procest statusTransition#available
  ✅ gone  launchpad widgetApi#addTile               ✅ gone  procest statusTransition#history
  ✅ gone  launchpad dashboardApi#activate           ✅ gone  zaakafhandelapp documenten#download
  ✅ gone  launchpad ruleApi#addRule                 ✅ gone  decidesk participationBudget#publishBudgetResults
  ✅ gone  openconnector events#pull                 ✅ gone  decidesk participationBudget#castAdvisoryVote
  ✅ gone  openconnector synchronizations#test       ✅ gone  doriath folder#children
  ✅ gone  openregister tmlo#summary                 ✅ gone  pipelinq posTransaction#confirm
  ✅ gone  pipelinq posTransaction#park              ✅ gone  pipelinq posTransaction#settle

THE 5 TRUE POSITIVES (#425 working — prose standing in for a test):
  ✅ still fires  softwarecatalog settings#getAllGroups / getGenericUserGroups
                  / getOrganizationAdminGroups / getSuperUserGroups
  ✅ still fires  openregister ui#reports

Tests

test_check_contract_coverage.py goes 8 → 21. Reverting check_contract_coverage.py and re-running flips 10 and leaves 11 green, so every repair has an arm that fails without it and a control that does not:

FAIL: test_a_medial_placeholder_does_not_make_the_route_unmatchable
FAIL: test_a_sibling_operation_on_the_same_id_is_not_the_same_endpoint
FAIL: test_a_request_named_after_the_controller_method_is_coverage
FAIL: test_the_method_name_in_a_url_is_not_a_request_name
FAIL: test_a_route_entry_with_a_nested_requirements_array_keeps_its_url
FAIL: test_an_api_request_does_not_cover_the_spa_page_route_of_the_same_name
FAIL: test_a_trailing_placeholder_is_a_required_segment
FAIL: test_a_partly_literal_segment_keeps_its_literal
FAIL: test_a_route_is_not_covered_by_a_request_that_extends_it
FAIL: test_a_neighbouring_path_is_not_the_route
Ran 21 tests — FAILED (failures=10)

Also verified:

  • the shipped gate-acceptance/contract-coverage fixture still holds through bin/hydra-gates: planted FAIL naming thing#farAttribute, clean PASS;
  • all 44 scripts/lib/test_*.py suites give byte-identical results to a316aa5;
  • one existing fixture changed and is called out in its own docstring — test_an_unparseable_collection_falls_back_to_raw_text wrote /api/things for a /api/things/{id} route, which only worked while trailing placeholders were dropped. It now writes the url the route has, so the arm tests the fallback rather than the leniency.

What I could not verify

  • The CI layer. Everything here is bin/hydra-gates --full run locally (python 3.8.10, node v22.22.0). No GitHub Actions pull_request run was made, and after fix(gates 19, 25, 26, 51, 52, 54, 55): the scope decision has exactly one source (#416) #418 a local full run and a CI run are not interchangeable claims.
  • Diff-scoped behaviour. All numbers are full-tree. On a PR the endpoint population is the diff's; the mechanisms are scope-independent.
  • Whether the 13 new findings are untested by some other route — I checked Postman collections and the PHPUnit ->method( arm. An endpoint exercised through a differently-named service call, or by an e2e spec, would still read as uncovered here.
  • The left-edge floor that remains. /registers/{id} still matches …/api/registers/7 when the app id cannot be read from appinfo/info.xml; three of the four anchors still apply and the pattern is then no looser than the one it replaces.

Two unrelated pre-existing defects found while building the controls are filed separately rather than folded in: #428 (gate-48's src/**/*.js pathspec cannot see a file directly under src/) and #429 (gate-50's guard window crosses method boundaries).

🤖 Generated with Claude Code

…on removed (#430)

#425 rebuilt gate-25's Newman haystack from the raw bytes of each collection
into a newline-joined list of extracted VALUES. That correctly stopped a
Postman `description` standing in for a request. It did not update
`is_covered`, whose second Newman arm still matches JSON key syntax —

    re.search(rf'"name"\s*:\s*"[^"]*\b{method}\b', newman)

— against a haystack that is no longer JSON. The arm stopped seeing anything a
collection declares. Confirmed rather than read, on docudesk's own collections:

    AFTER  name-arm fires for method 'versions'?  False
    BEFORE name-arm fires for method 'versions'?  True

MEASURED, eighteen core apps, identical trees, only the package moving
(fa555a2 vs a316aa5, `bin/hydra-gates --full`): gate-25 went 239 -> 280, nine
apps PASS -> FAIL, and 36 of the 41 new findings had been carried by this arm
and nothing else. 25 of the 41 could not be closed by any correct app change.

Three causes, each with its own arm and its own control:

1. THE NAME ARM. Both arms now ask their question of the field that answers
   it: `_newman_evidence` tags every extracted value `<field>:<value>`, the
   name arm reads `^name:` lines, the url arm reads url-bearing lines. Its
   control is a collection where the word appears in a url and in a payload
   and in no request name — still a finding.

2. `_url_signature` DELETED PLACEHOLDERS AND JOINED THE SURVIVORS.
   `/api/pos-transactions/{id}/confirm` became `api/pos-transactions/confirm`,
   a string no correct url can contain. 16 endpoints were reported uncovered
   while the app's own collection held a request for exactly that route. Now a
   regex with placeholders as wildcard segments. Its control is a sibling
   operation on the same id — `…/{id}/confirm` covers `confirm` and leaves
   `cancel` reported, which "truncate at the first placeholder" cannot do.

3. `_ROUTE_ENTRY_RE` COULD NOT READ A NESTED ARRAY. `\[[^\[\]]*?\]` fails on
   every route declaring `'requirements' => [...]`, so 9 endpoints reached
   `is_covered` with an EMPTY url — an arm nothing can satisfy. The entry is
   now recovered by balancing brackets over a string-masked copy, so a bracket
   inside a requirement regex is not counted as structure.

TWO TIGHTENINGS COME WITH IT, BOTH MEASURED, NEITHER OPTIONAL:

  - A trailing placeholder is now a REQUIRED segment. Keeping it optional was
    unshippable next to (3): four openregister SPA page routes acquired a url
    for the first time and were immediately answered by `…/api/registers/…`.
    A parser repair that converts findings into silence is the wrong trade
    whichever way the count moves.
  - The left edge is anchored on the app base (`/apps/<id>`, a `}`, a quote,
    or the start of the path). `/registers/{id}` and `/api/registers/{id}` are
    different endpoints and the second contains the first.

And one correctness fix with zero fleet effect, pinned so it stays: a route
name registered under several urls (a `'postfix'` entry — six of the eighteen
apps do it, 13 times in zaakafhandelapp) is covered by a test for ANY of them,
not for whichever entry the parser happened to keep last.

RE-MEASURED, same protocol, third column:

    before(fa555a2)  after(a316aa5)  now
    239              280             251

vs `before`: 13 findings appear, 1 disappears. The one that disappears is
shillinq `periodClose#aiFlags` — a medial-placeholder false positive that
pre-dates #425 and is answered by `…/api/period-close/{{period_id}}/ai-flags`.
All 13 that appear were verified by hand to have no request, no matching
request name and no PHPUnit call: softwarecatalog's four `settings#get*Groups`
(a docblock route table was the only mention), openregister `ui#reports`,
`ui#configurations`, `ui#endpoints`, `ui#entities`, `ui#tables`,
zaakafhandelapp `users#me` and `resultaten#pages`, doriath `publicShell#page`,
openconnector `synchronizations#deleteLog`.

Acceptance is the two measured lists, not the count: all 16 false positives
gone, all 5 true positives still firing.

The suite goes 8 -> 21 tests. Reverting check_contract_coverage.py flips 10 of
them and leaves 11 green, so each repair has an arm that fails without it and a
control that does not.
@rubenvdlinde
rubenvdlinde merged commit 36e96fb into main Aug 13, 2026
33 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/gate-25-newman-name-arm-and-url-signature branch August 13, 2026 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gate-25: the request-NAME arm matches a JSON syntax the extraction removed — 41 new findings, 25 of them unclosable

1 participant