Skip to content

fix(bundler): treat an explicit-null records field as missing, not the text "None" - #4136

Open
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/bundler-records-explicit-null
Open

fix(bundler): treat an explicit-null records field as missing, not the text "None"#4136
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix/bundler-records-explicit-null

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

InstalledBundleRecord.from_dict and _component_from_dict read the required fields with:

str(data.get("bundle_id", "")).strip()

The "" default covers only a missing key. A key that is present but null — how a hand-edited or corrupt .specify/bundle-records.json spells an empty field — yields None, and str(None) is the literal "None". That text is non-empty, so it sails straight past the if not bundle_id: / if not version: / if not cid: guards that exist precisely to reject such a file.

Reproduction on current main (bf88c9f)

bundle_id=null -> ACCEPTED: bundle_id='None' version='1.0.0'
version=null   -> ACCEPTED: bundle_id='a'    version='None'
id=null        -> ACCEPTED: components=[('presets', 'None')]

The record is accepted as a bundle literally named None at version None, contributing a component whose id is None.

That phantom is not inert: it feeds the collateral-protection refcount, so components_still_needed() reports ('presets', 'None') as protected, and install_bundle()'s other_tracked set will attribute real components to it.

Fix

Reuse _text from the sibling parser in the same package. Its docstring describes this exact trap:

A .get(key, "") default only covers a missing key. A key that is present but null — how YAML spells an empty field (author: with nothing after it) — yields None, and str(None) is the literal "None". That text is non-empty, so it sailed past the if not value required-field checks…

This is the second half of a hardening sweep that was already applied here in part: the falsy-non-list guards landed in this file as #3666, and the explicit-null fix landed for bundle.yml as #3798 — but was never mirrored into records.py. Using the shared helper keeps the two parsers from drifting again.

No breaking change. Only null-valued inputs behave differently; every string input is byte-identical. installed_at is optional, so its null correctly becomes "" rather than an error — pinned by a test.

Verification

  • Fail-before / pass-after: 4 new-vs-baseline failures with the source reverted, all passing with the fix — 5 failed → 1 failed (that one is a pre-existing Windows symlink test).
  • Normal records still load unchanged: bundle_id='demo' version='1.0.0' comps=[('presets','p1')].
  • Scoped regression over tests/unit: no new failures vs a clean-main baseline captured on bf88c9f9.
  • uvx ruff@0.15.0 check src tests → clean

Tests sit beside the existing test_load_records_rejects_record_missing_bundle_id / _missing_version / _rejects_component_missing_id, which cover the missing-key half of the same contract.


Written with assistance from Claude Code. Bug found, reproduced, and verified by me on current main.

…one"

`InstalledBundleRecord.from_dict` and `_component_from_dict` read required
fields with `str(data.get(key, "")).strip()`. The `""` default covers only a
MISSING key. A key that is present but JSON `null` — how a hand-edited or
corrupt `.specify/bundle-records.json` spells an empty field — yields
`None`, and `str(None)` is the literal `"None"`. That text is non-empty, so
it sails past the required-field guards that exist to reject such a file:

  bundle_id=null -> ACCEPTED: bundle_id='None' version='1.0.0'
  version=null   -> ACCEPTED: bundle_id='a'    version='None'
  id=null        -> ACCEPTED: components=[('presets', 'None')]

The phantom component then feeds the collateral-protection refcount, so
`components_still_needed()` reports ('presets', 'None') as protected.

Reuse `manifest._text`, the sibling helper whose docstring describes this
exact trap and which fixed the same bug for bundle.yml in github#3798. The
falsy-non-list half of this hardening already landed in this file as github#3666;
the explicit-null half was never mirrored here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jawwad-ali
jawwad-ali requested a review from mnriem as a code owner August 15, 2026 13:22
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.

1 participant