Skip to content

🐛 sphinx-needs: an empty need.FIELD selector fails the call, as an unset one does - #2136

Merged
chrisjsewell merged 2 commits into
masterfrom
claude/magical-hopper-uw9j1q
Oct 8, 2026
Merged

chrisjsewell merged 2 commits into
masterfrom
claude/magical-hopper-uw9j1q

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

What

Package: packages/sphinx-needs.

A need.<field> argument that selects what a built-in reads (copy's id, field and filter; calc_sum's field,
filter and links_only; check_linked_values' field and filter; links_from_content's id and filter) now fails the
call when its field holds "", exactly as it already did when the field holds None:

.. req:: Uses a selector its need does not set
   :id: E_COPY
   :summary: [[copy("title", need.src)]]

With src an untyped extra option (needs_extra_options = ["src"]) that the need does not set, src is "", and the
call ran as copy("title", ""), which copies the need's own title, silently. It is now one
needs.dynamic_function warning, Error while applying need to function 'copy': need.src selects what the call reads, and is not set, and the field keeps its placeholder (empty here). The same holds for the call in an :ndf: role or a
needtable :style_row: (they share the check), and the dependency order reads nothing for such a call. A need.<field>
in a value argument (check_linked_values' result or search_value, copy's upper) still passes "" on.

  • functions/order.py: one _UNSET = (None, "") used by unset_selector() (the run-time check of the field, the role
    and :style_row:) and by the order's own reading of a selector.
  • Docs: dynamic_functions.rst's need.<field> paragraph says "unset or empty" (and that a value argument is None,
    or "" for an untyped extra option, when unset); the 9.0.0 entry of the changelog likewise, in the Unreleased
    entry "Dynamic functions and variants are computed in dependency order", whose cite list gains this pull request.

Why

#2064 (derived values), #2134 (phase 1, merged), which made an unset (None) selector fail the call instead of
letting copy fall back to the need's own field. ubCode's review of its own phase 1 found that an untyped extra option
left unset holds "", not None, so the same silent fallback remained for the most common kind of extra option, and
links_only=need.src summed every need instead of the need's links. Both tools now treat "" in a selector as unset,
with the same finding.

Tests

  • test_an_empty_need_attribute_selector_fails_the_call (tests/test_dynamic_functions_strata.py): an untyped extra
    option left unset in copy's id, calc_sum's field, check_linked_values' field and links_only, each None and
    one needs.dynamic_function naming need.src; the :ndf: role renders ??; upper=need.src is a value and copies
    normally. Red before the change: {'E_COPY': "copy's need"} != {'E_COPY': None} (the own-field copy) and
    {'E_ONLY': 2.0} != {'E_ONLY': None} (every need summed).
  • tests/test_functions_order.py: two rows of the edge table, an empty selector of copy and an empty links_only,
    read nothing. Red before: {'deps': [('RD', 'summary')]} != {'deps': []} and
    {'columns': [(Column(field='hours', candidates=None), None)]} != {'columns': []}.

The full suite (uv run poe test-needs -n 4 tests/) passes but for the 50 graphviz renders that need dot (the same 50
on master on a machine without it).

Checklist

  • I wrote this change myself and have read every line of it; it was not generated automatically from an issue.
    Built in an orchestrated session from ubCode's review finding and the code; the tests were written first and shown
    red, and every line was read.
  • I ran the package's tests (uv run poe test-needs) and they pass (the graphviz renders excepted on a machine
    without dot, identically to master).
  • Documentation is updated where behaviour or options change (docs/dynamic_functions.rst).
  • The package's docs/changelog.rst has the change, in the existing Unreleased derived-values entry (its
    :pr: cite now names this number).
  • uv run poe lint and uv run poe typecheck pass.

Refs #2064.

When squashing, strip the Co-authored-by trailer GitHub proposes.

An untyped extra option that a need does not set holds "", and a
need.<field> selector holding "" passed it on: copy("title", need.src)
then ran copy("title", ""), which copies the need's own title, and
calc_sum(..., links_only=need.src) summed every need instead of the
links. In a selector slot (copy's id, field and filter; calc_sum's field,
filter and links_only; check_linked_values' field and filter;
links_from_content's id and filter) "" is now unset, as None already was:
the call fails as needs.dynamic_function naming the field, in a field, an
ndf role and a :style_row: alike, and the order reads nothing for it. A
value argument still passes "" on. Found by ubCode's phase-1 review; both
tools follow the same rule.
@github-actions github-actions Bot added the pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs label Oct 8, 2026
@chrisjsewell chrisjsewell changed the title 🐛 sphinx-needs: an empty need.<field> selector fails the call, as an unset one does 🐛 sphinx-needs: an empty need.<field> selector fails the call, as an unset one does Oct 8, 2026
@chrisjsewell chrisjsewell changed the title 🐛 sphinx-needs: an empty need.<field> selector fails the call, as an unset one does 🐛 sphinx-needs: an empty need.FIELD selector fails the call, as an unset one does Oct 8, 2026
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.78%. Comparing base (1bba2ea) to head (4aa3756).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #2136   +/-   ##
=======================================
  Coverage   92.78%   92.78%           
=======================================
  Files         137      137           
  Lines       20158    20159    +1     
=======================================
+ Hits        18704    18705    +1     
  Misses       1454     1454           
Flag Coverage Δ
ai-index 100.00% <ø> (ø)
codelinks 95.35% <ø> (ø)
mounts 94.27% <ø> (ø)
pytests 92.34% <100.00%> (+<0.01%) ⬆️
reports 88.82% <ø> (ø)
ub-test-reports 90.20% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chrisjsewell
chrisjsewell merged commit 7be5bf5 into master Oct 8, 2026
43 checks passed
@chrisjsewell
chrisjsewell deleted the claude/magical-hopper-uw9j1q branch October 8, 2026 08:55
chrisjsewell added a commit that referenced this pull request Oct 8, 2026
## What

Package: `packages/sphinx-needs`.

This fixes `check_linked_values` and `calc_sum(links_only=True)` in a
link field whose need's own `links` are computed in
the same step. Until the links are computed, nobody knows which needs
they name, including the ones written in them.
So the call now reads its field, and the fields its filter names, as a
**column**, on every need. That is the same
column node a whole-project `calc_sum` reads:

```rst
.. req:: Reads a link type through links that are computed too
   🆔 LL_WLINK
   :links: T_THREE, [[copy("links", "OTHER_L")]]
   :blocks: [[check_linked_values("T_THREE", "refs", "T_ONE", one_hit=True)]]
```

What the call does now depends on where its field is computed:

- **Computed in step 2, the link fields, by some need** (`refs` above):
the call runs after every need that computes
it, so it reads the computed value (above, `blocks` is `['T_THREE']`
once a linked need's computed `refs` names
  `T_ONE`). It also still waits for its own `links`.
- **Computed after step 2, on any need, or a back link or dead-link
flag:** the read is out of scope. The call reports
`needs.derive_scope` and is not run, and the field keeps its written
links. For example: `dynamic function
'check_linked_values' for option 'blocks' reads 'links_back' on every
need its links name, which is final only after
the link fields are computed: the call is not run and the field is left
empty`.
- **Computed by no need:** the call reads the written value.

Two more cases:

- A call in the `links` themselves still reads itself, which is a cycle.
- A need that reads its own field through such a column is on a cycle
with it. The message then names the links:
`…, through its links, which are computed in the same step, so every
need is a candidate`.

Code changes:

- `functions/order.py`:
  - `_CallReads._linked()` replaces the two walks of the stored list.
- `OutOfScope.linked` records a back link or flag read on every need the
links name.
  - A column's `Reason` can now be the reader's own `links` node.
- `functions/functions.py`: the `derive_scope` and `derive_cycle`
messages for these reads.
- Docs: a paragraph in `dynamic_functions.rst`'s processing order, a
clause in the list of reads that cannot be
ordered, and a sentence on the cycle message. The changelog's
*Unreleased* derived-values entry gets a clause and
  cites this pull request.

## Why

#2064 (derived values) and #2134 (phase 1, merged) order a call after
every value it reads. #2136 was a follow-up.

ubCode's review of its own phase 1 found a gap in these reads. The order
walked `need.get("links")`, which is `[]`
for a computed link list, so a written target got no edge. That had
three effects:

- Its back links were read as the pass had reset them, so
`check_linked_values(…, "links_back", …)` passed silently.
- A link type it computes was read before it was computed. Only the
run-time check caught this, as `…the order of the
  pass did not account for this read; please report this…`.
- A filter on such a field silently gave the wrong value, because the
run-time check does not see a filter's reads.

A target added by the computed list was read in order or not, depending
on its id. That means the value depended on the
schedule, which the order exists to rule out.

The ruling holds for both tools: such a reader reads through a column.
ubCode's phase 1 takes the same rule, with the
same wording for the back-link case.

## Tests

-
`tests/test_dynamic_functions_strata.py::test_a_read_through_computed_links_is_a_column_read`
uses reviewer A's
shapes (`LL_WBACK`, `LL_ABACK`, `LL_WLINK`, `LL_WLINK1`, `LL_ALINK`, and
`Z_ALINK` three links down). It adds:
  - a filter on a computed link type (`LL_WFLT`);
- a field computed in step 4 on a need the links do not name
(`LL_LATER`);
  - a field no need computes (`LL_FINAL`, `['T_ONE']`, no finding);
  - a cycle through the column (`LL_SELF`).

It runs serially and with `-j 2`, and asserts every value and every
warning. The run-time check is silent on every
  shape. Red before the change:
- `{'LL_WBACK': ['T_ONE']} != {'LL_WBACK': []}` and `{'LL_ABACK':
['T_ONE']} != {'LL_ABACK': []}` (the call ran on
    the reset back links);
- `{'LL_WFLT': []} != {'LL_WFLT': ['T_ONE']}` (the filter read `refs`
before it was computed, silently).

Before the change, its build also gave the "please report this" warning
for `LL_WLINK`, `LL_WLINK1` and `LL_ALINK`.
- `tests/test_functions_order.py`:
- The three outcomes as unit rows: a column `(Column('refs'), ('RD',
'links'))` for the option, a `links_only` sum and
a filter; out of scope for a later field, a back link and a dead-link
flag; final for a field no need computes.
  - A link field reading through itself stays a self-cycle.
  - A cycle through the column names the links.
- Red before the change: 9 of the 12 new items (the other 3 are
regression guards that already pass).

The full suite (`uv run poe test-needs -n 4 tests/`) passes, except the
50 graphviz renders that need `dot` (the same 50
fail on master on a machine without it).

## Checklist

- [x] I wrote this change myself and have read every line of it; it was
not generated automatically from an issue.
Built in an orchestrated session from ubCode's review finding and the
code. The tests were written first and shown
  red, and every line was read.
- [x] I ran the package's tests (`uv run poe test-needs`) and they pass
(the graphviz renders excepted on a machine
  without `dot`, identically to master).
- [x] Documentation is updated where behaviour or options change
(`docs/dynamic_functions.rst`).
- [x] The package's `docs/changelog.rst` has the change in the existing
*Unreleased* derived-values entry (its `:pr:`
  cite now names this number).
- [x] `uv run poe lint` and `uv run poe typecheck` pass.

Refs #2064.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants