Skip to content

✨ sphinx-needs: needs.derive_unresolved for reads of same-pass values - #2080

Merged
chrisjsewell merged 9 commits into
masterfrom
claude/magical-hopper-uw9j1q
Oct 7, 2026
Merged

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

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

All [[…]], <<…>> and <{…}> are resolved in one pass, need by need, in the order the needs reached the build
environment, and each result is written into its need as soon as it is computed. A dynamic function or variant
condition that reads a field which itself carries a [[…]], <<…>> or <{…}> (on another need, or on its own need)
therefore reads the computed value or the unresolved one depending on that order: document names, the documents the
last build re-read, -j. Nothing reported it. It is now a warning, needs.derive_unresolved, once per reading
call
, located at the reading need (None for an external need, as for every pass warning).

The rule. A read of name on need T is reported iff name is a key of T's dynamic fields after needextend
(new read-only accessor NeedItem.carries_dynamic_value(name), over a set the pass never clears), whether or not the
pass has already computed it. Reporting only "not yet computed" reads would make the warning itself depend on build
history: on one project (CHAIN_X on a.rst copies CHAIN_Y.summary on b.rst, CHAIN_Y = [[copy("title")]]) a
scratch build reads 'None', an incremental build re-reading a.rst reads 'done', one re-reading b.rst reads
'None' again, so a "not yet visited" warning would turn a -W build red, green, red by which file was edited last.
So the warning also fires where today's value happens to be the computed one (CHAIN_B, CYC_B, VAR_COND in the
corpus below): that value is an accident of insertion order and flips on an incremental build (pinned by a test).
It is ubCode's rule (reads_unresolved), so both tools report the same reads.

What is read (only the built-ins and variant conditions, phase 0):

  • copy: the source field: own need, need_id, or the filter's chosen match (the lowest id);
  • calc_sum: the value of every need it considers (need-id order for a whole-project sum), noted BEFORE its
    filter runs, so a need the filter drops is named too; with links_only, also the need's own links;
  • check_linked_values: the need's own links, then each target's search_option until it stops, again noted
    before its filter;
  • a variant condition: the names of each condition evaluated (after the needs_variants lookup, up to the first that
    holds), on the need's own fields, excluding names needs_filter_data, build_tags or var supply (they win over a
    field of the same name in the evaluation context).

Mechanism. resolve_functions opens one UnresolvedReads record for each dynamic function call and each
<<…>> variant it evaluates (the conditions of one variant share it), and passes it down explicitly; no module-level
or context state is involved. _execute_dynamic_func hands the record, as the keyword-only argument reads, only to
a function carrying the records_reads mark (the built-ins copy, calc_sum and check_linked_values). The mark
names the function it was set on and is checked against the function as registered, so a wrapper that copies a
built-in's attributes (functools.wraps) is not marked. Each built-in notes, through UnresolvedReads.note_read, a
field or link it reads itself that carries a [[…]], <<…>> or <{…}> of its own once every needextend is
applied: copy the field of the need it copies from (with a filter, the match the filter chose); calc_sum the field of each candidate need, and
check_linked_values the field of each target it reaches before it stops, each before their filter decides on it, and their own need's
links where they read them. The fields a filter reads are never noted. _get_variant receives the same record
and notes the need's fields that each evaluated condition names. Every other function, every user-registered one
included, is called exactly as before and never receives reads. A built-in called after the pass (an ndf role, a
needtable :style_row:, a direct execute_func call), or by a user's own function during it, gets reads=None and
notes nothing: the user's function has no record to pass on. When the call or variant ends, whatever its outcome,
the record is reported in a finally block as one needs.derive_unresolved warning located at the reading need.

Message: ubCode's words up to the reads, then a clause that is true whether or not the value had been computed:

index.rst:60: WARNING: dynamic function 'copy' for option 'summary' read 'summary' on need 'CHAIN_B', which carries a dynamic function or variant computed in the same pass: the value read depends on the order the needs are resolved in [needs.derive_unresolved]
index.rst:69: WARNING: dynamic function 'calc_sum' for option 'total' read 'hours' on 3 needs (HRS_1, HRS_2, LIT_3), which carry a dynamic function or variant computed in the same pass: the value read depends on the order the needs are resolved in [needs.derive_unresolved]
index.rst:95: WARNING: variant condition for option 'band' read 'f1' on need 'VAR_COND', which carries a dynamic function or variant computed in the same pass: the value read depends on the order the needs are resolved in [needs.derive_unresolved]

A name read on four or more needs names the first three (… and K more); several names are joined as ubCode joins
them (a, b and c); carries is singular only for one name read on one need.

Not reported (documented): the fields a filter argument reads (current_need included; ubCode evaluates none of
these), the reads your own functions make, through a built-in they call or otherwise (no record reaches a user
function), <{…}> (variant data, not needs), links_from_content (reads the document). A need.<attr> argument
cannot occur in a field value in sphinx-needs.

Caveats (documented): a filter that reads a computed field still decides, by the order, which needs it keeps.
Noting before the filter keeps calc_sum stable, but a copy with such a filter, and a check_linked_values whose
check stops at a target the filter kept before it reaches a computed one, can be reported in one build and not in another. After the first computed value a call reads, what it reads next (an early
exit, the first true condition) can depend on that value, so the needs a message names can vary between builds while
the warning itself does not.

Opting out: suppress_warnings = ["needs.derive_unresolved"] silences exactly this subtype; it is its own subtype
(listed in the documentation's build-warnings list), so needs.dynamic_function failures stay visible.

Part of #2064 (fourth item); the notice period of #2030 step 0/E. ubCode reports the same reads as the Info-graded
needs.derive_unresolved (useblocks/ubcode#3855): the same finding set on the shared corpus, a lower severity by design.
This is changed output: a -W build with such a read fails until the read is changed or the subtype suppressed.
Docs: a "Reads of a value computed in the same pass" section in docs/dynamic_functions.rst, and a note on the
reserved reads keyword beside the built-ins; changelog entry under Unreleased → Improvements.
For the release writer: the section carries .. versionadded:: 9.0.0, after the page's existing 9.0.0 note;
renumber it if the release is not 9.0.0.

Tests

packages/sphinx-needs/tests/test_dynamic_functions_unresolved.py (inline test_app projects plus one doc_test
project; exact build_warnings(app) == [...]), committed first: 26 red, 4 negatives green, against the unchanged code;
30/30 green after. The review round added three (33/33 green): the filtered-sum fence, red against the previous code,
and two read-site tests. The explicit-record round added six more (39/39 green), under T12 below; none of the 33 changed: four with the parameter, two in its review round (a functools.wraps wrapper of a built-in, and the parameter's keyword-only None default).

test pins
test_message_shape (8) / test_message_needs_a_read the pure formatter: 1, 2, 3, 4, 10 ids, two names, three names, variant vs function; empty input refused
T1 test_chain_across_needs CHAIN_A copies CHAIN_B.summary: one warning at CHAIN_A, none at CHAIN_B; target on a page read after ("") and before ("Middle") give the identical list; also under -j 2
T2 test_chain_inside_one_need status = [[copy("comment")]], comment = [[copy("title")]]: 'comment' on need 'SAME_NEED'
T3 test_calc_sum_over_every_need summands written in reverse id order: 'hours' on 2 needs (HRS_1, HRS_2); five: (HRS_1, HRS_2, HRS_3 and 2 more)
T4 …links_only_reads_its_own_links / …names_its_computed_targets own links = [[copy(...)]]: 'links' on need 'OWN_LINKS'; links HRS_2, HRS_3, HRS_1: (HRS_2, HRS_1) in link order
T2b test_copy_filter_reads_the_match_it_copies_from copy(filter=) reports the lowest-id match it copies: RD_ONE (computed match read first, authored lowest id) silent; RD_TWO (authored match first, computed lowest id SRC_A2) warns
T5 test_check_linked_values reads WORK_1.status (already computed: reported anyway); the one_hit gate that stops at an authored target reports nothing
T5b test_check_linked_values_reads_its_own_links own links = [[copy("links", "OTHER_9")]]: 'links' on need 'GATE_OWN'
T6 test_variant_condition f1 declared before band (matched) and after (unmatched): identical warning; f1 named twice in the variant, named once
T6b …reads_only_the_expressions_it_evaluates a condition after the first true one is not read; a needs_variants name reads its expression's names
T7 test_a_call_set_by_needextend_is_a_computed_value LIT_3 :hours: 1 + needextend … [[copy("h0")]]: LIT_3 is named (total 3.0 reads the pre-extend value)
T8 test_a_failing_call_still_says_what_it_read both warnings, derive_unresolved first; with the subtype suppressed only needs.dynamic_function remains
T9 (4) authored reads, links_from_content and an :ndf: of a computed field report nothing; a condition name needs_filter_data supplies is not a field read; the suppressed T1 project is warning-free; the subtype is listed with its description
T10 test_the_warning_does_not_depend_on_the_build_history T1 built twice on one app, index.rst re-read: value flips "" → "Middle", warning list identical
T10b test_a_filter_on_a_computed_field_does_not_decide_the_warning calc_sum("hours", "summary == 'done'") and a filtered check_linked_values over TGT_F (computed summary and hours), built twice: the filter keeps TGT_F in one build only (total 0.0 → 3.0), and the two warnings are identical in both. Against the previous code, CLI builds gave 0, 2, 0 warnings for scratch, re-read reader page, re-read target page
T11 test_ubcode_fixture ubCode's dynamic_functions_unresolved fixture as tests/doc_test/doc_df_unresolved/ (hours' default moved out of its schema; NEED_ATTR dropped): (reader, option, name, needs) set equals ubCode's 12 findings; all 12 message heads are byte-identical to ubCode's
T12 (6) …user_function_is_called_without_the_record / …builtin_a_user_function_calls_is_not_reported / …user_wrapper_of_a_builtin_is_not_handed_the_record / …builtin_called_after_the_pass_notes_nothing / test_reads_written_in_a_call_is_an_error / …take_the_record_only_as_a_keyword_defaulting_to_none a needs_functions entry with the plain signature (no **kwargs) resolves in the pass with no warning; a copy a user function calls reads CHAIN_B's unresolved summary and is not reported; :ndf: roles, a :style_row: and a direct execute_func call on computed values render the final values and report nothing; a functools.wraps(copy) wrapper with a narrower signature resolves ('NARROW') and a forwarding one is not reported (the mark names its own function, so a copied __dict__ does not carry it); copy("title", reads=1) in a field and in a role each fail that call with needs.dynamic_function, the build completing; reads is keyword-only with a None default on all three built-ins

Mutation proofs (each applied to the committed code, the new file run, reverted):

mutation red
M1 "not yet visited": skip the note when the pass already wrote that field T1 target-earlier, T1 -j, T5, T6 f1-first, T6b, T10, T11
M2 report only when the call succeeded T8, T11
M3 do not note links_only's own links T4 own-links, T11
M4 drop the subtype from WarningSubTypes only the listing test; poe typecheck (2 diagnostics); the suppress tests stay green (Sphinx filters on the string)
o8 drop check_linked_values' own-links note T5b
c4c copy(filter=) notes the first match (result[0]) instead of the one it copies T2b
calc_sum notes the summand after its filter again T10b
check_linked_values notes the target after its filter again T10b
r1 drop @records_reads from calc_sum T3 (both)
r2 drop @records_reads from copy T1 (all three)
r3 hand the variant path reads=None T6 (both)
r4 hand reads= to every function, marked or not T12 user-function (shout() got an unexpected keyword argument 'reads'), and the links_from_content negative of T9
r5 read the mark off the timing wrapper instead of the registered function every reporting test (17): functools.wraps copies the mark's name, not its identity
r6 a shared module-level record as the built-ins' default the keyword-only-None signature test

No existing assertion or snapshot changed. The full suite was last run whole on master 4e3db3b plus the first commits
(2127 passed / 13 skipped, 304 snapshots: 2094 before + the 33 new); after merging master 714123a and the
explicit-record round, the machine at hand had no graphviz dot, so its 50 render tests failed identically at the base
and at the head while everything else passed with the six new tests added — CI is the authority for the whole-suite count.

Cost, on a 10 000-need project with 100 whole-project calc_sum carriers, built with -E and timing the real
resolve_functions. Eight builds each, alternating, on a noisy machine (the base alone ranged 1305–1858 ms):
the median is 1836 ms with this change, against 1693 ms before the warning existed (+143 ms, +8.5 %);
the reviewer's session measured +17.6 % on the same point, so the cost is in the +9 % to +18 % range.
The first version of this pull request measured 2028 ms (+19.8 %) in the same session, before calc_sum
fetched the record once per call. The whole-build wall time moves by between ±0.5 % and +2.4 %. The explicit
parameter removes the per-call context-variable set/reset and the per-read lookup, so it is not slower than that.

uv run poe lint and uv run poe typecheck pass. uv run poe docs-needs was not run (its
intersphinx inventories need network access); the new docs section, the reserved-keyword note and the changelog entry
parse without docutils messages, and docs/conf.py ignores the nitpick on the record's class in the built-ins'
autodoc signatures, as it does for NeedsSphinxConfig.

Follow-ups

  • Reads made by a filter argument (copy, calc_sum, check_linked_values, links_from_content) are not reported
    (see Caveats): reporting them would make a copy with a filter on a computed field stable too.
  • A corpus both tools accept, to replace the adapted copy: the divergences the run surfaced are schema.default
    (accepted by ubCode, refused here), need.<attr> in a field value (accepted by ubCode, need refused here), a link
    list mixing a literal and a call (ubCode keeps the literal before resolution, sphinx-needs holds []), a
    needextend-set call's pre-resolution value (old value here, empty in ubCode), and the default needs_id_regex
    refusing GATE.

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

claude added 4 commits October 6, 2026 15:36
Dynamic functions and variants are resolved in one pass, need by need, in
the order the needs reached the environment, so a `[[…]]` or `<<…>>` that
reads a field another one computes sees the computed value or the
unresolved one depending on document names, the documents the last build
re-read, and `-j`. Nothing reports it today.

These tests pin a `needs.derive_unresolved` warning for such a read, once
per reading call, at the reading need: a `copy` of another need's field
and of the need's own, a whole-project and a `links_only` `calc_sum`
(including the need's own computed `links`), `check_linked_values` up to
its early exit, and variant conditions up to the first true expression.
A read is reported when the field carries a `[[…]]`, `<<…>>` or `<{…}>`
after `needextend`, whether or not the pass had reached it: each case is
built in both layouts, and an incremental build that flips the value read
leaves the warning unchanged. A call that fails because of what it read
gets both warnings, and each subtype is suppressed on its own. Authored
values, `links_from_content`, an `ndf` role and a condition name supplied
by `needs_filter_data` are not reported. ubCode's
`dynamic_functions_unresolved` fixture, adapted where sphinx-needs refuses
it, gives ubCode's findings.

All but the four negative tests fail against this commit's code.
A `[[…]]` or `<<…>>` that reads a field which itself carries a `[[…]]`,
`<<…>>` or `<{…}>` is now reported as `needs.derive_unresolved`, once per
reading call, at the reading need's location. The resolution pass writes
each result into its need as it goes, in the order the needs reached the
environment, so such a read sees the computed value or the unresolved one
depending on document names, on what the last build re-read, and on `-j`.

A read is reported when the field read is a key of the read need's dynamic
fields after `needextend` (`NeedItem.carries_dynamic_value`, a read-only
accessor over a set the pass never clears), not when the pass has yet to
reach that need: the second would make the warning flip with the build
history, and a `-W` build go red or green by which file was edited last.
It is also ubCode's rule, so both tools report the same reads.

`resolve_functions` opens a private record (a context variable) around
each dynamic function call and each variant, and reports it once the call
is over, whatever its outcome, so a call that fails because of what it
read also says what it read. The built-ins note their reads into it:
`copy` its source field, `calc_sum` each summand and, with `links_only`,
its own `links`, `check_linked_values` its own `links` and each target it
compares until it stops, and a variant the fields of its own need each
evaluated condition names (not a name `needs_filter_data`, `build_tags` or
`var` supplies). Outside the pass no record is open, so an `ndf` role, a
`:style_row:` and user functions record nothing. A `filter` argument's
reads are not reported.

The message keeps ubCode's words up to the reads and ends with a clause
that holds whether or not the value had been computed yet. The new
subtype is listed with the build warnings, so `suppress_warnings =
["needs.derive_unresolved"]` silences it alone. The documentation of
dynamic functions describes the warning, and the changelog entry goes
under `Unreleased`.
A `filter` is evaluated on values the pass may or may not have computed
yet, so a filter that reads a computed field keeps or drops a need by the
order the needs are resolved in. `calc_sum` and `check_linked_values`
noted a summand or target only after their filter kept it, so a filtered
sum over a computed field warned in one build and not in the next: on a
two-page project, no warning on a scratch build, one after re-reading
the reader's page, none again after re-reading the target's. Both now
note the read before the filter: a filtered sum names every need it
considers whose value is computed, kept or not, since its result depends
on them through the filter anyway. A new test builds that project twice
and asserts the same two warnings both times.

Two read sites had no test: `check_linked_values` reading the need's own
computed `links`, and `copy(filter=)` reading the lowest-id match it
copies from rather than the first match in read order. Both have one now.

`calc_sum` fetches the call's record once per call (`_open_reads`) rather
than once per summand, and skips every note outside the pass.

The documentation says what a filter on a computed field still decides
(a `copy` with such a filter can be reported in one build and not in
another, and the needs a message names can vary after the first computed
value a call reads), and that the reads your own functions make
themselves are not reported, while a built-in they call during the pass
is, under your function's name.
…en builds

The caveat in the dynamic-functions docs names check_linked_values beside
copy: a check that stops at an authored target its filter kept, before it
reaches a computed one, is reported in one build and not in another, as the
review measured. The fence test's docstring is limited to its own layout.
@github-actions github-actions Bot added the pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs label Oct 6, 2026
@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.65217% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.47%. Comparing base (714123a) to head (46cfd37).

Files with missing lines Patch % Lines
...hinx-needs/src/sphinx_needs/functions/functions.py 94.73% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2080      +/-   ##
==========================================
+ Coverage   92.39%   92.47%   +0.08%     
==========================================
  Files         136      136              
  Lines       19320    19394      +74     
==========================================
+ Hits        17850    17934      +84     
+ Misses       1470     1460      -10     
Flag Coverage Δ
ai-index 100.00% <ø> (ø)
codelinks 95.35% <ø> (ø)
mounts 94.26% <ø> (ø)
pytests 91.88% <95.65%> (+0.13%) ⬆️
reports 88.01% <ø> (ø)
ub-test-reports 90.16% <ø> (ø)

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 added a commit that referenced this pull request Oct 6, 2026
…epends on earlier extends (#2083)

## Summary

`needextend` directives were applied in `(docname, lineno)` order only,
and each filter was evaluated against
the needs as the extends applied before it had left them.
So which `:status:` won a conflict depended on file names,
and renaming a file could change which needs a filter reached:
in a project where `b.rst` holds `needextend:: TGT_1 :status: closed`
and then `needextend:: status == "closed"`,
that filter tags `TGT_1`, while the identical filter in `a.rst` matches
nothing.

- **`:extend_priority:`**, an integer of 0 or more, default **500**;
extends are applied sorted by
  `(extend_priority, docname, lineno)`, **lower first**.
That is Sphinx's own convention for event handlers (`app.connect(event,
handler, priority=500)`: lower runs first,
default 500), so on a conflicting `:status:` the higher priority is
applied last and wins,
  and `+tags` values append in ascending priority.
A project that never sets the option keeps exactly today's order (pinned
by a `needs.json` snapshot recorded
  before the change).
- *Why not `priority`*: a `needextend`'s options are field names (the
directive has a catch-all option spec),
and `priority` is a field projects define (this package's own schema
guide and changelog examples declare one),
so `:priority:` on a `needextend` would be ambiguous with setting that
field. The `extend_` prefix avoids
the collision. (The issue proposed `extend_order`; the name follows
Sphinx's `priority`.)
- *Why not default 0*: with lower-first, a default of 0 leaves no room
to run anything before the unmarked extends
without negative numbers. 500 leaves room on both sides, and negatives
are refused.
- The value is parsed with docutils' `nonnegative_int`, as `max_items`
is in the view directives.
An invalid value (`abc`, `-1`, `1.5`, empty) is handled exactly as a bad
`:strict:` is:
one `needs.needextend` warning at the directive (`Invalid value for
'extend_priority' option: …`),
and nothing recorded, so that extend is not applied at all (not applied
with the default).
- Like `:strict:`, the option is popped before the options that modify
fields, so a field named `extend_priority`
(or `strict`) cannot be replaced by a `needextend`; `:+extend_priority:`
/ `:-extend_priority:` still reach it.
    Documented on the page, and pinned by a test.
- **Matching is unchanged in this release, with a dual evaluation.**
Before the first extend is applied,
the filters are evaluated against the needs as written (no copy of the
needs is needed, as `id` cannot be
extended; logging is suppressed for that pass so a filter's own errors
are still reported once).
That evaluation is done once per filter string and document (every one
reads the same unmodified needs,
and only `c.this_doc()` depends on the document). Suppressing the
warning silences it through Sphinx's own
filtering; the evaluation itself is not skipped (a skip keyed on
`suppress_warnings` was tried and dropped as
not worth a parameter and a hand-rolled check for a one-release notice).
The apply loop then evaluates each filter against the live needs, as
today,
and where the two match sets differ, the extend is reported once, at its
location, as the new
  **`needs.needextend_match_order`**:

  ```
b.rst:7: WARNING: the needs matched by this needextend depend on
modifications applied by earlier needextend directives: it matches 1
need (TGT_1) now and 0 against the needs as written; from the next
release filters are evaluated against the needs as written, before any
needextend is applied [needs.needextend_match_order]
  ```

Both sets are named in need-id order (plain string order), the first
three ids and a count of the rest.
  Id-targeted extends are never compared: their target is fixed.
**The next release flips matching to the needs as written**, so the
reported extends are exactly the ones
whose reach will change. `:extend_priority:` orders the modifications;
it is not a way to choose what a filter
matches. In this release a filter sees the changes of the extends
applied before it, so a priority can change
its matches, and such an extend is reported; from the next release it
never does
(the issue comment's "does not affect which needs a filter matches"
describes the semantics after the flip,
not this release, and the docs, the changelog and the subtype's
description now say which is which).
- **`ENV_DATA_VERSION` 8 → 9**: `NeedsExtendType` gains
`extend_priority`, which changes the pickled environment.
Measured: a rebuild over a `_build` written before this change ends with
exit 2 (`KeyError: 'extend_priority'`,
surfaced from `build_needs_json`) at 8, and re-reads every document and
exits 0 at 9.
- **Cost**, measured on a generated project (10 000 needs over 20 pages,
100 filter-targeted and 100 id-targeted
extends, `-b needs -E`, the subtype not suppressed, two runs each;
"without" stubs the as-written evaluation out):
- 100 **distinct** filter strings: `extend_needs_data` 6.6 / 6.9 s →
10.9 / 10.7 s (+3.8–4.3 s, +54–65 %;
100 as-written evaluations, 3.6 s), whole build 21.2 / 22.1 s → 25.6 /
25.1 s (+3.0–4.4 s).
- 100 copies of **one** filter string: one as-written evaluation (20–22
ms); `extend_needs_data`
3.2 / 3.0 s → 2.8 / 2.7 s and whole build 17.8 / 18.0 s → 17.5 / 17.2 s,
i.e. no measurable cost.
So the cost is one filter evaluation per distinct filter string and
document, for the one release that
  carries the notice.
- **Docs**: the `needextend` page gets an `extend_priority` subsection
(the sort key, the direction, the default,
an example where a `:status:` set at 600 wins over one set at 400
whatever the file order, the shadowed field)
and a "Filters and earlier needextend directives" section (the dual
evaluation, the notice for the next release,
`suppress_warnings = ["needs.needextend_match_order"]`). Both carry
`versionadded:: 9.0.0`, as the pages already do
for this release; **release writer: check the number**, and the wording
"the next release" in the warning text
  and the docs.
- **Changelog**: `Unreleased` → `Improvements`, marked **(changed
output)**: a project building with `-W`
that has an order-dependent filter goes red until the filter is
rewritten or the subtype is suppressed,
with `"needs.needextend_match_order"`: `"needs.needextend"` in
`suppress_warnings` does not cover it
  (Sphinx matches the subtype exactly).

Closes #1658; part of #2064 (fifth item). Independent of #2080 and
#2081; it touches two sentences of
`docs/directives/needextend.rst` and `docs/dynamic_functions.rst` that
#2081 also edits ("document-name and line
order" → "extend priority, then document-name and line order"), and the
changelog `Unreleased` list: whichever
lands second resolves those by hand.

## Tests

New file `packages/sphinx-needs/tests/test_needextend_priority.py`
(inline `test_app` projects, exact
`build_warnings(app) == [...]`), committed first and run against the
unchanged code: 13 of its 16 items failed,
and the 3 guards passed. The review round added a priority-0 extend to
T3 and a test for the memo (17 items).

| test | what it pins | before the change |
|---|---|---|
| T1 `test_without_the_option_needs_json_is_unchanged` | no option
anywhere: the whole `needs.json` equals the snapshot recorded before the
change; no warning | green (guard) |
| T2 `test_higher_priority_is_applied_last[serial]` | `a.rst` 600
`late`, `b.rst` 400 `early` → `late` | `'early' == 'late'` |
| T3 `test_appends_follow_ascending_priority` | `+tags` at 600, 0, 400
and the default → `p0, p400, p500, p600` (0 runs first, it is not
"unset") | `['p600', 'p400', 'p500']` (before the 0 was added) |
| T4 `test_equal_priorities_keep_document_order` | an explicit 500 and
no option tie, `(docname, lineno)` decides; every recorded extend
carries 500 | unknown-option warning |
| T5 `test_invalid_priority_is_reported_and_not_applied[abc, -1, 1.5,
empty]` | the exact warning, the need untouched, nothing recorded |
unknown-option warning, and applied |
| T5b `test_priority_option_shadows_a_field_of_that_name` |
`:extend_priority: 7` sets the priority; `:+extend_priority:` still
appends to the field | `'7' == 'authored appended'` |
| T6 `test_filter_depending_on_an_earlier_extend_is_reported[serial]` |
the recon project as it is: exactly one warning, at `b.rst:7`;
`needs.json` values as today | no warning |
| T7 `test_no_warning_where_the_matches_agree` | a filter on a field no
extend changes, and id-targeted extends after other extends: no warning
| green (guard) |
| T7 `test_match_order_warning_is_suppressed_alone[reported,
suppressed]` | `suppress_warnings` silences the subtype and not the
other `needextend` warning | `[reported]` red; `[suppressed]` green
(guard) |
| T8 `test_needs_as_written_ignore_an_earlier_priority` | a priority-100
extend in `z.rst` closes REQ_1 to REQ_5 before the two `status` filters
at 500 in `a.rst` (which sorts first): both warn, `6 needs (REQ_1,
REQ_2, REQ_3 and 3 more) now and 1 (REQ_6)` and `0 needs now and 5 (…)`;
the needs are written in reverse id order | unknown-option warning, no
match-order warning |
| T9 `[j2]` variants of T2 and T6 | identical results under `-j 2`
(padded to 7 documents, since Sphinx 7 reads in parallel only above 5) |
red, as their serial twins |
| `test_identical_filters_are_evaluated_once_per_document` | the recon
project plus a second identical filter in `b.rst`: both `b.rst` filters
reported at their own lines; the as-written evaluations are exactly
`(filter, "a")` and `(filter, "b")` | (review round) red without the
memo: three evaluations |

Mutation proofs (each applied to the finished code, the new file run,
the code restored):

| mutation | red |
|---|---|
| M1 sort key without `extend_priority` | T2 serial and j2, T3, T8 |
| M2 the "as written" sets evaluated inside the apply loop (the live
needs) | T6 serial and j2, T7 `[reported]`, T8 |
| M3 default 0 instead of 500 | T3, T4, T8 |
| M4 an invalid value falls back to the default instead of skipping |
T5, all four |
| `:extend_priority: 0` treated as unset (`nonnegative_int(...) or
DEFAULT_EXTEND_PRIORITY`) | T3 (`['p400', 'p0', 'p500', 'p600']`) |
| the memo removed (one evaluation per extend) | the once-per-document
test only |

No existing assertion or snapshot changed: before `master` was merged
in, the full suite was 2102 passed / 13
skipped (2084 + 18 new; one of those went with the dropped skip, so the
file now holds 17), 305 snapshots passed
(304 + the new one); after the merge the needextend test files, `uv run
poe lint` and `uv run poe typecheck` pass.
`uv run poe docs-needs` was not run (it creates its own environment);
the whole docs tree was built with the
package's development environment and stand-ins for three docs-only
extensions, with no warning
(nitpicky included), and the new example renders `late` with
`applied_first, applied_second`.

## Follow-ups

- **The flip**, in the next release: apply each filter-targeted extend
to the needs it matched as written
(the sets this change already computes), and retire or reword
`needs.needextend_match_order`.
One case is not reported now: a filter whose evaluation raises against
the needs as written but not live
(in practice only a non-bool result on the fast path) is not compared;
after the flip it would be an
  `Invalid filter` warning.
- **ubCode** mirrors `:extend_priority:` (same default, direction and
sort key) and, if it evaluates filters
  against the mutated needs too, the same notice.
- **`options.popitem()`** records one directive's options in reverse
written order. The same key twice is refused
by docutils (`duplicate option`), but different keys on the same field
are applied in reverse, measured:
`:-tags:` then `:+tags: new` gives `[]` (written order would give
`['new']`), and `:status: replaced` then
`:+status: appended` gives `replaced` (written order: `replaced
appended`). It is within one directive, so the
priority does not touch it; applying in written order would be a change
of output of its own.
claude added 4 commits October 6, 2026 16:59
…lved branch

# Conflicts:
#	packages/sphinx-needs/docs/changelog.rst
The record of a call's reads of same-pass values was ambient state: a
module-level ContextVar the built-ins reached through `_open_reads` and
`_note_read`. It is now a keyword-only parameter. `resolve_functions`
opens an `UnresolvedReads` for each call and each variant condition and
hands it down: `_execute_dynamic_func` passes it as `reads=` to a
function marked `@records_reads` (`copy`, `calc_sum`,
`check_linked_values`), the mark read off the function as registered,
and `_get_variant` notes a condition's names into it. Every other
function is called exactly as before, never with `reads`. `execute_func`
passes none, so a built-in called by an `ndf` role, a `:style_row:` or a
user's own function notes nothing. What is noted is decided in one
place, `UnresolvedReads.note_read`.

The rule, the message, the subtype and where the warning is reported are
unchanged. One consequence is documented: a built-in that a user's
function calls during the pass is no longer reported under that
function's name, since no record reaches it. A `reads=` written in a
call fails that call with the usual `needs.dynamic_function` warning.
New tests pin that a user function is called without the record, that a
built-in it calls is not reported, that a built-in called after the pass
notes nothing, and the reserved keyword.
The `records_reads` mark was `True` in the function's `__dict__`, which
`functools.wraps` copies, so a user's function made with
`functools.wraps(copy)` was marked too: called with `reads=` in the
pass, it failed if it took no `**kwargs` (it resolved before the
refactor), and one that forwards its keywords had its read reported
under its own name. The mark now names the function it is set on, and
`_execute_dynamic_func` checks that it names the function as
registered, so a copied mark marks nothing. A test pins both shapes.

A user's function calls a built-in inside the pass; it simply has no
record to pass on, so the built-in's read is not reported. The
docstrings now say so rather than calling it a read after the pass, and
say that one record serves a whole `<<…>>` variant, shared by the
conditions it evaluates. The reserved `reads` is worded as "do not give
it in a call (doing so fails the call)". A test fences the built-ins'
`reads` as keyword-only with a `None` default, so a shared default
record cannot come back. `_execute_dynamic_func`'s docstring lists its
real parameters.
@chrisjsewell
chrisjsewell merged commit f3a3131 into master Oct 7, 2026
43 checks passed
@chrisjsewell
chrisjsewell deleted the claude/magical-hopper-uw9j1q branch October 7, 2026 09:56
chrisjsewell pushed a commit that referenced this pull request Oct 7, 2026
The three source and test files that both sides changed take master's
versions (the explicit-record refactor and its review fix); the one
section that both sides placed, the needs.derive_unresolved
subsection, is kept once, under Processing order as this branch
intended, with master's final wording.
chrisjsewell added a commit that referenced this pull request Oct 7, 2026
Stacked on #2080 (its branch is this pull request's base, so the diff is
the one docs commit; GitHub retargets it to `master` when #2080 merges).

## Summary

The documentation stated one barrier between the stages that run once
every document is read:
a dynamic function cannot read back links ("Restrictions /
incoming_links" in `docs/dynamic_functions.rst`).
Everything else about the order was undocumented, and three of its
effects surprise users:

- a `needextend` filter never sees the result of a `[[…]]` or `<<…>>`:
`needextend:: late == "D"` matches nothing
  although the need's `late: [[copy("title")]]` resolves to `"D"`;
- the order of a need's options is irrelevant: with `early` declared
before `late` in `needs_fields`, a need that
writes `:late: [[copy("title")]]` before `:early: [[copy("late")]]`
still computes `early` first, which reads
  `late` unresolved (`"None"`);
- a variant condition sees only the fields of its own need computed
before its own field:
`:band: <<[early == "E"]:hit, miss>>` gives `miss` and the identical
`:after:` gives `hit`,
  because `band` is declared before `early` and `after` after it.

This replaces that paragraph with a **Processing order** section (label
`needs_processing_order`) that lists the
four steps, each checked against the code and against a probe project
built on this branch:

1. `needextend`, applied in (document name, line) order; each filter
sees the needs as written plus the earlier
extends' changes, never a `[[…]]`/`<<…>>`/`<{…}>` result; an extend may
set a field it can modify to a call,
   evaluated in step 2;
2. `[[…]]`, `<<…>>` and `<{…}>`, need by need, and within a need in the
fixed field order whatever the option
order, a field a `needextend` turned into a call last; a whole-project
`calc_sum` and `copy(filter=…)` take the
needs in need-id order; reads of a value computed in this step point to
the `needs.derive_unresolved` subsection,
   which becomes a child of the new section;
3. back links, link conditions, unknown links, so no call can read a
back link (the old restriction, kept);
4. constraints; then the needs are frozen, and schema validation, every
page and `needs_warnings` at the end of the
   build see the final values.

Also:

- `docs/directives/needextend.rst` says that extends are applied in
document-name and line order and that a filter
sees the earlier extends' changes, linking to the section; its claim
that `needextend` "can modify all
string-based and list-based options" is corrected: it modifies `status`,
`tags`, `style`, `layout`, `hide`,
`collapse`, and every extra field and link option whatever its type (a
number or boolean field included), and
  `+option` takes only string and list options;
- the tutorial's "Resolve" step links to the section;
- changelog: `Unreleased` → Improvements.

Part of #2064 (third item); builds on the need-id order PR and the
`needs.derive_unresolved` PR, whose semantics it
documents.

Docs only, no tests. `uv run poe lint`, `uv run poe typecheck` and the
two dynamic-function test files pass; the
changed pages parse with docutils (Sphinx-only roles and directives
stubbed) without warnings or errors, and every
`:ref:` they use resolves to a label. `uv run poe docs-needs` was not
run locally (its intersphinx inventories need
network access); the docs CI job is the authority.

## Follow-up

- #2075: `constraints` set by a `needextend` or by a `[[…]]` is silently
dropped: `NeedItem.__setitem__` checks that the
constraint results are not computed yet and then never stores the value.
The `needextend` page therefore does not
  list `constraints` among the options it modifies.
chrisjsewell pushed a commit that referenced this pull request Oct 8, 2026
…ut of scope (sphinx-needs)

dynamic_functions.rst: the processing order is rewritten for the strata
(needextend, link fields, back links, every other field, checks), with what
each built-in reads, the order a set and a link list are read in, user
functions last, and two new sections, Cycles (needs.derive_cycle) and Reads
that cannot be ordered (needs.derive_scope), which replace the one on
needs.derive_unresolved; predicates filters are not reported yet. The
need.<field> argument form is documented with the rule that a field
selecting what a call reads must be final first. needextend.rst: a filter
naming a computed field is needs.derive_scope.

changelog: the #2080 and #2081 entries become the 9.0.0 statement of the
ordered pass, with needs.derive_cycle and needs.derive_scope, need.<field>
admitted and a None result adding nothing, and a breaking-change entry says
what moves for a project.
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