🧪 needflow: a shared conformance corpus for the portable vocabulary - #1781
Merged
Conversation
added 2 commits
August 20, 2026 07:30
The portable needflow vocabulary is implemented twice -- here and in ubCode -- and nothing structural made the two drift visibly. This adds the sphinx-needs half of a corpus that does: language-neutral fixture cases, duplicated verbatim into both repositories under a checksummed manifest, each mapping needs plus portable configuration and options to the diagram source every engine must emit. Test infrastructure only: no production code changes, and the five shipped cases pin behaviour that already exists (the no-warning fence, node-id injectivity, literal diagram-significant text, an empty outgoing label, and the in-diagram legend of a bare `:show_legend:`). The corpus format ships as `tests/conformance/needflow/README.md`, verbatim in both copies. `.gitattributes` pins the corpus to LF and the harness hashes line-ending-normalised bytes, so a Windows checkout stamps the same checksums; yamlfmt is held off the shared bytes for the same reason. The plantuml command every test project renders with moves into one session fixture that `test_app` now shares.
An omitted types: is the CORPUS default (one req/Requirement/R_ type), never the repository's own built-in types — those differ between the two tools, so a repo-default reading would make the two copies structurally unable to agree. The harness already read it this way; now the spec says so.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1781 +/- ##
==========================================
+ Coverage 86.87% 90.64% +3.76%
==========================================
Files 56 76 +20
Lines 6532 11340 +4808
==========================================
+ Hits 5675 10279 +4604
- Misses 857 1061 +204
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
chrisjsewell
requested review from
ubmarco
and
a lite review from Copilot
and removed request for
Copilot
August 20, 2026 08:37
ubmarco
approved these changes
Aug 20, 2026
This was referenced Aug 20, 2026
chrisjsewell
added a commit
that referenced
this pull request
Aug 20, 2026
Third slice of the #1770 split (after #1780 and #1781): the presentation options. It gives `needflow` an engine-neutral way to say which way the graph flows, what the edges are labelled with, and what the legend contains — additive only, **zero deprecations**: two existing options are widened rather than replaced, so the spellings in existing documents keep working and keep producing the same bytes. ## Options - **`:direction:`** (new) — `down` (default) / `up` / `right` / `left`, plus the `TB`/`TD`/`BT`/`LR`/`RL` two-letter forms Graphviz and Mermaid users already know, with a `needs_flow_direction` project default. An explicit option beats an engine-config(`:config:`)-derived direction on both engines, and disagreement warns. PlantUML has no bottom-to-top or right-to-left primitive (probed: both are syntax errors), so those degrade to their axis mate with a single warning per project. - **`:show_link_names:` widened** — the flag now takes an optional value `none` / `outgoing` / `incoming` / `type`, and written bare still means exactly what it always meant (`outgoing`), byte-for-byte. `needs_flow_show_links` likewise accepts a string as well as a boolean (`True` ≡ `outgoing`, `False` ≡ `none`). The old OR of flag and config becomes a precedence — only an unset option consults the config — which fixes the interaction that made "project default on, this one diagram off" impossible to express. - **`:show_legend:` widened** — it now takes the *name* of a legend defined in `needs_flow_legends`, or nothing. Deliberately key-only: an inline value set would collide with user-chosen names and need a precedence rule. Written bare it renders today's in-diagram legend, byte-for-byte. ## Config `needs_flow_legends = {name: {...}}` defines legends — `parts`, an **ordered list** of the sections to draw (`types`, `links`; a list only, and order is rendered as listed), and `placement` (`internal` / `external`), a preference: unset takes the engine's own default placement, which is internal for both engines here. A legend that includes `links` renders as a table beside the diagram, since neither engine can draw link rows inside it. `needs_flow_show_legend` names the legend a diagram gets when it asks for one without naming its own; it selects *which*, never *whether* — presence stays per-directive, so there is no off-switch value for a legend name to collide with. An undefined key resolves as a **chain** — the directive's key, else the project's, else the engine default — warning at each undefined step and handing on, because an unusable value is treated as unset everywhere in this vocabulary. The two steps warn at different tiers: a bad directive key per directive (`needs.needflow`), a bad `needs_flow_show_legend` once per project (`needs.config`, no directive location — it is a `conf.py` mistake). All four new configs are validated as they are read, so a project that misconfigures one and happens to have no needflow is still told; the checks warn and fall back, never crash (including non-string values, `parts: 5`, and `parts: "links"`). Enumerated config values are matched the way the option parsers already match theirs — case-insensitively, ignoring surrounding whitespace — and `needs_flow_engine`'s membership check from #1780 gets the same treatment. ## Nothing moves for existing projects — with one named edge Verified by cross-commit diff of the generated diagram source on both engines (a five-needflow probe using only pre-existing spellings, and a ten-shape matrix over every reachable `show_link_names` shape), and independently reproduced in review: projects using only existing spellings produce byte-identical output. No test fixture changed: `git diff master -- tests/doc_test/` is empty, `.rst` and `conf.py` alike. The five conformance cases merged in #1781 — including the bare-`:show_legend:` and bare-`:show_link_names:` parity targets — pass **unregenerated**, with unchanged checksums. The one edge, and why the changelog has a Breaking section: `needs_flow_show_links` set to a **string** was never a supported spelling (the config was declared `bool`, and Sphinx already warned about the type) but drew labels via truthiness. It is now read as a value: an unrecognised string warns and draws no labels, and `'none'` — the sharpest case — now silently means what its author meant instead of the opposite. Non-string truthiness (`1`, `0`) is deliberately preserved. ## Environment version `ENV_DATA_VERSION` goes 6 → 7: the directive persists the resolved options in doctrees, and an older reader over a newer doctree fails on the missing keys (reproduced by rebuilding across the change and watching it raise, then fixed by the bump). No other open PR claims 7. ## Conformance corpus `corpus_version` 1 → 2, 5 → 23 cases: direction (one per value, including the two PlantUML degradations and the option-vs-engine-config conflict), the four link-label values plus config-driven and option-beats-config cases, and six legend cases (explicit keys, order pinning, the chain). Fifteen case files are byte-identical to the reviewed umbrella branch and pass here unregenerated — a cross-check of this carve rather than a snapshot of it. The degradation mapping table gains its first three live rows (tier + subtype + pattern), regeneration now records degradation entries, and the per-engine `expect.<engine>.legend` key is exercised for the first time by the external-placement cases. Regeneration is idempotent (verified over two full runs). ## Tests and review Every behaviour was recorded failing before its implementation landed, and seven targeted mutations each turn a test red — one of them (an option-beating-`:config:` assertion satisfiable by a `rankdir` emitted in the wrong place) was caught by the byte-exact corpus case and now has a dedicated ordering fence. The adversarial review reproduced the byte-preservation, the matrix, the tier split, and the ENV crash independently, and found one real defect — a non-string `needs_flow_show_legend` crashed the build — which is fixed at both affected sites with red-first tests for both paths. Full suite: 1637 passed; the 47 remaining failures/errors are pre-existing environmental ones, byte-identical to master's set. Docs build warning-count and warning-set identical to master. ## Follow-ups (later slices of the #1770 split) - Slice 4: link and type styling (`needs_links[].line/part_line/color/part_color/arrow`, `needs_types[].shape`) with the first deprecations. - Slice 5: `:styles:` + `needs_flow_styles`, `:engine_config:`, the remaining deprecations, and moving the engine membership check into `validate_flow_config` (review measured today's #1780 behaviour reporting a `conf.py` engine mistake against a directive location, and staying silent when no needflow exists — inherited there deliberately).
This was referenced Aug 21, 2026
Merged
chrisjsewell
added a commit
that referenced
this pull request
Aug 27, 2026
## Release v8.4.0 Minor release. Headline changes: the `needflow` portable vocabulary (`:direction:`, named legends, `:show_link_names:` values), the new `needs_card_layouts` configuration, `list2need` rebuilt to create its needs directly, and `max_items` on every view. ### Changes - Bump version to `8.4.0` in `sphinx_needs/__init__.py` - Bump `NEEDS_VERSION` to `8.4.0` in the Docker workflow - Stamp the changelog's "Unreleased" section as `8.4.0` — release date `27.08.2026`, the `v8.3.1...v8.4.0` compare link, and a release summary - Move the `needtable :style_col:` deprecation out of "Improvements" into a "Deprecations" heading, matching 8.2.0 - Add the one missing entry: 📚 `list2need` documentation corrections (#1788) `docs/_static/tutorial_needs.json` is **not** regenerated this time — #1755 stopped tracking it, so unlike 8.2.0 and 8.3.0 there is nothing to commit for it. There are no `.. versionadded:: unreleased` markers to stamp either; every one in `docs/` was already written as `8.4.0`. ### Coverage All 24 commits since 8.3.1 were reviewed against the changelog. 19 user-facing changes all have entries; 5 are internal or convention-exempt (#1747, #1764 — `docs/ubproject.toml` only; #1763 — README only; #1781 — tests only; #1769 — refactor, filed under "Internal changes"). One gap was found and filled: #1788's `list2need` documentation corrections. ### Release steps after merge 1. Merge this PR 2. Create and push tag `8.4.0` — the release pipeline (`.github/workflows/release.yaml`) publishes to PyPI on tag push
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice 2 of the #1770 split (see the plan there); slice 1 merged as #1780.
What this is, and why it exists. The needflow portable option vocabulary is implemented
twice: here, and in ubCode's mermaid emitter. Two implementations of one user-facing
contract drift, and until now nothing structural would have made that drift visible — you
would find out from a user whose diagram looked different in the two tools.
This PR adds the sphinx-needs half of the only structural mitigation we have: a
conformance corpus. It is a set of fixture cases written in a language-neutral
vocabulary — each states some needs, some portable configuration, some directive options,
and the diagram source every engine must emit for them — duplicated verbatim into both
repositories under a checksummed manifest. Each repo's harness independently verifies
sha256(case file) == manifest entryfor its own copy, so a local edit that skips themanifest is a red test in that repo, and drift between copies shows up at re-sync as
differing checksums or
corpus_version. Honest about the limit: there is no cross-repo CIguard — the manifest makes drift visible, not impossible. ubCode is the repo of record;
edits land there first and this copy is a verbatim re-sync.
What is in the PR.
tests/conformance/needflow/README.md— the corpus format spec, shipped inside thecorpus so it travels with both copies, byte-identical in both.
cases/*.yaml— five cases (below);manifest.json—corpus_version: 1plus thesha256 of the README and every case, over line-ending-normalised bytes.
test_conformance.py— the harness: builds a minimal Sphinx project per case per engine,pulls the emitted diagram source out of the doctree, normalises node hyperlink URLs to
<NODE_URL:need_id>tokens, compares byte-exactly. It also enforces the format's ownrules (unknown keys, tier-1 degradation entries, malformed legend expectations, checksum
mismatch) — a runner that quietly accepts a malformed case is green while testing
nothing, so each refusal has a test.
.gitattributespins the corpus to LF so the working-tree bytes stay canonical and are-sync cannot carry a platform's line endings across;
.pre-commit-config.yamlholdsyamlfmt off the corpus (shared bytes — verified it would rewrite all five files and
invalidate every checksum).
tests/conftest.py— the plantuml command every test project renders with becomes onesession fixture that
test_appnow uses too. CI has java and the vendored jar but noplantumlonPATH, so a project on the default command passes locally and fails there.There is no production change.
git diff master -- sphinx_needs/is empty, and so isdocs/. Every case pins behaviour that already exists on master, so this PR cannot changewhat any user's diagram looks like. It can only start failing when a later change moves
those bytes — which is the point.
The five cases.
baseline-defaultsnode-id-injectivityR-1andR=1stay two nodes with distinct edges — they used to fold to the same plantuml entity and silently lose a need (#1768)percent-neutralisation%%, quotes, angle brackets and braces in a title stay literal to the reader and are never truncated (depends on the graphviz label escaper from #1780)edge-empty-labellegend-engine-default:show_legend:: today's in-diagram legend, byte-exact, on both engines — including that plantuml lists every configured type while graphviz lists only the drawn onesReading and changing the corpus. Expectations are regenerated, never hand-written:
UBC_UPDATE_CORPUS=1 pytest tests/conformance/needflowrewrites the emitted sources intothe case files and restamps the manifest — then read the diff, because an expectation
accepted without reading is a snapshot, and a snapshot cannot tell you that what it
recorded is wrong. Regeneration is idempotent and a single pass converges. Any change to a
case or the README bumps
corpus_version, lands in ubCode first, and comes here as thepaired re-sync.
One thing to know about the harness. The corpus format is deliberately wider than
what this repo can express today — it is the shared format, and Sphinx-Needs' surface is
a growing subset. The harness keeps the two apart: the
SPEC_*tables are the format'sclosed vocabulary; the mapping tables beside them are what this repo can currently build a
project from, carrying only rows a shipped case uses. A case naming something the format
defines but this repo has no surface for is refused with a message saying so — never
built as if the key were absent, which would leave the case asserting a diagram that was
never asked for the thing it is about. When a slice implements an option it adds its
mapping row together with the case that needs it. Two mapping tables ship empty on purpose
(project-level config keys, and the degradation registry) because nothing on master
populates them; with the degradation table empty, every warning a corpus build emits
counts as unexpected, which is what makes the fence a fence.