Skip to content

✨ sphinx-codelinks: multi-line needs in source comments (@need … @endneed); the never-rendered @rst blocks are removed - #2152

Merged
chrisjsewell merged 12 commits into
masterfrom
feat/codelinks-multiline-needs
Oct 8, 2026
Merged

chrisjsewell merged 12 commits into
masterfrom
feat/codelinks-multiline-needs

Conversation

@chrisjsewell

Copy link
Copy Markdown
Member

What

A need can now be written across the lines of a source comment, and codelinks analyse extracts it:

/**
 * @need req: Login must be rate limited
 * :id: REQ_LOGIN_1
 * :links: SPEC_AUTH, SPEC_LOCK
 *
 * After **five** failed attempts the account is locked; see :need:`SPEC_AUTH`.
 * @endneed
 */
  • Grammar (parsed by the analyser before any markup parser): @need[<markup>] <type>: <title>, then :key: value
    options (a deeper-indented line continues a value), then the body in the declared markup (dedented, never re-flowed),
    then @endneed alone on its line. @need[md] declares Markdown; the default is the project's default_markup
    (rst). A body line \@endneed is the escape.
  • Runs: a block lives in one block comment, one Python docstring, or a run of consecutive line comments (//, ///,
    //!, #) with one delimiter and only whitespace before them — every supported language, and under the libclang
    engine too (a block in an inactive #if branch is not extracted).
  • Prefixes are stripped by the comment's kind, never by searching: the delimiter and one space for line comments; the
    doxygen * leader only when every line carries one, so *emphasis* survives (the old @rst stripping cut through the
    first * on any line); banner rows (/*****, ***/) are delimiters, and a block whose leader is not stripped reports
    an open word behind a star instead of losing it.
  • Precedence: a block's lines are never read as one-line needs or @need-ids: references.
  • Refusals, as analyse warnings at the source line: multiline_need_header, _unterminated, _oneline_form,
    _markup, _duplicate_option, _nested_open.
  • Record: marked_content.json gets one "type": "multiline-need" record per need — need (option values as the
    directive strings written, content, doctype), the resolved markup, and a source with a root-relative POSIX
    path, root, commit, the open/close/body positions (1-based lines, 0-based columns), option lines and scope.
  • Config: get_multiline_needs and [analyse.multiline_needs] (start_sequence, end_sequence,
    default_markup, markups).
  • Corpus: tests/data/extraction/multiline_needs.yaml, 37 cases, the shared contract; each case's snapshot is the record as
    dumped plus a warnings sidecar, the shape 🧪 sphinx-codelinks: snapshot the real extraction output, with marker scope association (moved from sphinx-codelinks#100) #2128 introduced; ubCode compares it verbatim (useblocks/ubcode#3014).
  • Removed: the @rst … @endrst blocks, get_rst, [analyse.marked_rst] (refused with a message naming the
    replacement), the "rst" records. SourceAnalyse.oneline_warnings is now warnings (old name kept as an alias for
    one release); the CLI prints every analyse warning in one shape. write rst ignores the new records.

Why

#1885: the @rst blocks were documented as a way to write needs in comments, but nothing ever rendered them, and their
extraction only worked inside one /* */ comment and corrupted *emphasis*. #1898 decision 1 settled the design: one
need per comment block, a markup-neutral header parsed by codelinks, the body's markup declared per block — so the
record exists before any parser runs and the consumers treat it as a need imported through needimport.

The branch is built on #2128 (merged as 2f40ce27).

Not yet

The src-trace directive does not render these needs: it does so in the next sphinx-codelinks slice, once
sphinx-needs 9.0.0 is released with #2135 (the step that parses a need's content in its declared markup); sphinx-codelinks
pins sphinx-needs<9 today. Until then a project that switches the extraction on gets the records from codelinks analyse only.

A //! inner doc run binds to the next item's scope, as a one-line marker does (#2128's scope rule); the consumer slice decides
whether an inner doc comment should bind to its enclosing module instead.

Found on the way, all pre-existing: a stale extraction snapshot is invisible to --snapshot-warn-unused (#2146); the
row order of the snapshotted records (#2150) and the scope walk (#2151), both found in #2128's review.

ubCode

ubCode's producer is useblocks/ubcode#3914: parse_marked_rst is a placeholder there (rust/ubc_codelinks/src/markers/rst.rs).
It copies packages/sphinx-codelinks/tests/data/extraction/multiline_needs.yaml byte for byte into
rust/ubc_codelinks/tests/fixtures/extraction/ and adds it to sync_codelinks_expected.py's SHARED_FIXTURES. Since #2128 each
snapshot is the record as dumped, and useblocks/ubcode#3014 (Marco's open PR, being taken over) has ubCode compare that
production shape verbatim — so #3914 emits the full multiline-need record (ruled on that issue today; the earlier five-key
projection, useblocks/ubcode#3928, is withdrawn with #3014), and until #3914 lands multiline_needs.yaml is listed as
reference-only in ubCode's sync. Until
#3914 lands, ubCode's deny_unknown_fields analyse config rejects a shared ubproject.toml that sets
get_multiline_needs.

Tests

  • Corpus, 37 cases: 13 languages/comment kinds; 21 grammar cases (every refusal, escape, continuation, lenient option
    indentation, reserved keys, banner comments, mixed leaders, dedent, runs broken by code, by a delimiter change and
    by a trailing comment, custom words and markups); precedence with the one-line and reference extractors on; two
    libclang cases (active/inactive branches, a trailing comment).
  • Unit tests for run formation, prefix stripping and the grammar; config validation (default_markup must be a key of
    markups, the markers join the mutual-exclusion check when the feature is on, not forced on); the CLI record, warning shape and removed-key
    message; write rst ignoring the records.
  • Nine mutation proofs, each red on its pin and green after revert.
  • poe test-codelinks (0 skipped), the sphinx-7 cell, poe docs-codelinks under -nW.

Refs #1885
Refs #1898
Refs #1982

The header-first split follows the closed PR useblocks/sphinx-codelinks#66; the analysis-layer record and the
host-markup problem were first raised in #44 and #99.

…shared corpus contract

The pins for the multi-line needs the next commit builds, red by construction
until then (the configuration and the record do not exist yet):

- tests/data/extraction/multiline_needs.yaml, the shared extraction corpus file
  ubCode copies byte for byte: every language, the grammar's refusals, the
  precedence over one-line markers and @need-ids references, one libclang case.
  It replaces marked_rst.yaml, whose two snapshots go with it.
- The normalized contract's marked_rst key becomes multiline_needs, so every
  existing extraction snapshot changes by that one key and nothing else.
- Unit tests for run formation, prefix stripping and the block grammar; the
  configuration, the CLI payload and warning shape, the removed-key message,
  write rst ignoring the new records.
- test_extract_rst and test_remove_leading_sequences go with their functions.
…move the never-rendered @rst blocks

A need can now be written across the lines of a comment, and the analyser
parses it before any markup parser runs:

    @need[<markup>] <type>: <title>
    :<key>: <value>
    <body in the declared markup>
    @endneed

- analyse/multiline_parser.py forms comment runs (one block comment or
  docstring, or consecutive line comments with one delimiter and nothing but
  whitespace before them), strips prefixes by the comment's kind (the doxygen
  leader only when every line has one, so *emphasis* survives), and parses the
  block grammar, with six multiline_need_* refusals and notes.
- The rows a block claims are blanked before the one-line and @need-ids
  extractors see the comment, so no line of a block is a one-line need or a
  reference.
- MultilineNeed is a plain JSON record: the need (option values as directive
  strings, content, doctype), the resolved markup, and a source with a
  root-relative POSIX path, the open/close/body positions, option lines and
  scope. codelinks analyse writes it to marked_content.json.
- get_multiline_needs and [analyse.multiline_needs] (start_sequence,
  end_sequence, default_markup, markups) replace get_rst and
  [analyse.marked_rst], which are refused with a message naming the cure.
- SourceAnalyse.warnings holds one-line and multi-line warnings alike, with
  oneline_warnings kept as a read-only alias; the CLI prints both in one shape.
- write rst ignores the new records. The src-trace directive is untouched: it
  renders multi-line needs in a following release.
… names the new module

The Unreleased bullet says what an author writes, what codelinks analyse
writes, that src-trace renders the needs in a following release, and what
went: the @rst blocks, get_rst, [analyse.marked_rst], the rst records and the
oneline_warnings name (kept as an alias for one release).

AGENTS.md lists analyse/multiline_parser.py in the package tree, and the three
places that named the removed MarkedRst, MarkedRstConfig and @rst blocks now
name what replaced them.
…, one-line form, indented escape, markers when off, Rust rows)

Red against the current code, each for its own reason:

- a ` ***/` closer and a `/*****` banner are delimiter-only rows, dropped before
  the leader test (today the leader is not stripped and the need is lost);
- a refused one-line form claims its own row (today it claims nothing);
- the escape is tested on the indentation-stripped line, indentation kept
  (today an indented `\@endneed` keeps its backslash);
- the two multi-line words join the marker check only when the feature is on
  (today `markers = ["@need"]` fails with the feature off);
- a Rust `///` node spans its own row only, so the row above the open line
  neither holds claimed rows nor stands for the block.
…d leaders, one-line form, indented escape, markers when off

The rules that change, each written into the corpus header and the docs page:

- A block comment's first row holding only its opener (any stars, or /*!) and
  last row holding only its closer (stars then */) are delimiter rows, dropped
  before the leader test: a ` ***/` closer or a `/*****` banner no longer loses
  its need silently. `/*!` is stripped like `/**`.
- When a block comment's lines do not all carry the `*` leader, an open word
  behind a star is multiline_need_header at its line, naming the cause, instead
  of nothing at all.
- A refused one-line form claims its own row, so the one-line parser neither
  mints a need from it nor warns twice; it needs the close WORD at the line end
  (`foo@endneed` is no close).
- The escape is tested past a body line's indentation, and the indentation is
  kept: the close word can be shown inside an indented literal block.
- The two multi-line words join the marker mutual-exclusion check only when
  get_multiline_needs is on.
- A Rust `///` node spans one row, so a record's comment is the open line's.

Corpus: five new cases (option_lines_indented, reserved_option_keys,
banner_block_comment, mixed_leaders_refused,
libclang_trailing_comment_not_in_run); oneline_form_refused and escaped_close
extended; the header states the lenient option indentation and its blank-line
consequence, the whitespace and tag-matching rules, TAB indentation, and that a
Python run is any bare string statement.

Fences: a libclang record's column, a src_dir-rooted record, CRLF input,
character columns past non-ASCII. test_analyse pins source.path against src_dir.
Cleanups: the unused tests/data/analyse/marked_content.json, stale @rst text in
two tests, the online_comment_style typo in convert_analyse_config.
…nterminated hint, and stars-only leader lines

Red against the current code:

- an open word behind a star in a block whose lines do not all carry the
  leader is consumed up to the first close line, which may carry the stars
  (today it hides nothing, so a `* @PARAM a, b` line still feeds the one-line
  parser);
- with no close, it is multiline_need_unterminated naming the leader as the
  likely cause, as is an open on the opener's row of such a block;
- a line of stars only inside a leader block counts as a leader line and
  becomes a blank line (today it makes the whole block leaderless).
…s-only separators are leader lines

- In a block comment whose lines do not all carry the `*` leader, an open word
  behind a star is a multiline_need_header refusal that now consumes its lines
  up to the first close line, which may carry the stars too (`* @endneed`), as
  every other header refusal does: a `* @PARAM a, b` line in it no longer
  makes a one-line need, nor a `* @need-ids:` line a reference. Without such a
  close it is multiline_need_unterminated, and in such a block every
  unterminated message names the missing leader as the likely cause.
- A line of stars only inside a leader block counts as a leader line and is
  read as a blank line, so a doxygen separator no longer makes the whole block
  leaderless.
- Corpus header and docs say so; mixed_leaders_refused gains the two lines it
  now hides, cpp_doxygen_leader a separator in its body (both snapshots
  unchanged). Docs: the RST-bullet false positive and its cure; "whitespace"
  where any whitespace is accepted.
…red one-line form

Red against the current code:

- the unterminated message's leader hint is for a block with at least one
  leader line, not a plain block with no star (today every leaderless block
  gets it);
- in a leaderless block, ` * @need req: A @endneed` is the one-line form, as
  everywhere else (today: unterminated).
…one-line form is the one-line form

- CommentRun.mixed_leaders: a leaderless block in which some lines do carry
  the `*` leader. Only there does an unterminated message name the missing
  leader as the likely cause; a plain block with no star gets the plain
  message.
- In a leaderless block, an open word behind stars whose line ends with the
  close word is refused as the one-line form and claims its row, as an open
  line is anywhere else: the refusal order is the same in both modes. The
  corpus header says so.
- _STARS's docstring is back under _STARS (it had become a stray string
  below _STARS_ONLY's).
…for mixed blocks only

The unterminated message names the missing `*` leader only in a mixed block
(at least one line carries the leader or is stars only); a plain block's
message has no hint. The header already scoped it with the code; this states
the plain-block half explicitly, in the header ubCode implements from and on
the docs page.
…hot shape

On top of #2128, each multiline_needs.yaml case's snapshot is the marked
content as dumped (its multiline-need records, beside any one-line need or
reference) plus the [warnings] sidecar. The 37 cases' 74 files are
regenerated in that shape. Projected back to the earlier normalized form
(need, markup, open/close/first body lines, warning kinds and lines), they
state the same facts as before for all 87 cases. The corpus header's
paragraph on the snapshot says so, and that ubCode projects each record to
{need, markup, start_line, end_line, content_start_line} for its parity test;
the contract above it is unchanged.
@github-actions github-actions Bot added the pkg: sphinx-codelinks Concerns the sphinx-codelinks package (packages/sphinx-codelinks) label Oct 8, 2026
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.57143% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.23%. Comparing base (2f40ce2) to head (ae16b51).

Files with missing lines Patch % Lines
...s/src/sphinx_codelinks/analyse/multiline_parser.py 98.24% 6 Missing ⚠️
...es/sphinx-codelinks/src/sphinx_codelinks/config.py 98.14% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2152      +/-   ##
==========================================
+ Coverage   93.05%   93.23%   +0.17%     
==========================================
  Files         138      139       +1     
  Lines       20313    20689     +376     
==========================================
+ Hits        18903    19289     +386     
+ Misses       1410     1400      -10     
Flag Coverage Δ
ai-index 100.00% <ø> (ø)
codelinks 96.17% <98.57%> (+0.81%) ⬆️
mounts 94.27% <ø> (ø)
pytests 92.46% <ø> (ø)
reports 93.65% <ø> (ø)
ub-test-reports 90.20% <ø> (ø)

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.

@ubmarco
ubmarco self-requested a review October 8, 2026 13:43

@ubmarco ubmarco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good one, we finally have a solution. Thanks a lot.
You even thought about the escape A body line \@endneed is the escape.

@chrisjsewell
chrisjsewell merged commit 3816d91 into master Oct 8, 2026
66 of 72 checks passed
@chrisjsewell
chrisjsewell deleted the feat/codelinks-multiline-needs branch October 8, 2026 13:57
chrisjsewell added a commit that referenced this pull request Oct 8, 2026
…oc field tags in docstrings are not needs (#2129)

## What

`packages/sphinx-codelinks`: the production defects that snapshotting
the real extraction output (#2128) exposed, fixed.

- **Marker positions are physical.** A one-line need's or `@need-ids`
reference's `source_map` columns count the characters before it on
its line. They used to count from the start of the comment, so an
indented marker, a comment after code, or a comment after a
non-ASCII character reported a column that was not its column in the
file. Each comment records the column, in characters, at which its
text starts, on both the tree-sitter and the libclang path; it is added
on the text's first row, and later rows start at column 0. A
comment's rows are split on line breaks only, so a form feed no longer
starts a row. `marked_content.json` changes accordingly.
- **A need reference's span starts at its first id**, not at the
whitespace after `@need-ids:`, and ends after its last id
(`NeedIdRef.start_column` / `end_column`). A marker with no id after it
is no reference.
- **A block comment's closing `*/` is not marker text**: `/* @need-ids:
REQ_1 */` named `*/` as a second id, and `/* @t, IMPL_1, impl */`
had the type `impl */`. A one-line need on a comment's last line — the
row of a block comment's `*/` or of a docstring's closing
  quotes — is no longer dropped.
- **A Python docstring is read as its content**, without its prefix
letters and quotes, so a marker on a one-row docstring,
  `"""@t, IMPL_1, impl"""`, no longer has the type `impl"""`.
- **A field-shaped tag line in a Python docstring is a `docstring_tag`
warning, not a need.** A line warns, and yields no need, only when
all hold: it is inside a docstring (any bare string statement directly
in a module, class or function body; never a `#` comment, never
another language); nothing alphanumeric precedes the one-line start
sequence; a tag of `PYTHON_DOCSTRING_TAGS` follows it; and the tag
has the Epydoc field shape `:|[ \t]+[^ \t:]+:` — `:` directly (`@return:
text`), or spaces or tabs, one word without space, tab or `:`,
and `:` (`@param a: text`, `@raise Exc: text`, `@param नाम: text`).
Every other line goes to the one-line parser as before:
`# @todo fix the parser, IMPL_TODO, impl` is a need, so is the docstring
line `@todo fix the parser, IMPL_DS, impl`, and so is
`@return : text, IMPL_RET, impl`. Doxygen's comma form (`@param a the
first, thing`) stays the phantom need it is in C++. The warning
says to choose a start sequence the docstrings do not use.
"Alphanumeric" here is Python's own notion, which differs from ubCode's
on a
  few characters (combining marks, for one): a documented non-goal.

Closes #2154

## Why

Any tool that reads the columns of `marked_content.json` — an editor
jumping to a marker, say — was sent to the wrong place for every
indented marker and every need reference. In a build the links are
line-based, so there only the order in which unknown ids on one line
are reported changes. A project with Epydoc-style Python docstrings got
phantom needs from its `@param a: x, y` lines with the default
`@` start sequence.

## Review of this PR, addressed on the branch (taken over 2026-10-08
with Marco's go)

Rebased onto master (after #2128 and #2152): Marco's two commits
replayed, the one conflict (`oneline_parser.py`: the warning kinds now
live in `models.py`) resolved in place, and the one multi-line corpus
case that holds `@need-ids` references re-captured at the new
spans. Then:
- `docstring_tag` is narrowed to the rule above. As first built it fired
on every comment of a Python file and on any listed tag word, so
`# @todo fix the parser, IMPL_TODO, impl` — a valid one-line need — and
any docstring line opening with a listed tag word, such as
`@todo fix the parser, IMPL_DS, impl`, became a warning instead of a
need. A field-shaped docstring line such as `@return: x` still
warns, by design. The field word is spelled so that this package and
ubCode read it the same way in every script: a parameter named in
Devanagari is a field word on both sides. The pins came first and were
red against each earlier version.
- A docstring is scanned as its content, the last line of a comment
counts as terminated, a block comment's `*/` is not marker text, a
comment's rows are split on `\n` only, and an empty `@need-ids:` is no
reference — each the rule ubCode applies, each with a shared case.
- The corpus README's two "known production quirks" are fixed by this PR
and are gone; what remains is a rule of the corpus: no case
puts two entries of one kind on one row (#2150). Its JSON example, and
the examples in `docs/components/analyse.rst` (measured with
  `codelinks analyse`) and `write.rst`, show physical columns.
- Nothing pinned the libclang column (setting it to 0 left the suite
green); a test now checks that both engines report the same
physical columns, with LF, CRLF and CR line endings, including a one-row
block-comment need, a need on a `*/` row, a form feed and an
  empty marker.

Found on the way, pre-existing and not changed here: production orders
two entries of one kind on one row by tree-sitter's capture
order (#2150); the scope walk can bind a marker to a declaration further
down the file, past a decorated `def` or a `template <…>` too
(#2151); a non-ASCII character before an inline YAML comment loses the
comment (#2155).

## ubCode

useblocks/ubcode#3014 implements every rule here — physical columns,
reference spans from the first id, docstring content, terminated
last lines, the `*/` rule, `\n`-only rows, no empty references, and the
same `docstring_tag` (same tag list, the same field-shape
pattern, docstrings only) — and re-vendors the corpus from master after
this merges. Each tool keeps its own notion of "alphanumeric"
before the start sequence and of the whitespace between `@need-ids:` ids
(they differ on a few characters); the corpus says so and pins
neither.

## Tests

- `positions.yaml` (11 cases, new): indentation; code, or a non-ASCII
character, before the comment; a non-ASCII title and id; trailing
whitespace in a block comment; a one-line need and a reference on one
row; a one-row block-comment need; a need on a block comment's
  `*/` row; a form feed inside a line.
- `docstrings.yaml` (13 cases, new; its header states the rule):
trailing whitespace; a docstring statement holding a second string;
field-shaped tags warn (`@param a: the first, thing`, `@return:
nothing`, a Devanagari parameter name, an `r"""…"""` one-row
docstring); a tag word without the field shape, `@return : text`,
`@Todo`, and a `#` comment's tag line (field-shaped or not) are needs;
one-row docstrings' need and reference lose the quotes; a need on the
closing-quote row.
- `need_refs.yaml` gains `every_extractor`, `block_comment`,
`space_separated_ids`, `marker_after_text`,
`empty_marker_is_no_reference`,
  `line_comment_ending_in_closer`.
- Unit: `docstring_tag()`'s field shape (26 rows, the same table as
ubCode's); a field-shaped tag line in a `#` comment goes to the
one-line parser; libclang columns equal tree-sitter columns (LF, CRLF,
CR). `test_need_id_ref_record` expects the span from the first id.
- `uv run poe test-codelinks`: **857 passed, 0 skipped** (the sphinx-7
cell too); `uv run poe docs-codelinks` builds with no warning.

## Checklist

- [x] Every line of this change has been read: Marco's commits, the
reviews and validations of 2026-10-08, and the takeover's line-by-line
pass; it was not generated automatically from an issue.
- [x] I ran the package's tests (`uv run poe test-<package>`) 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.

---------

Co-authored-by: Chris Sewell <chrisj_sewell@hotmail.com>
chrisjsewell added a commit that referenced this pull request Oct 9, 2026
…dy is parsed in its declared markup (#2168)

## What

`src-trace` now creates a need from every multi-line need
(`@need[<markup>] <type>: <title>` … `@endneed`) in the files it traces,
with its body parsed in the markup the block declares, whatever the
page's.

- One-line and multi-line needs are one list, in the order the analysis
already gives one-line needs (a block by its open line), and
take the same steps: the id (written, or generated by Sphinx-Needs), one
need per id (the earlier line keeps it whatever the
kinds; `codelinks.duplicate_need` names the kind skipped), local/remote
URL and the source page's `[docs]` link — at the open line.
- Each block is one `ingest_need_record` call (on the unclaimed-markup
fallback a second, in the page's own suffix, and a third,
with no markup, when no parser claims that suffix either): its body as
content in the markup its `doctype` names, anchored at the
body's line in the source file (`content_source`; not for a block
rendered through a template or with `jinja_content`, whose
rendered text Sphinx-Needs anchors at the need), the need recorded at
the directive (`docname`/`lineno`), as a one-line need is.
- A block's options are the need directive's (core options + the
project's extra and link fields), converted as `.. req::` converts
them -- keys lowercased as docutils lowercases them, links and tags
split, typed fields coerced, `jinja_content` a flag; an option
naming one of the block's own keys (`:Type:`, `:Content:`) is ignored
with a warning, the block's value standing; any other
key (`parts`, `docname`, `arch`, and the directive's own `delete` and
`title_from_content`) is ignored with a warning at its line;
an empty `:id:` is refused with the directive's message
(`invalid_option:`). This also keeps a `:parts:` option away from the
  record-seam crash #2165.
- `needimport`'s two rules: a block rendered through a template (its
own, or the project's default/predicate) is parsed as the
page's markup; a markup no parser claims falls back to the page's markup
-- still anchored at the body's source line, unless the
page's own parser is neither reStructuredText nor MyST, when the body is
parsed by that parser and its warnings sit at the page --,
`doctype` kept, one warning per directive and markup (none for a hidden
or blank block).
- Every warning about a block's own content or options — the analyser's
included — is `codelinks.multiline_need`; a skipped duplicate
shares `codelinks.duplicate_need` with one-line needs; the fallback and
floor warnings sit at the directive.
- `ingest_need_record` is Sphinx-Needs 9.0.0's; the floor stays
`>=8.5.0,<9` and the import is guarded: without it a directive warns
once and creates its one-line needs (removed at the 9.0.0 floor bump,
with `_make_hashed_id`).
- Reference side (the first five commits, splittable, with the fix
round's `eaff9a4f` + `20a5f6d7` + `94e712b3` for the `get_rst`
warning's count, text and project name, and the non-empty suffix): the
shared corpus header now says ubCode compares records in production
shape;
`<type>` and option keys are ASCII word characters on both sides
(`[A-Za-z0-9_-]+`; Python's and Rust's `\w` differ on combining
marks) with two shared cases; 13 shared cases for rules no case pinned;
a `markups` suffix must not be empty; `get_rst = false`
alone is ignored with one warning per project, naming it -- at
configuration time in a build, per analysed project in the CLI --
saying it is no longer read and can be removed (a configuration scaffold
wrote it into shared `ubproject.toml` files).

## Why

#1885 asked whether marked-rst blocks rendered: they never did —
`get_rst` extracted them and `src-trace` ignored them. Their
replacement, multi-line needs (#2152), was extracted and dropped the
same way, waiting for Sphinx-Needs to parse a need's content in
its declared markup (#2135, `add_need(content=MarkupContent(…))` /
`ingest_need_record`). This renders them. The docs gain the example
#1885 asked for (a traced file with one block) and list what a body
should not contain until #2130 refuses it.

## ubCode parity

Measured on this branch (Sphinx 9.1 / myst-parser 5.1; Sphinx 7.4 /
myst-parser 4.0 where noted); ubCode side = ubcode#3915's rulings.

| behaviour | ubCode (#3915) | Sphinx (this PR) | register row? |
|---|---|---|---|
| body markup | the block's `doctype` via `[parse.parsers.*]` | the
block's `doctype`, exact `source_suffix` key, always | residual: exact
suffix vs case-insensitive longest match (clb2's row) |
| unclaimed doctype | warn once per source, host parser | warn once per
directive per markup at the directive, page parser, `doctype` kept |
cadence (clb2's row) |
| templated block | — | parsed as the page's markup (template and body)
| yes, if ubCode differs |
| nested need directive in a body | fenced | not fenced (a nested need
is created, recorded at the page) | yes — until #2130 item 3 |
| file-reading directives / raw / images | denied (c2) / off (c1) | not
refused; `include` resolves against the page | yes — until #2130 items
1–2 |
| host lexical environment | none | the page's substitutions and
`rst_prolog` | existing needimport row |
| labels in a body | register with the page | register with the page;
duplicate → Sphinx's `duplicate label` at the source line | none |
| diagnostics anchoring | source file + line | docutils/Sphinx messages
at source file + line (absolute path), the unclaimed-markup fallback
included (not on a page of a third parser); a body rendered through a
template or with `jinja_content` at the need (page, directive line —
Sphinx-Needs anchors rendered text there); myst-parser's own at the page
+ line (myst 5) / `<source>.rst` (myst 4) | yes for myst-logged — until
#2130 item 4; yes for templated/Jinja bodies |
| need location | directive's | `docname` = page, `lineno` = directive
line | none |
| `doctype` | block's | block's | none |
| option conversion | "exactly as a directive's options" | the
directive's option set and conversion; option keys are lowercased as
docutils does (ubCode #3915 mirrors); other keys warned at their line,
need kept; empty `:id:` refused | none |
| unknown type / unknown option | open line / option line | open line
(`invalid_type`) / option line | none (texts differ) |
| duplicate id | (not stated) | one need per id, earlier `(row, file,
column)` wins, warning at the skipped one's line naming its kind | check
when #3915 lands |
| URL / source link | line start | open line for `local-url`,
`remote-url`, `[docs]` | none |
| `<type>` / option key class | same as ubCode's producer branch (#3914,
in review) | ASCII `[A-Za-z0-9_-]+` (shared cases) | none once #3914
merges |

## Tests

- `tests/test_multiline_needs_render.py` (40 cases): the four page×body
cells (HTML + `needs.json`), body diagnostics (OS
separators), the option table (19 rows) + empty id + upper-case id +
unknown type, duplicates across kinds + generated id, card
order (two cases), `-j 2`, incremental, analyser subtype,
unclaimed-markup fallback with its source anchor on an RST and a MyST
page and on a page of a third parser, three template routes, the id
predicted with `:full_title:`, the floor guard (with and
  without blocks).
- 15 new shared extraction cases (30 snapshots); `get_rst = false` cases
in `test_analyse_config.py` (converter silent, a two-project
build warning twice fresh and incremental and naming each project, an
empty `markups` suffix, `get_rst = 0` refused) and
  `test_cmd.py` (every project / `-p`).
- No existing test or snapshot changed. 25 mutations, each red against a
named pin, plus reviewers' 41 and the loop-ender's 4.
- `poe test-codelinks`: 941 passed, 0 skipped (sphinx-9 and sphinx-7
cells); `poe import-check-codelinks` green at sphinx-needs 8.5.0;
  `poe docs-codelinks` 0 warnings.

Closes #1885
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: sphinx-codelinks Concerns the sphinx-codelinks package (packages/sphinx-codelinks)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants