Skip to content

✨ Filters follow inherits: c.is_a('req') selects a type and the types inheriting from it - #2401

Merged
chrisjsewell merged 9 commits into
masterfrom
claude/vibrant-allen-o22lej-ancestors
Oct 30, 2026
Merged

chrisjsewell merged 9 commits into
masterfrom
claude/vibrant-allen-o22lej-ancestors

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

What

packages/sphinx-needs.

  • c.is_a('req') is true for a need whose type is req, or a type inheriting from req, directly or through other types,
    as the project's need type declarations give inherits (once checked: an inherits naming no need type, or on a cycle, is
    ignored with needs.type_inherits, and its type is a root). With sys_req inheriting req and hw_req inheriting sys_req,
    c.is_a('req') selects all three, as type in ['req', 'sys_req', 'hw_req'] does; type == 'req' still compares the name alone.
    An unknown name is false on every need, unreported; a type is named by its directive name, never its title.
  • It is a check beside c.this_doc(), in the portable filter language with a string literal as its argument (the fast tier
    answers it), and in the eval tier for any other shape (call:c.is_a). It reads the need alone, so a filter configured in
    conf.py and a derived field's where take it; like c.this_doc(), a needimport :filter: and a needs_fields entry's
    predicates do not (documented).
  • No field holds a need's types. c.is_a reads the need's own type and the declarations: create_schema computes, once
    per build, each type with itself and every type inheriting from it (the map target_types already reads), and sets it on the
    configuration as prepare_env sets the var proxy. c.is_a('<literal>') compiles, at parse time, to the membership of the
    need's type in that set. The compiled filter is cached per string and shared by every project a process builds, so the set is
    passed per call (as c.this_doc()'s document is) rather than compiled in; both tiers answer through one function.
  • The docs say, for projects that model an ontology: c.is_a tests the need's type; to follow a link type named is_a, use the link.
  • ENV_DATA_VERSION 14 -> 15 (above ✨ Every need records where its text came from, as hops on its source (provisional; nothing shown changes) #2399's 14), on the semantics axis as version 12 was: c.is_a('req') was an invalid filter and a refused
    derive where in 9.1.0, so an upgraded build over an existing _build would keep the empty selections and values on the
    pages whose sources did not change.

Why

Closes #2322.

target_types follows inherits; until now no filter could: a filter or a derived field's where had to list every subtype by hand.

An earlier revision of this pull request also added a core field type_ancestors (the type, then each type it inherits from).
The maintainer has ruled that the field goes: a core field name is permanent public API on four surfaces (the need, needs.json,
filters, schema rules) and a name a user's own field can collide with. c.is_a stays, re-defined by the declarations. The listed
form of the lineage is deferred to the facets work, which will settle its name, whether it includes the type itself, and whether it
is a field or a type. facet. Two general fixes found on the way (the contains-without-items schema fix and a warning for a
needs_fields entry naming a computed core field) moved to a pull request of their own.

Merge note. ENV_DATA_VERSION is 15 here, above the 14 that #2399 took on master. The incremental-build pull request also
claims 14 and renumbers when it lands (to 16 if it lands after this one). #2399 needed nothing beyond the textual merge: it adds no
field or filter name c.is_a reads. An extension's type given inherits in needs_types (#2400) is selected by c.is_a like any other.

Contract. Filter corpus: case is-a-follows-inherits (17 entries: a two-level chain hw_req -> sys_req -> req, each level,
a type with no inherits, an ignored inherits, an unknown name, a title, the exact type, parts, no document, the eval shapes;
tiers pinned), and a header line saying an inherits naming no need type is each tool's own, uncompared finding (the runner's
tolerance is narrowed to that message). Derive corpus: case where-follows-inherits (c.is_a in a where, an unknown name, the
exact type), plus one header line on bare type entries. ubCode mirrors c.is_a in its own pull request and re-vendors both corpora.

Tests

Checklist

  • I wrote this change myself and have read every line of it; it was not generated automatically from an issue.
  • I ran the package's tests (uv run poe test-needs) and they pass.
  • Documentation is updated where behaviour or options change.
  • The package's docs/changelog.rst has an entry under Unreleased.
  • uv run poe lint and uv run poe typecheck pass.

…n follow `inherits`

`type_ancestors` is a core field modelled on `type_name`: the need's type, then
each type it inherits from, nearest first, through the parents `inherits` leaves
once it is checked (an `inherits` naming no need type, or on a cycle, is dropped,
so its type is a root). `FieldsSchema` holds the parent map (`set_type_parents`,
told once by `create_schema`; `type_ancestors(type)` answers, a type with no
parent answering itself alone). The field is read-only (no directive option, no
needextend, immutable on the need), left out of needs.json by default
(`exclude_json`, opt in through `needs_json_exclude_fields`) and recomputed for
imported and external needs.

`"req" in type_ancestors` is in the portable filter language with no grammar
change and is answered by the fast tier; it reads the same in a derived field's
`where`, a needextend filter and a needtable column. A schema rule sees it in its
`local` properties and, now, in its `select` (it was not among the fields a
select could read). An array field's schema rule may give `contains` without
`items`, which stopped the build with a raw schema error before (tags too).

Corpus: filter case `type-ancestors-follow-inherits` (10 entries; the header now
says an `inherits` naming no need type is each tool's uncompared finding, and the
runner tolerates `needs.type_inherits`); derive case `where-follows-inherits`,
appended at the end. The name is reserved as every core field name is.
ENV_DATA_VERSION 14: stored needs gain the key.

Refs #2322
…m it

`c.is_a("<type>")` is `"<type>" in type_ancestors`, spelled as a check beside
`c.this_doc()`. The portable tier compiles it with a string literal as its one
argument (the `is_a_check` arm in ubquery.py, reading the need's own
`type_ancestors`); any other shape (`c.is_a(type)`, `c.is_a()`,
`c.is_a('req') == True`) is refused there with the reason `call:c.is_a` and
falls to eval, as the `c.this_doc()` arms do. The eval tier's
`NeedCheckContext.is_a(name)` answers the same. A name that is no need type
selects nothing, unreported. It reads the need alone, so it works with no
document (conf.py filters) and a derived field's `where` accepts it in that one
shape (`filter_problem` still refuses every other `c.` check);
`filter_names` says it reads `type_ancestors` rather than `docname`.

Corpus: 10 entries appended to `type-ancestors-follow-inherits` (tiers pinned:
fast for the literal forms, eval with reason `call:c.is_a` for the rest, one
with no document, one with parts); the derive case `where-follows-inherits`
gains a `c.is_a('spec')` count.

Refs #2322
…n ignored core field, pin order and reads

Review follow-ups for #2322.

Docs and changelog: neither `type_ancestors` nor `c.is_a()` is available in a
needimport `:filter:` (it reads the imported file's records) or in a field's
`predicates`, said as for `c.this_doc()`, in place of "wherever a filter string
is"; a project that sets `needs_json_exclude_fields` itself now writes
`type_ancestors` unless it lists it; code building a `NeedItem` core or a
`NeedsInfoType` must give the key; a `needs_filter_data` key of that name shadows
the field but not `c.is_a`.

create_schema reports a `needs_fields` entry naming a core field a need computes
(`type_name`, `type_ancestors`, `docname`, ...) as ignored, with a `needs.config`
warning, where it was ignored silently.

Tests: the derive case `where-follows-inherits` copies `type_ancestors` into an
array field, so the nearest-first order of a three-type chain is in a corpus
(the derive header now names the bare-entry rule the case relies on); the filter
runner tolerates the `needs.type_inherits` finding of an `inherits` naming no
need type alone, not one on a cycle; an external need's value is recomputed;
assignment to `type_ancestors` is refused on a built need; the `items` injection
skips link types.

Refs #2322
Conflicts resolved keeping both sides: the changelog's Unreleased section (both sides' entries), the need type key list in configuration.rst (back_links and inherits), and the derive corpus' case count (91 on master, plus the branch's one case: 92).
…s` field is dropped

`c.is_a('x')` is true for a need whose type is `x` or inherits from `x`, transitively,
as the project's need types declare `inherits` once it is checked; an unknown `x` is
false on every need. No field of a need holds its types any more: the core field
`type_ancestors` is removed from the need data, needs.json, the fields schema, schema
rules, ordering and derive, and `ENV_DATA_VERSION` returns to 13 (no stored need
changes shape against master).

The parse-time handling stays: `c.is_a('<literal>')` compiles to the membership of the
need's own type in the set `create_schema` computes once per build (the type and every
type inheriting from it, the map `target_types` already reads), set on the configuration
as the `var` proxy is. The compiled filter is cached per string and shared by every
project a process builds, so the set is passed per call (`IS_A_KEY`, as `c.this_doc()`'s
document is); both tiers answer through one function, `ubquery.is_a`.

Corpora: the filter case `is-a-follows-inherits` (req <- sys_req <- hw_req, a root, an
ignored inherits, an unknown name, the exact type) replaces `type-ancestors-follow-inherits`
(21 cases / 250 filters, from 253: 20 entries replaced by 17); the derive case
`where-follows-inherits` reads `c.is_a` alone (92 cases). The schema fixtures of the field
are removed; `tags_contains_without_items` and the `needs_fields` core-field warning stay,
being general.
…announces it alone

The documentation of the `type_ancestors` field is removed from filter.rst, need_types.rst, derived_fields.rst, schema/index.rst and configuration.rst. filter.rst's section (now `filter_is_a`) defines `c.is_a` by the types' `inherits`, and says that `c.is_a` tests the need's type, while a link type named `is_a` is followed through the link. The changelog's entry announces `c.is_a` only; its breaking-changes section, which named the field, goes.
…er types, a bare `c` reads the document

ENV_DATA_VERSION goes to 14 on the semantics axis, as version 12 did: `c.is_a('req')`, an invalid filter and a refused derive `where` in 9.1.0, now selects and computes, and an upgraded build over an existing `_build` would otherwise keep the empty selections and values on the pages whose sources did not change.

A test builds two projects with different `inherits` in one process, in both orders and back, with the same `c.is_a('req')` in a needextend filter and a derived field's `where`: each selects by its own types, so the per-string filter cache leaks none.

`filter_names` counts a `c` read otherwise than through `c.is_a` (`getattr(c, 'this_doc')()`) as reading `docname` again, as master does.
…s_fields` warning leave this branch

Both are general (neither is about `c.is_a`), and move to a pull request of their own, with their tests and changelog entries: the schema rule an array property gives with `contains` alone, and the `needs.config` warning for a `needs_fields` entry naming a core field a need computes. Nothing here depends on either.
Master brought #2400 (extension need types narrowed by a needs_types entry) and #2399 (need source hops), which took ENV_DATA_VERSION 14. This branch's semantics bump is renumbered to 15, its note following #2399's version-14 note; the changelog keeps both sides' entries. #2399 adds no filter name and no need field (`need.source.hops` is an attribute), so `c.is_a` and the fast path need nothing more; a type an extension adds and a needs_types entry gives `inherits` (#2400) is followed by `c.is_a`, as `target_types` follows it.
@github-actions github-actions Bot added the pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs label Oct 30, 2026
@codecov

codecov Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.74%. Comparing base (2b1e00d) to head (f6e48ac).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #2401   +/-   ##
=======================================
  Coverage   94.73%   94.74%           
=======================================
  Files         145      145           
  Lines       25294    25323   +29     
=======================================
+ Hits        23962    23992   +30     
+ Misses       1332     1331    -1     
Flag Coverage Δ
ai-index 100.00% <ø> (ø)
codelinks 96.31% <ø> (ø)
mounts 94.27% <ø> (ø)
pytests 94.55% <100.00%> (+0.01%) ⬆️
reports 96.80% <ø> (ø)
ub-test-reports 91.05% <ø> (ø)

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 f1ec25e into master Oct 30, 2026
45 of 46 checks passed
@chrisjsewell
chrisjsewell deleted the claude/vibrant-allen-o22lej-ancestors branch October 30, 2026 01:21
chrisjsewell added a commit that referenced this pull request Oct 30, 2026
…entry naming a computed core field is reported (#2405)

## What

`packages/sphinx-needs`. Two small, independent fixes, one commit each.

- **A schema rule's property on an array field may give `contains`
without `items`.**
`"tags": {"contains": {"const": "a"}}`, in a rule's `local` properties
or its `select`, failed the meta-schema and stopped
the build. The type injection now gives such a property the field's item
type as `items`, which every value already satisfies,
so the rule applies. A link type's property takes neither and is
unchanged. Documented under the array keywords of
  `docs/schema/index.rst`.
- **A `needs_fields` entry naming a core field a need computes is
reported.** An entry such as `type_name` or `docname`
declared nothing and was ignored in silence. It is now one
`needs.config` warning, and still ignored. A core field that takes
a declaration (`status`, `tags`, …) is specialised as before. Documented
in `needs_fields`.
Nothing read such an entry, so no output changes. But **a `-W` build
that has one now fails** until the entry is removed,
  as the changelog says.

## Why

Both were found while building #2322 (`c.is_a`, #2401), and were split
out of that pull request because neither is about it.
No open issue covers either. #2199 is the link-side twin of the second
fix (a link type named after a core field stops the build
instead of being refused at load). It is related but not fixed here: the
warning added here is for `needs_fields` entries alone.

## Tests

- Red first:
`tests/schema/test_populate_field_type.py::test_field_array_contains_alone_gets_items`
and the schema fixture
`tags_contains_without_items` fail on master.
`test_link_contains_alone_gets_no_items` pins that link types are
unchanged.

`tests/test_config_unknown_keys.py::test_an_entry_naming_a_core_field_a_need_computes_is_reported`
fails on master
  (no warning).
- An independent review reproduced both red-on-master proofs and checked
a `contains`-only property in a rule's `select` by probe
  (it applies; no test pins that shape yet).
- After merging master (#2401): `uv run poe lint` 0, `uv run poe
typecheck` 0, full `uv run poe test-needs` 0
(4040 passed, 15 skipped), `uv run poe smoke-needs` 0. `docs-needs`: no
warning from this repository's pages or docstrings
(the sandbox cannot reach the intersphinx hosts, so only those
references stay unresolved there).

## Checklist

- [ ] I wrote this change myself and have read every line of it; it was
not generated automatically from an issue.
- [x] I ran the package's tests (`uv run poe test-needs`) and they pass.
- [x] Documentation is updated where behaviour or options change.
- [x] The package's `docs/changelog.rst` has an entry under
*Unreleased*.
- [x] `uv run poe lint` and `uv run poe typecheck` pass.
chrisjsewell added a commit that referenced this pull request Oct 30, 2026
…ed (#2414)

## What

`packages/sphinx-needs` (new internal module `_incremental.py`,
`_`-prefixed like the package's other new internal modules).

The needs each re-read document gives are compared, as read, with what
it gave before: an `env-purge-doc` stash and `env-get-updated`,
before the environment is pickled, so post-processing still happens
after the pickle. On a change, Sphinx re-writes (never re-reads):

- every view page (needtable, needlist, needflow, needgantt,
needsequence, needpie, needbar, needextract, needuml/needarch,
needreport, need_count, ndf);
- the pages of the affected needs: changed, named by a changed
id-needextend, or computing a value from an affected need (a dynamic
function read by its arguments,
whether written or set by a needextend; a derive rule over its links,
needextend-added ones included, its back links or `from`), to a
fixpoint;
- the pages of their link targets, old and new, and of the needs linking
to them;
- every page whose need role names one.

What a computed value reads is decided by an **allow-list**: a value
counts as reading only its own need when every argument is statically
known
to read that need's own fields; anything else (variant conditions,
functions of your own, opaque filters, any field other needs decide,
such as back
links, dead-link flags or constraint results) counts as reading every
need. Every core field is classified, and a test fails on an
unclassified new one.
A need that only moved (lines added above it) re-writes the views and
the pages whose roles name it, and only when nothing anywhere reads a
line number;
prose below the needs re-writes nothing more. Projects with links
computed to needs not named before resolution (a dynamic function in a
link field or a
needextend's links, a `links` rule, `content_links` with `from`)
re-write every need-relevant page on each change, as does a changed
filter-needextend.
The rules err toward re-writing a page, never toward a stale one.

Views without a sort now list needs in document order on every build (an
incremental build used to list re-read needs last). `ENV_DATA_VERSION`
is 16
(master's 14 is the hops on a need's source, #2399; its 15 is `c.is_a`,
#2401).
Other open branches also claim 16 (#2398's fix, the source-hops slice
3a): whichever lands first keeps it, each later one renumbers at its
merge of master and keeps every version paragraph.
Docs: a page on incremental builds, saying exactly which pages are
re-written and when the fallbacks fire.

### Master's newer readers, checked against the rules

Merged with master after #2383–#2406. Each new reader is covered, each
by a scratch-vs-incremental case (or, for schema findings, a test of the
incremental build's findings), and where a rule of this PR carries it, a
mutation of that rule turns the case red:

- **`c.is_a('x')`** (#2401) reads the need's type and the need types'
`inherits`. In a filter of a rule or a call it reads the candidates'
types, which the
rules already follow (a type is a field the need's own document gives);
a needtable, needextend or any view using it is re-written or re-applied
as before.
An `inherits` edit is a change of the need types, which re-reads every
document (`reread_on_need_types_change`, #2287) — also when it is made
in
`ubproject.toml` alone. Case: `inherits-changed` (toml only: `req`
declares a field with a default, which `spec` takes once it inherits
`req`, so the
case goes red with that re-read switched off; a needtable and a
needextend filtering on `c.is_a`, a count `over links where c.is_a(...)`
copied by a
  need on another page).
- **Per-type back-link counts and severity** (#2395) are schema
findings, which are validated over every need on every build: removing
the one link to a
need on another page reports it on the incremental build exactly as on a
fresh one (pinned by a test). Per-type `id_regex` is matched at read
time,
  on the need's own document, and is a need-type change too.
- **Derive `where` beside `copy`, `empty` on `any`/`all`** (#2384): a
`where` narrows the needs the rule already reads (`over` links), and
`empty` is a
literal. Case: `rule-with-where-and-empty-read` (rules declared on one
need type alone in `ubproject.toml`, #2400, read two hops away).
- **Source hops** (#2399): nothing renders from them (`need.source.hops`
is provisional; the legacy keys derived from them are classified core
fields).
They do record a need's lines, so the moved-only comparison now leaves
out each hop's start and end line, and the source classes' private line
field:
after the merge it crashed (`NeedItemSourceDirective.__init__() got an
unexpected keyword argument '_lineno'`) on every build in which a need
moved.
Cases: `prose-above-included-need` (a need written in an included file,
which its hops record), `prose-above-imported-need` (an imported need,
whose line is kept beside its hops) and
`need-moved-to-another-included-file` (the same text from another file
is a changed need), each with the
  exact set of pages it writes.
- **`create_need`** (#2406): its needs and sources are compared as any
other; it adds no reader. A source an extension passes that is not a
dataclass
and has no value `__eq__` compares by identity, so its need counts as
changed on every re-read of its document: pages re-written, never stale.
- **Extension types and fields per type** (#2400): declared by the
configuration (a need-type change re-reads every document) and read
through the
schema the rules already ask (`rule_for(type)`); a type-local derived
field is covered by the case above.

### For the source-hops work

The moved-only comparison (`_without_lines` in `need_item.py`) reads the
source classes' private `_lineno` and rebuilds #2399's provisional hop
dataclasses from outside `_need_source.py`: a new field there fails safe
(a moved need counts as changed), a custom `__init__` on a hop class
would
raise (the incremental suite goes red); the clean seam is a method on
`HopsSource` / `NeedSourceHop` giving that key, left to that work.
The source-hops slice 3a (one frozen `NeedItemSource` in place of these
classes) was merged onto this branch in a scratch clone by that work's
reviewer: `_without_lines` works unchanged there and these tests pass;
whichever of the two lands second moves the comment to the new seam.

## Why

Fixes #2376, fixes #346. Related: #442.

Editing a need's title and rebuilding incrementally re-wrote only the
edited page: every needtable/needflow page, every `:need:` role, the
need's link
targets (back links) and the needs linking to it kept the old values
until a full build, as did the needs a needextend changed or stopped
changing.

Not covered: a changed `needs_external_needs` file (#353), and moving a
need between two documents in one incremental build (pre-existing,
identical
before this change; #2391).
A `:need_outgoing:` / `:need_incoming:` naming a need that does not
exist ends even a fresh build (#2413); on master an incremental build
left
that page stale and green, with this change it is re-written and fails
the way a fresh build does.

## Tests

- A scratch-vs-incremental equivalence oracle: every page on disk after
the incremental build equals a fresh build's, over the fixture's
scenarios
(several also at `-j 2`), plus precise-set tests. Two independent
reviews and four validation rounds probed 34 edit shapes; every hole
they found
is now a test, and every dependency rule fails a test when mutated.
After the merge with master: six more scenarios and four tests for
master's readers (51 tests in the module).
- Full builds at base and head are identical for 14 projects (html and
needs builders), apart from the env version in `searchindex.js`.
- Cost on a 1000-need / 211-page project: a title edit writes 14 pages
in about 1.5–2 s (before: 2 pages, the views stale; re-writing every
need page:
  211 pages, 22 s); with a field default `[[copy('title')]]` the same.
- `uv run poe lint` 0, `uv run poe typecheck` 0, full `uv run poe
test-needs` 4119 passed / 15 skipped (all 4134 collected), `uv run poe
smoke-needs` 0.
After the module's rename to `_incremental.py`: lint, typecheck and
smoke-needs 0 again, and the incremental, need-types and need-source
modules 103 passed.

## Checklist

- [x] I wrote this change myself and have read every line of it; it was
not generated automatically from an issue.
- [x] I ran the package's tests (`uv run poe test-needs`) and they pass.
- [x] Documentation is updated where behaviour or options change.
- [x] The package's `docs/changelog.rst` has an entry under
*Unreleased*.
- [x] `uv run poe lint` and `uv run poe typecheck` pass.
chrisjsewell added a commit that referenced this pull request Oct 30, 2026
master squash-merged the derive branch this one stacked on (#2384), and has since
gained per-type declarations (#2395), extension types (#2400), c.is_a (#2401), the
incremental rewrite rules (#2414) and more. Conflicts kept both sides: the changelog's
Unreleased bullets; LinkSchema's severity beside summary in the basic-doc snapshot;
a path's select default in derive.py; the derive corpus gains master's
where-follows-inherits case ahead of this branch's eleven, 103 cases.
chrisjsewell added a commit that referenced this pull request Oct 30, 2026
…s far ends as links (#2418)

Read by the data-model side before going out of draft: needs.json's
`"summary": true` marker and the exchange-contract sentence fit, and
nothing collides with its in-flight work. The ubCode side is
useblocks/ubcode#4452; this PR merges first.

## What

`packages/sphinx-needs` (+ one sentence in
`packages/ub-needs-exchange`'s exchange contract, and a reader test).

1. **The stance.** `summary = true` on a link type's `needs_links` entry
(any link type, authored or derived) says its links materialise a path,
not an edge of the trace. A summary link type is read where it is named
(a filter, a needtable column, a rule's `over` / `field`, a needflow's
`:link_types:`, a schema rule, a need type's `back_links` count, which
counts its computed links too), and a need holds its links and back
links
as for any link type. It is left out of every view that reads all link
types:
in sphinx-needs, the needflow default (without `:link_types:` a summary
link type is neither drawn nor walked from `:root_id:`, and the
`needs_flow_link_types` fallback leaves it out too). A union cannot name
one as a member (`needs.derive_invalid`). `summary` beside the
deprecated
`copy = true` is reported and the copy ignored, so a summary link never
leaks into `links`. needs.json's schema marks the link type and its back
links
`"summary": true`, and ub-needs-exchange passes it through. The stance
is one per name: a need type's entry giving it is a `needs.config`
finding.
2. **The kinds.** On a link type, `copy` takes `field = "id"` and
`over`, and links each need `over` names. `collect` takes `field` (a
link type, a derived
one included) and `over`, and gives the links `field` holds on each need
`over` names. Both take `where` (`c.is_a(...)` included), and both may
be
declared on need types alone (`[needs.types.<t>.links.<l>]`). Each link
appears once by target and part,
its condition dropped. A dead link is carried and reported where it is
written, rendered as that link type allows, and does not mark the needs
below it
as having dead links. Such a link type is a summary implicitly. `over`
may name a union, read through its members, so a chain can climb a
`trace_up`-style
union rung by rung. What the link step cannot wait for is refused at
load:
   - an `over` naming back links;
- a `collect` of its own link type over a link type another rule
computes (it would read itself on every need);
   - a `field` naming a value field or back links;
   - a `copy` of a field other than `id`;
   - a `select` other than `"list"`;
   - `after`;
   - `allow_dead_links` beside such a rule.

Per need, a path is ordered after the needs it reads; needs reading one
another's chain around a ring (through a union too) are
`needs.derive_cycle`,
and only those. The density guard counts these links; fingerprints leave
them out.
3. **Incremental builds.** A `collect` on a link type links needs its
need does not name (a chain's far ends), so it is classified as a
`links` rule
is: in a project with one, every change of a need re-writes every
need-relevant page. A `copy` over a link type written in the needs links
only the
needs its own links name, and stays a read of its links (a `copy` over a
computed link type is classified as unnamed too).
`ENV_DATA_VERSION` is 19 (17 is #2415's, 18 #2419's): a `copy` /
`collect` on a link type, refused by 9.1.0, now computes, and `summary`
now changes a
   needflow's default, with the configuration unchanged.

## Why

Closes #2370.

The far ends of a chain (the requirements a test result is evidence for,
three hops up) were only expressible as a field: strings, with no back
links
("the test results that reach me"), no `:need:` rendering, no direct
relationship in a graph consumer. The link form adds them. The stance it
needs, that such a link
is a summary and not a trace edge, applies to authored and other derived
link types too.

**Bringing it up to master.** The branch was built on the derive branch,
since squash-merged as #2384; master was merged in (merge commits, no
rebase).
The conflicts kept both sides: the changelog's Unreleased bullets,
`LinkSchema`'s `severity` (#2395) beside `summary` in the basic-doc
snapshot,
a path's `select` default, and the corpus (master's
`where-follows-inherits` where master has it, this branch's cases after
it). Checked against what master
gained since: the incremental rules of #2414 (a `collect` on a link type
was read as an ordinary `over` and left the old far end's page stale:
fixed,
red first, with scratch-vs-incremental cases for both kinds); a
`[needs.types.<t>.back_links.<l>]` count (#2395) naming a summary or
computed link type
(allowed, counts every link, pinned); `c.is_a` (#2401) in a path's
`where` (pinned in a test and the corpus); a path declared on need types
alone and a need
type's `summary` (#2400, pinned); the needs.json writer's nullable rule
and `""` -> null (#2395, #2400: fields only, beside `summary` on link
types), and
the needflow change on master (a view category only). Later merges
brought in #2415 (hidden-need location) and #2419 (`NeedItemSource`),
which took
versions 17 and 18, so this branch's bump is 19; every bump stays in
`data.py`'s version notes and in the changelog's bump bullet.

**Corpus.** `derive_conformance.toml` gains twelve cases on top of
master's 92 (104, sha256
`c4721bcc6cb1532b4a39a79c3ba5325c75b006762ca5d25a5e1c78809923ba01`;
python_only 2, ubcode_only 1) and an optional `schema.<name>`
expectation, documented in the header; master's cases are untouched.
ubCode re-vendors it and mirrors the stance and the kinds in
useblocks/ubcode#4452, including its own consumers that read all link
types:
the needflow default, the agent walks, `ubc diff`'s default, the Need
Viewer tree and the Cypher schema flag.

**Follow-up, not in this PR.** `summary` on a need type's own link entry
is a `needs.config` finding today, so an authored link type declared on
need types alone cannot be a summary. It can be lifted under the
one-name-one-shape rule (#2284): accept it there when every need type
declaring
the name agrees, and report a disagreement as any other shape mismatch.

## Tests

- Red first for both slices and for the incremental fix; two independent
reviews reproduced every proof, ruled on the two judgement calls (the
refusal of a
derived `over` lifted for unions; a call-computed `over` making only
that need a cycle, kept) and added mutations of their own, each now
killed (the needflow
default and fallback, the union refusal, load-time refusals, stratum
order, implied summary, dead links, the `forbidden` rendering with both
`allow_dead_links` values). After the first merge: mutations of the
incremental classification (both directions), of the back links of a
computed summary
  link type, and of `c.is_a` in a `where`, each red.
- After the merge, master's unknown-key report (#2388) pins the keys of
a link type's entry: `summary` is one now, and its test says so.
- `uv run poe lint` 0, `uv run poe typecheck` 0, full `uv run poe
test-needs` 4244 passed / 15 skipped (after the #2419 merge),
`uv run poe test-ub-needs-exchange` 0 (246 passed), `uv run poe
smoke-needs` 0.
`docs-needs` warns only on what intersphinx resolves (the inventories
are unreachable from the sandbox); none of this PR's references.

## Checklist

- [x] I wrote this change myself and have read every line of it; it was
not generated automatically from an issue.
- [x] I ran the package's tests (`uv run poe test-needs`, `uv run poe
test-ub-needs-exchange`) and they pass.
- [x] Documentation is updated where behaviour or options change.
- [x] The package's `docs/changelog.rst` has an entry under
*Unreleased*.
- [x] `uv run poe lint` and `uv run poe typecheck` pass.
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.

✨ Let filters and schema rules follow inherits: an is_a() filter function or a type_ancestors field

1 participant