Skip to content

✨ needflow: portable link and type styling, with the first deprecations - #1785

Open
chrisjsewell wants to merge 8 commits into
masterfrom
claude/needflow-link-type-styling
Open

chrisjsewell wants to merge 8 commits into
masterfrom
claude/needflow-link-type-styling

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Aug 21, 2026

Copy link
Copy Markdown
Member

Fourth slice of the #1770 split (after #1780, #1781, #1782): link and type styling, and the
split's first deprecations. Pairs with the ubCode mirror PR (cross-linked there) so the two tools
keep one styling vocabulary.

Config

  • needs_links[].line / part_line / arrow / color / part_color — engine-neutral
    drawing keys: line is solid / dashed / dotted / thick / invisible (with bold and
    hidden accepted as the words PlantUML and Graphviz users already write); arrow is normal /
    none / open / circle / cross / both; the two part_* keys style edges that end on a
    need part and each falls back to its ordinary counterpart when unset. color is finally
    honoured — an identical TODO sat in both emitters — and an explicit #000000 draws black:
    unset is spelled by absence (or ""), never by a colour. The built-in links type's
    "color": "#000000" default is removed in the same commit, because it was a stand-in for
    "nothing" and would otherwise have turned every default project's edges black.
  • needs_types[].shape — a ten-member neutral enum (rectangle, rounded, circle,
    ellipse, diamond, hexagon, cylinder, document, folder, box3d); the sixteen legacy
    PlantUML style keywords are accepted as aliases. PlantUML cannot draw diamond and warns
    once per project (tier 2); Graphviz draws all ten. The hexagon note documents the
    PlantUML ≥ 1.2020.13 requirement (verified: fails at 1.2020.02, renders at 1.2022.5).
  • Legacy style / style_part fold in per key, not per type: a half-migrated link type
    (neutral line set, no neutral colour) keeps its legacy colour, and a colour-migrated one has
    only the colour token stripped from its legacy string. Both directions are separately
    mutation-fenced.

The first deprecations — honoured indefinitely

needs_links[].style, style_part, style_start, style_end. Usage-gated: the notice fires
once per link type, names exactly the keys present, and a fully-migrated project is silent
(the silence fence is itself mutation-tested). Four test fixtures migrated to the neutral keys;
doc_needs_filter_func_allow_dirty_filter deliberately keeps the legacy spelling as the
deprecation regression fixture. No warning-fence allow-lists were needed anywhere — the
suite's whole-project empty-warnings fences are untouched.

Two bugs fixed against the reviewed source branch

  • _PLANTUML_ARROWS["both"] emitted <[dashed]->, which PlantUML rejects outright; the pair is
    now <- / ->. A render matrix of all six arrows beside a line and colour passes 60/60 at
    this tip versus 1/10 on the source branch.
  • part_line did not fall back to line, contradicting the source branch's own docstring and
    docs — a fully-migrated link type drew its part edges dotted from a legacy default it never
    wrote.

Environment version

ENV_DATA_VERSION goes 7 → 8, and the story is worth reading: the first build of this slice
argued no bump was needed because slots dataclasses "pickle by name". Review disproved the
premise — frozen slots dataclasses pickle positionally, and part_color is inserted
mid-struct, so a stale environment.pickle holds silently shifted values (measured:
part_color = 'dashed,#00AA00') until the schema is rebuilt. No build shape reads the stale
object today (probed across five shapes), but a silent-corruption failure mode guarded only by an
unversioned ordering invariant is exactly what the constant is for. Reproduced in both
directions: at 7 the stale environment is reused and a probe handler reads the shifted object; at
8 Sphinx discards and re-reads, byte-identical output.

Conformance corpus

corpus_version 2 → 3, 23 → 29 cases: link-line-arrow-color, link-part-styling,
shape-enum-sample (with the shape-unmapped tier-2 degradation), arrow-unsupported, plus two
from the review round — link-part-fallback (the fallback contract this slice fixed) and
link-arrow-both (the exact composition that was invalid on the source branch). All pre-existing
case checksums are character-for-character unchanged, and regeneration is idempotent. The README
format grows: needs[].parts, links[].part_line / part_color, and the colour contract stated
outright ("#000000" is a colour and not "unset") — the line that also settled a cross-tool
divergence in ubCode's favour of this spelling.

Docs

Configuration reference for the five link keys and shape, .. deprecated:: 8.4.0 markers on
the legacy sections, and an arrow migration table whose normal row now says what it maps rather
than over-promising rendered output (measured: base Graphviz renders both ->> and -> as
vee, so arrow: open is the byte-identical migration).

Tests and review

31 assertions recorded red before implementation; the mutation battery covers the per-key folding
(both directions — two mutations are caught only by the review round's mirror test), the
deprecation usage gate, the arrow fix, and both fallbacks. Full suite: 0 new failures against the
22 pre-existing environmental ones; docs warning set identical to master. Adversarial review
reproduced the byte-preservation probes, the arrow matrix, and the ENV mechanism independently.

Byte-preservation: a probe using every legacy spelling at once is byte-identical to master on
both engines; the single exception class is a link type that explicitly sets color, which
moves exactly one line — the feature.

Follow-up (slice 5, last of the split)

:styles: + needs_flow_styles, :engine_config:, the remaining deprecations (:config:,
:scale:, :highlight:, :border_color:, needs_flow_link_types), the color-normalisation
corpus case, the mermaid-reserved engine value, and moving the engine membership check into
validate_flow_config.

Chris Sewell added 8 commits August 20, 2026 19:05
`needs_links[]` gains the neutral `line`, `part_line`, `arrow`, `color` and
`part_color` keys, and `needs_types[]` gains `shape`: each says what is meant
rather than naming one engine's syntax, and each engine writes it in its own.

`color` is finally honoured -- an identical TODO sat in both emitters -- and is
unset by default rather than `#000000`, so a link type can now ask for black and
be given black, while one that names no colour keeps the engine's own edge colour
and draws byte-identically to before.

The four PlantUML-token keys (`style`, `style_part`, `style_start`, `style_end`)
stay valid indefinitely as aliases, folding into the neutral keys where those are
unset.  The fold is per key, not per link type: `style` is a compound of a colour
and a line keyword, so a half-migrated link type keeps the half it has not moved
yet.  The notice is usage gated -- it names exactly the deprecated keys a project
wrote, and says nothing about one that writes none.

Out-of-enum values for the new keys warn once against `conf.py` and hand back to
the deprecated spelling, rather than reaching a lookup table as a `KeyError`.

`_PLANTUML_ARROWS["both"]` is `("<-", "->")`, not `("<", "->")`: the line style is
written between the two tokens, so `<[dashed]->` is a syntax error where
`<-[dashed]->` renders.  A pair that only ever concatenated to a bare arrow would
have passed unnoticed until a link type set a line or a colour as well.
Four cases, carved from the umbrella and passing unaltered against this
implementation on both engines: `link-line-arrow-color` (the three neutral link
properties composing into one construct), `link-part-styling` (`part_line` and
`part_color` drawn for the part edge and NOT for the ordinary one beside it),
`shape-enum-sample` (three shapes, one of which PlantUML has no form for) and
`arrow-unsupported` (`cross`, likewise).

The seam grows with them: `TYPE_KEYS` gains `shape`, `LINK_KEYS` gains the whole
portable link set, and the observable-degradation table gains `shape-unmapped`
and `arrow-unsupported`. Both are now mapped, so the probes that used them as
examples of vocabulary with no surface here move to what is still unmapped.

The format itself grows too, which is why `corpus_version` steps to 3: a need may
carry `parts`, and a link type `part_line`/`part_color`. All 23 existing cases
keep their checksums byte for byte -- only the README hash moves.
`needs_types[].shape` and the five neutral `needs_links` keys get their sections
in `configuration.rst`, with the ten-member shape enum, the older-PlantUML
`hexagon` caveat, and `deprecated::`/`versionadded::` markers on the four
PlantUML-token keys and on the two sections that describe them.

The arrow migration table is spelled out, and says what a migrating project most
needs to know: today's default graphviz arrow head is `open`, not `normal`. The
default `style_end` of `->` has always rendered as an open "V" there, so a project
reaching for the obvious-looking member would silently change every diagram it
renders with graphviz.

`docs/ubproject.toml` moves to the neutral keys, so this project's own
documentation no longer builds on a deprecated spelling; `[needs.links.triggers]`
and its siblings are exactly the configurations that needed `part_color` in order
to migrate at all.

The changelog carries the feature and the deprecation. The PR number is a GUESS
(1783) -- confirm before merging.
… color

The umbrella's own docstring says "``part_color`` falls back to ``color`` when it
is unset, exactly as ``part_line`` falls back to ``line``", but only the colour
half did it: an unset `part_line` handed straight back to the deprecated
`style_part`, whose dataclass default is `dotted`. A link type that had fully
migrated -- `line = "dashed"` and no legacy key anywhere -- therefore drew its
part edges dotted, from a default it had never written, and no rule a reader could
guess explained why the colour and the line behaved differently.

The part keys are overrides: unset takes the ordinary counterpart, and only when
neither neutral key is set does the deprecated pair decide. A purely legacy link
type is untouched, so the byte-preservation gate is unaffected -- verified by
re-diffing the legacy probe on both engines.

Also adds the mirror of the half-migration case: a neutral `color` beside a
`style` of `dotted,#FF0000` supersedes the colour token and leaves `dotted`
drawing. Mutating the strip either way (drop the string wholesale, or emit it
untouched) turns that test red.
Not a doctree key -- `NeedsFlowType` gains nothing on this branch. What moved is
the positional layout of `LinkDisplayConfig`, which lives on the environment as
part of `env._needs_schema` and is therefore pickled.

A frozen `slots=True` dataclass pickles POSITIONALLY: `__getstate__` returns a
list of field values in declaration order and `__setstate__` `zip`s it back on.
`part_color` was inserted between `color` and `style` rather than appended, so a
stale entry loads with every later field holding its neighbour's value --
`part_color` holding a line keyword like `dashed,#00AA00` -- and with the trailing
four never set. Four plausible-looking wrong values and four AttributeErrors, none
of which raise where the damage is.

Nothing reads it today: `Builder.read()` emits `env-before-read-docs`
unconditionally and `create_schema` replaces the schema there before any reader.
The bump is taken anyway, because the failure mode is a silent misread rather than
a crash and the only thing preventing it is an unversioned ordering invariant --
a handler added later on `builder-inited`, `env-get-outdated` or `env-purge-doc`
would read the shifted values and get no error for the first four fields. Proved
reachable with exactly such a handler: at 7 the stale env is reused and the
handler reads the shifted object; at 8 Sphinx reports "build environment version
not current", discards it, and re-reads. The re-read emits byte-identical
diagram source.

Appending instead of inserting would not have been enough: `zip` stops at the
shorter sequence either way, so a stale `display.line` raises whatever the order.
Two contracts this slice fixed had no corpus case, and the corpus is how ubCode
inherits them at re-sync -- a sphinx-needs unit test is invisible to it.

`link-part-fallback` pins the half of the part-key contract that
`link-part-styling` structurally cannot: that case sets `part_line` and
`part_color` explicitly, so it would pass whatever an UNSET one did. The new case
leaves both unset and asserts the part edge takes `line`/`color`, which the README
already specified and nothing checked.

`link-arrow-both` pins `arrow: both` beside a line and a colour -- the member whose
umbrella token pair was invalid PlantUML, and invalid in exactly this shape, since
the line style is written between the two arrow tokens. Only a unit test caught
that, so ubCode could have inherited the broken pair unseen. The corpus harness
runs plantuml through the suite fixture, so a source that cannot render surfaces as
a warning outside the degradation registry.

`corpus_version` stays 3: version 3 has not been published, so these ride it (the
slice-2 pre-publication precedent). All 27 existing case checksums are byte
identical; the manifest gains two entries and the README hash moves for the two
new rows in its required-cases list. Regeneration through the committed single path
is idempotent across two successive runs.
…tput

The row said `arrow: normal` replaces `style_end = "->>"`, which is right about
what the configuration asks for and wrong about what base draws. The graphviz
translation of the deprecated keys reads only the first and last character of
`style_start + style_end`, so `->>` has always collapsed to the same `vee` as `->`
-- measured directly: both resolve to `[('arrowhead', 'vee')]`.

A reader following the table therefore sees their graphviz arrowheads change. That
is arguably a fix, since they asked for a solid head and the first-and-last cheat
gave them an open one, but it is a change, and the `.. important::` block already
goes to trouble to warn about exactly this class of surprise for the `->` default
while saying nothing about `->>`. Row annotated and the note extended.
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.96%. Comparing base (4e10030) to head (378516a).
⚠️ Report is 365 commits behind head on master.

Files with missing lines Patch % Lines
sphinx_needs/directives/needflow/_graphviz.py 88.37% 5 Missing ⚠️
sphinx_needs/directives/needflow/_options.py 98.43% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1785      +/-   ##
==========================================
+ Coverage   86.87%   90.96%   +4.08%     
==========================================
  Files          56       77      +21     
  Lines        6532    11732    +5200     
==========================================
+ Hits         5675    10672    +4997     
- Misses        857     1060     +203     
Flag Coverage Δ
pytests 90.96% <96.77%> (+4.08%) ⬆️

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 added the pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs label Sep 4, 2026
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.

1 participant