Skip to content

fix(py): hold the batch slot for unreadable inputs - #1350

Merged
dekobon merged 4 commits into
mainfrom
fix/1238-batch-one-to-one
Aug 23, 2026
Merged

fix(py): hold the batch slot for unreadable inputs#1350
dekobon merged 4 commits into
mainfrom
fix/1238-batch-one-to-one

Conversation

@dekobon

@dekobon dekobon commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Summary

analyze_batch / analyze_paths documented that skip_generated=False
guarantees one element per input, so callers could
zip(inputs, results). That promise was unsatisfiable:
analyze_path returns Ok(None) from two places — the
skip_generated filter, and the unconditional read_file_with_eol
gate (three bytes or fewer, a UTF-16 BOM, a non-UTF-8 leading window) —
and push_one_result dropped the slot for both.

Verified against the 2.1.1 build before fixing:

analyze_batch([tiny.rs(3B), binary.rs, good.rs], skip_generated=False)
→ 3 inputs, 1 result;  zip pairs tiny.rs with good.rs's metrics

No error, no AnalysisFailure, nothing to observe — a silent
data-corruption class defect for any pipeline following the documented
pattern over a corpus containing one empty or binary-prefixed file.

The fix

With the filter off, the read gate is the only remaining Ok(None)
source — exactly the inputs single-file analyze already returns None
for. So that arm keeps its slot:

Ok(None) if !opts.skip_generated => results.push(py.None()),
Ok(None) => {}

No signature change and no new parameter: push_one_result already
receives opts: AnalyzeOptions, which carries skip_generated. The
skip_generated=True default is untouched — a skipped file, generated
or unreadable, still yields no element.

analyze_paths shares the funnel and so shares the behaviour. It has no
positional contract of its own (results are walk-ordered), so this was a
deliberate consistency call rather than a second bug fixed for free; the
docs say what the slots buy there (a discovered file that could not be
analysed stays visible instead of vanishing) and how to filter them.

Breaking-change note

Marked (breaking) in CHANGELOG.md, in both halves:

  • Typed consumers: the stub widens to
    list[FuncSpaceDict | AnalysisFailure | None]. A mypy --strict
    caller indexing a slot without a None check newly fails to
    type-check — which is the point; the runtime values it would have
    received were already wrong.
  • Untyped consumers: a skip_generated=False loop that indexed
    every slot used to run to completion (silently mis-paired) and now
    raises TypeError: 'NoneType' object is not subscriptable. Loud and
    local beats silent and downstream.

Widening a return union is not additive under STABILITY.md. It
landed in a minor rather than waiting for 3.0 because the alternative
was leaving silent data corruption in the released line, and because the
runtime contract it makes true is the one both entry points already
documented. The exception and its reasoning are recorded in
STABILITY.md under Python bindings → Typing.

Two facts made None the low-risk shape rather than a new vocabulary:
to_sarif's stub already accepted
Iterable[FuncSpaceDict | AnalysisFailure | None] and called that "the
natural shape of analyze_batch's return value", and None is what
single-file analyze returns for the same inputs.

Tests

Rust patch coverage comes from cargo llvm-cov nextest, which never
runs pytest — so the guard is pinned on both sides. Mutation-verified in
three directions, since a guard can be wrong by being absent,
unconditional, or inverted:

perturbation Rust failures Python failures
guard removed (pre-fix) read_gate_skips_keep_their_slot_… 5
guard made unconditional every_skip_class_drops_its_slot_… 4
guard condition inverted both 8

Neither test alone covers both directions, which is why the default-path
companions exist. Fixture rows carry an Expect so both tests derive
every position and count from the table rather than hardcoded indices.

Review remediation (016ee72b)

An 8-angle review pass found 14 verified issues; 11 fixed here, 2 filed
(#1348, #1349), 1 by design. The two that mattered:

Also: stale AnalysisError (removed at 2.0) in to_sarif's runtime
TypeError; three example surfaces labelling read-gate declines as
"generated"; analyze_path's rustdoc claiming a UTF-16 BOM is stripped
when it is rejected (#803); the over-broad "behaviour-preserving"
migration claim in four places; analyze_paths preallocation missing
the missing-seed elements.

Validation

make pre-commitBCA_GATE: pass. mypy --strict + pyright clean,
367 pytest passed, cargo llvm-cov reports hit counts of 5 and 7 on the
two new production lines.

Fixes #1238

`analyze_batch` and `analyze_paths` documented that
`skip_generated=False` yields one element per input, so callers could
`zip(inputs, results)`. `analyze_path` returns `Ok(None)` from two
places, though — the `skip_generated` filter and the unconditional
`read_file_with_eol` gate (three bytes or fewer, a UTF-16 BOM, a
non-UTF-8 leading window) — and `push_one_result` dropped the slot for
both. A batch containing one tiny or binary file therefore returned a
shorter list, and the endorsed zip attributed every later result to the
wrong path with no error and no `AnalysisFailure` to observe.

With the filter off the read gate is the only remaining source, so that
arm now pushes `None` — the same value single-file `analyze` returns for
those files, and one `to_sarif` already skips. The `skip_generated=True`
default is unchanged.

The stub widens to `list[FuncSpaceDict | AnalysisFailure | None]`, which
is what flagged the two documented examples that needed a `None` branch.

Fixes #1238
The assertion "to_sarif skips the None entries" is vacuously true of
a list that has none — which is exactly what pre-#1238 analyze_batch
returned, so the test passed against the bug it sits downstream of.
Assert the slot count first.

Refs #1238
A sweep's result parser is as much a part of the subject as the file
under test. A uniform zero across perturbations describes the harness,
not the coverage.

Refs #1238
- The new to_sarif test compared a SARIF artifactLocation.uri to a
  raw str(path); the writer emits an RFC 3986 reference (percent-
  encoded, file:///C:/... on Windows), so the Windows pytest leg
  would fail. Assert count + basename instead.
- to_sarif's runtime TypeError and docstring still named
  AnalysisError, removed at 2.0 (#614).
- Three example surfaces printed "generated" for files analyze()
  skips for read-gate reasons (empty/tiny, UTF-16 BOM, binary):
  pipeline_db's per-file branch, sarif_upload's summary line, and
  the Jupyter quick-start cell. sarif_upload's analysed counter
  also flips to isinstance(r, dict) so a future
  skip_generated=False caller cannot count None as analysed.
- analyze_path's rustdoc claimed a UTF-16 BOM is stripped; it is
  rejected (#803), matching the correction already made in the stub.
- analyze_paths preallocates for missing seeds too, which the 1:1
  contract now makes the exact final length.
- The Rust test table carries an Expect per row, so both tests
  derive every position and count from the table instead of
  hardcoded indices a new row would silently shift.
- Docs: the CHANGELOG entry gains the **(breaking)** marker per the
  #1056 precedent; lesson 42's follow-up now records that the #542
  commit deleted the defensive fallback (the read-gate silence was
  its absence, not its failure); "behaviour-preserving" migration
  claims are qualified with the list-shape difference; the read-gate
  enumeration gains the mid-read-shrink case in the two contract
  surfaces; flatten_spaces' docstring names the batch None slots;
  the README zip snippet demonstrates the safe three-branch form.

Refs #1238
@dekobon
dekobon merged commit f6c144e into main Aug 23, 2026
50 checks passed
@dekobon
dekobon deleted the fix/1238-batch-one-to-one branch August 23, 2026 17:14
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.

fix(py): batch docs promise 1:1 results under skip_generated=False, but tiny/binary files still drop slots

1 participant