Skip to content

🧪 needflow: a shared conformance corpus for the portable vocabulary - #1781

Merged
chrisjsewell merged 2 commits into
masterfrom
claude/needflow-conformance-corpus
Aug 20, 2026
Merged

🧪 needflow: a shared conformance corpus for the portable vocabulary#1781
chrisjsewell merged 2 commits into
masterfrom
claude/needflow-conformance-corpus

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Aug 20, 2026

Copy link
Copy Markdown
Member

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 entry for its own copy, so a local edit that skips the
manifest 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 CI
guard — 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 the
    corpus so it travels with both copies, byte-identical in both.
  • cases/*.yaml — five cases (below); manifest.jsoncorpus_version: 1 plus the
    sha256 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 own
    rules (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.
  • .gitattributes pins the corpus to LF so the working-tree bytes stay canonical and a
    re-sync cannot carry a platform's line endings across; .pre-commit-config.yaml holds
    yamlfmt 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 one
    session fixture that test_app now uses too. CI has java and the vendored jar but no
    plantuml on PATH, 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 is
docs/. Every case pins behaviour that already exists on master, so this PR cannot change
what 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.

case what it pins
baseline-defaults two needs, one link, no options: the no-warning fence, and the baseline every other case deviates from
node-id-injectivity ids R-1 and R=1 stay 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-label a link type with an empty outgoing title, labelled: both engines here emit the label even when empty. Mermaid cannot (its pipe form is a parse error when empty), and the case says so — that divergence being explicit rather than discovered later is what the corpus is for
legend-engine-default a bare :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 ones

Reading and changing the corpus. Expectations are regenerated, never hand-written:
UBC_UPDATE_CORPUS=1 pytest tests/conformance/needflow rewrites the emitted sources into
the 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 the
paired 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's
closed 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.

Chris Sewell 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

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.64%. Comparing base (4e10030) to head (6d82bd9).
⚠️ Report is 329 commits behind head on master.

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     
Flag Coverage Δ
pytests 90.64% <ø> (+3.76%) ⬆️

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 requested review from ubmarco and a lite review from Copilot and removed request for Copilot August 20, 2026 08:37
@chrisjsewell
chrisjsewell merged commit c062feb into master Aug 20, 2026
40 of 43 checks passed
@chrisjsewell
chrisjsewell deleted the claude/needflow-conformance-corpus branch August 20, 2026 09:00
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).
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants