Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/source/components/analyse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Limitations

**Current Limitations:**

- **Language Support**: C/C++ (``//``, ``/* */``), C# (``//``, ``/* */``, ``///``), Python (``#``), YAML (``#``), Rust (``//``, ``/* */``, ``///``), Go (``//``, ``/* */``), JSONC (``//``, ``/* */``) and Bash (``#``) comment styles are supported
- **Language Support**: C/C++ (``//``, ``/* */``), C# (``//``, ``/* */``, ``///``), Python (``#``), YAML (``#``), Rust (``//``, ``/* */``, ``///``), Go (``//``, ``/* */``), JSONC (``//``, ``/* */``), Bash (``#``) and Markdown (``<!-- … -->``) comment styles are supported
- **Single Comment Style**: Each analysis run processes only one comment style at a time

Extraction Examples
Expand Down
6 changes: 5 additions & 1 deletion docs/source/components/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ Specifies the comment syntax style used in the source code files. This determine

**Type:** ``str``
**Default:** ``"cpp"``
**Supported values:** ``"cpp"``, ``"python"``, ``"cs"``, ``"yaml"``, ``"rust"``, ``"go"``, ``"jsonc"``, ``"bash"``
**Supported values:** ``"cpp"``, ``"python"``, ``"cs"``, ``"yaml"``, ``"rust"``, ``"go"``, ``"jsonc"``, ``"bash"``, ``"markdown"``

.. code-block:: toml

Expand Down Expand Up @@ -330,6 +330,10 @@ Specifies the comment syntax style used in the source code files. This determine
- ``"bash"``
- ``#`` (single-line)
- ``.sh``, ``.bash``, ``.zsh``, ``.ksh``
* - Markdown
- ``"markdown"``
- ``<!-- … -->`` (HTML-comment block)
- ``.md``, ``.markdown``

.. note:: Future versions may support additional programming languages.

Expand Down
35 changes: 35 additions & 0 deletions docs/source/components/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,41 @@ Features
.. fault:: Sphinx-codelinks hallucinates traceability objects in Bash
:id: FAULT_BASH_2

.. feature:: Markdown Language Support
:id: FE_MARKDOWN

Support for defining traceability objects in Markdown files using
HTML-comment markers (``<!-- @needs … -->``).

The Markdown language parser leverages tree-sitter to identify and extract
standalone HTML-comment blocks from Markdown documents, enabling
requirements traceability in agent definition files, skill files, and other
Markdown-based implementation artefacts.

``.md`` and ``.markdown`` files are auto-discovered when
``comment_type = "markdown"``. Because tree-sitter-markdown exposes
standalone HTML comments as ``html_block`` nodes, only block-level
``<!-- … -->`` markers are captured; inline HTML comments inside paragraphs
are not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This claim is inaccurate and understates the false-positive surface.

"Because tree-sitter-markdown exposes standalone HTML comments as html_block nodes, only block-level <!-- … --> markers are captured" — html_block is not comment-specific. <div>, <details>, <table>, <script> and <br/> blocks are all captured and scanned for markers (verified). What's true is the narrower statement that only block-level HTML is captured; inline HTML is not.

Either fix the query (see the comment on MARKDOWN_QUERY) and keep this sentence, or state plainly that all block-level HTML is scanned so users understand why <details> sections can produce phantom need IDs.


Key capabilities:

* HTML-comment (``<!-- … -->``) detection via tree-sitter
* Auto-discovery of ``.md`` and ``.markdown`` files

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Auto-discovering .md makes source-page names collide with real documents.

generate_code_page derives the generated page name by stripping the suffix:

pagename = str((file_path.relative_to(app.outdir)).with_suffix(""))

Until now no discoverable extension was also a Sphinx source extension. With .md discoverable, tracing README.md yields pagename README; in a MyST project (or any project with .md documents), html-collect-pages runs after the normal pages are written, so the source-tracing page silently overwrites the real document's HTML. Worth either documenting "don't point src_dir at your doc sources" or namespacing the generated pagename.

* Oneline-only mode (no scope association needed)

.. note::

Because the captured node text includes the full ``<!-- … -->``
delimiters, callers must set ``end_sequence: " -->"`` (not the default
``"\n"``) in their ``oneline_comment_style`` config.

.. fault:: Traceability objects are not detected in Markdown
:id: FAULT_MARKDOWN_1

.. fault:: Sphinx-codelinks hallucinates traceability objects in Markdown
:id: FAULT_MARKDOWN_2

.. feature:: Preprocessor-Aware C/C++ Extraction
:id: FE_PREPROC

Expand Down
15 changes: 15 additions & 0 deletions docs/source/development/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
Changelog
=========

Unreleased
----------

New and Improved
................

- ✨ Added Markdown language support for the ``analyse`` module.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Commit and PR titles don't follow the documented format.

AGENTS.md, Commit Message Format: <EMOJI> <KEYWORD>: Summarize in 72 chars or less (#<PR>) with keywords ✨ NEW: / 🐛 FIX: / 🧪 TEST: …, and PR Title and Description Format: "Use the same as for the commit message format, but for the title you can omit the KEYWORD and only use EMOJI".

This branch uses Conventional Commits instead:

  • 1c0402c feat(markdown): add CommentType.markdown backed by tree-sitter-markdown
  • 09585c9 test(markdown): add tests, fixtures, docs following PR #92 pattern
  • PR title: feat(markdown): add CommentType.markdown backed by tree-sitter-markdown

Also, 1c0402c's body still carries the unfilled placeholder Closes #NNN. (the PR description says Closes #94).


Standalone HTML-comment blocks (``<!-- @needs … -->``) in Markdown files are
now parsed for need ID references and one-line need definitions. ``.md`` and
``.markdown`` files are discovered when ``comment_type = "markdown"``.
Because ``tree-sitter-markdown`` exposes HTML comments as ``html_block``
nodes, callers must set ``end_sequence: " -->"`` in their
``oneline_comment_style`` configuration.

.. _`release:1.4.0`:

1.4.0
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies = [
"tree-sitter-go>=0.23.0",
"tree-sitter-json>=0.24.8",
"tree-sitter-bash>=0.25.1",
"tree-sitter-markdown>=0.5.1",
]

[project.optional-dependencies]
Expand Down
16 changes: 15 additions & 1 deletion src/sphinx_codelinks/analyse/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
},
# @Bash Scope Node Types, IMPL_BASH_2, impl, [FE_BASH]
CommentType.bash: {"function_definition"},
# Markdown has no function/class scopes relevant for code traceability.
# oneline markers in Markdown are always standalone html_block nodes;
# scope association (find_enclosing_scope / find_next_scope) is never
# invoked when get_oneline_needs=True and get_need_id_refs=False.
# CommentType.markdown is intentionally absent from this table.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This justification is factually wrong: scope association is invoked for Markdown, on every comment, regardless of those flags.

SourceAnalyse.extract_marked_content calls it unconditionally before looking at either flag (analyse.py):

tagged_scope = utils.find_associated_scope(
    src_comment.node, self.analyse_config.comment_type
)
if self.analyse_config.get_need_id_refs:
    ...
if self.analyse_config.get_oneline_needs:
    ...

Because CommentType.markdown is absent from this table, find_next_scope/find_enclosing_scope silently fall back to the C++ scope set:

scope_types = SCOPE_NODE_TYPES.get(comment_type, SCOPE_NODE_TYPES[CommentType.cpp])

so every html_block is walked to the end of the document looking for function_definition/class_definition. It returns None only because the Markdown grammar happens to have no node with those names — correct by luck, not by design, and O(blocks²) on a Markdown-heavy tree. An explicit CommentType.markdown: set() entry (or short-circuiting find_associated_scope for Markdown, as it already does for YAML/JSONC) would be both honest and cheaper.

}

logger = get_logger(__name__)
Expand Down Expand Up @@ -78,6 +83,10 @@
JSONC_QUERY = """(comment) @comment"""
# @Bash comment query for tree-sitter, IMPL_BASH_3, impl, [FE_BASH]
BASH_QUERY = """(comment) @comment"""
# @Markdown HTML-comment query for tree-sitter, IMPL_MD_3, impl, [FE_MARKDOWN]
# Captures block-level HTML nodes (<!-- … -->) as @comment. Inline HTML comments
# inside paragraphs are not captured — only standalone html_block elements.
MARKDOWN_QUERY = """(html_block) @comment"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(html_block) captures every block-level HTML element, not just HTML comments.

html_block is the CommonMark HTML block node: <div>, <details>, <table>, <script>, <br/> — all of them are handed to the extractor as "comments". Verified against tree-sitter-markdown==0.5.1:

<details>
<summary>@need-ids: FAKE_1</summary>
body
</details>

get_need_id_refs defaults to True in SourceAnalyseConfig (and the Sphinx path only force-sets get_oneline_needs), so the above yields a need-ID reference with id FAKE_1</summary> — a phantom link with HTML markup baked into the ID, which is exactly FAULT_MARKDOWN_2. Same for <script>var x = 1; // @need-ids: REQ_9</script> → ref REQ_9.

A #match? predicate fixes it (verified working on the pinned tree-sitter~=0.25.1):

Suggested change
MARKDOWN_QUERY = """(html_block) @comment"""
MARKDOWN_QUERY = r"""((html_block) @comment (#match? @comment "^[ \t]*<!--"))"""


# JSON value node types that can be associated with a comment.
JSON_STRUCTURE_TYPES = {
Expand Down Expand Up @@ -107,7 +116,7 @@ def is_text_file(filepath: Path, sample_size: int = 2048) -> bool:
return False


# @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST, FE_GO, FE_JSONC, FE_BASH]
# @Tree-sitter parser initialization for multiple languages, IMPL_LANG_1, impl, [FE_C_SUPPORT, FE_CPP, FE_PY, FE_YAML, FE_RUST, FE_GO, FE_JSONC, FE_BASH, FE_MARKDOWN]
def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:
if comment_type == CommentType.cpp:
import tree_sitter_cpp # noqa: PLC0415
Expand Down Expand Up @@ -149,6 +158,11 @@ def init_tree_sitter(comment_type: CommentType) -> tuple[Parser, Query]:

parsed_language = Language(tree_sitter_bash.language())
query = Query(parsed_language, BASH_QUERY)
elif comment_type == CommentType.markdown:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ninth branch on a chain that a table would replace.

Adding a language now means touching five places: COMMENT_FILETYPE, CommentType, SCOPE_NODE_TYPES, a *_QUERY constant, and this if/elif chain — and the chain is at 10 branches against ruff's PLR0912 limit of 12, so the next two languages break it.

A single registry keeps the per-language knowledge in one row and makes "no scope types" an explicit value rather than a missing key:

LANGUAGE_REGISTRY = {
    CommentType.markdown: ("tree_sitter_markdown", MARKDOWN_QUERY, frozenset()),
    ...
}

with init_tree_sitter doing one importlib.import_module + Query(...) lookup. Not required for this PR, but this is the third language added on the same chain.

import tree_sitter_markdown # noqa: PLC0415

parsed_language = Language(tree_sitter_markdown.language())
query = Query(parsed_language, MARKDOWN_QUERY)
else:
raise ValueError(f"Unsupported comment style: {comment_type}")
parser = Parser(parsed_language)
Expand Down
8 changes: 8 additions & 0 deletions src/sphinx_codelinks/source_discover/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
# PyPI, so it cannot be wired in here. Track fish separately if a PyPI
# grammar becomes available.
"bash": ["sh", "bash", "zsh", "ksh"],
# Markdown uses block-level HTML comments `<!-- @needs … -->` as traceability
# markers. tree-sitter-markdown captures them as `html_block` nodes.
# NOTE: because the node text includes the `<!-- … -->` delimiters, callers
# must set `end_sequence: " -->"` (not the default `"\n"`) in their
# oneline_comment_style config to prevent `-->` from leaking into parsed fields.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mandating end_sequence: " -->" silently opts Markdown out of the #88 prose anchor, so HTML comments containing prose hallucinate needs.

oneline_parser only anchors a marker to the start of the comment when end_sequence == UNIX_NEWLINE:

if oneline_config.end_sequence == UNIX_NEWLINE and any(
    char.isalnum() for char in oneline[:start_idx]
):
    return None

With " -->" that guard never runs, so every @ anywhere inside an HTML comment is parsed. Verified end-to-end:

  • <!-- reviewers: @alice, @bob, please check --> → need {title: "alice", id: "@bob", type: "please check"} — a bogus ID that raises InvalidNeedException in Sphinx-Needs (the exact failure the 1.4.0 "Anchor newline-terminated one-line markers" fix was written for).
  • <!-- @format --> (the Prettier pragma, common in Markdown) → too_few_fields warning, which fails sphinx-build -nW.

Markdown files are the most prose-heavy input this project accepts and .md discovery sweeps every README.md/CHANGELOG.md under src_dir, so this is a high-volume false-positive source. Consider extending the anchor check to treat a delimiter-shaped end_sequence as line-anchored too, or strip <!--/--> in the Markdown path so the default newline end_sequence keeps working.

"markdown": ["md", "markdown"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing enforces the end_sequence requirement, so the default config silently corrupts need fields.

A user who configures only comment_type = "markdown" keeps end_sequence = "\n", and generate_project_configs force-enables get_oneline_needs. Verified results:

  • <!-- @Md Title, IMPL_MD, impl --> → need with type == "impl -->" — silently corrupted, no warning.
  • <!-- @Md Title, IMPL_MD, impl, [REQ_MD] -->not_start_or_end_with_square_brackets warning, marker dropped.

A comment in COMMENT_FILETYPE and a .. note:: in the docs are the only things standing between a user and corrupted traceability data. This deserves enforcement in code rather than prose — e.g. a check in SourceAnalyseConfig.check_fields_configuration() that errors when comment_type is CommentType.markdown and oneline_comment_style.end_sequence == UNIX_NEWLINE, or a per-language default end_sequence.

}


Expand All @@ -35,6 +41,8 @@ class CommentType(str, Enum):
jsonc = "jsonc"
# @Support Bash style comments, IMPL_BASH_1, impl, [FE_BASH];
bash = "bash"
# @Support Markdown HTML-comment style, IMPL_MD_1, impl, [FE_MARKDOWN]
markdown = "markdown"


class SourceDiscoverSectionConfigType(TypedDict, total=False):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"needs": [
{
"id": "IMPL_MD",
"title": "Md Title",
"type": "impl",
"links": {
"links": [
"REQ_MD"
]
},
"metadata": {},
"line": 1
}
],
"need_refs": [],
"marked_rst": [],
"warnings": []
}
7 changes: 7 additions & 0 deletions tests/data/extraction/oneline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,10 @@ shebang_oneliner_bash:
#!/bin/bash
# @Bash Title, IMPL_BASH_SHEBANG, impl, [REQ_BASH]
function greet { echo hi; }

default_oneliner_markdown:
lang: markdown
config:
end_sequence: " -->"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

end_sequence: " -->" requires exactly one space before -->; ...--> silently extracts nothing.

<!-- @A Title, IMPL_A, impl, [R1]--> is valid HTML and a very common way to write the closing delimiter, but oneline.rfind(" -->") returns -1, so oneline_parser returns None: zero needs, zero warnings, no diagnostic. Verified against the real pipeline.

Same class of silent loss when two markers share a line: <!-- @A ... --> <!-- @B ... -->rfind takes the last -->, both comments merge into one field list and you get a spurious too_many_fields warning instead of two needs.

Worth a fixture for the no-space form so the sharp edge is at least pinned by a test.

source: |
<!-- @Md Title, IMPL_MD, impl, [REQ_MD] -->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Multi-line HTML comment blocks extract nothing under the mandated end_sequence: " -->".

The natural way to declare several Markdown markers is one HTML comment holding several lines:

<!--
@A Title, IMPL_A, impl, [R1]
@B Title, IMPL_B, impl, [R2]
-->

extract_oneline_need parses the html_block text line by line; no inner line contains " -->" and the closing --> line contains no @, so the result is 0 needs and 0 warnings. With the default end_sequence: "\n" the same input yields both needs correctly (verified).

So the two Markdown comment forms are mutually exclusive per project config — one-line markers need " -->", multi-line blocks need "\n" — and picking the documented one makes the other fail silently. This is the real argument for handling the <!-- … --> delimiters inside the Markdown path instead of pushing end_sequence onto callers.

48 changes: 48 additions & 0 deletions tests/test_analyse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import tree_sitter_cpp
import tree_sitter_go
import tree_sitter_json
import tree_sitter_markdown
import tree_sitter_python
import tree_sitter_rust
import tree_sitter_yaml
Expand Down Expand Up @@ -84,6 +85,53 @@ def init_bash_tree_sitter() -> tuple[Parser, Query]:
return parser, query


@pytest.fixture(scope="session")
def init_markdown_tree_sitter() -> tuple[Parser, Query]:
parsed_language = Language(tree_sitter_markdown.language())
query = Query(parsed_language, utils.MARKDOWN_QUERY)
parser = Parser(parsed_language)
return parser, query


@pytest.mark.parametrize(
("code", "expected_count"),
[
# standalone block HTML comment is captured
(
b"<!-- @Md Title, IMPL_MD, impl, [REQ_MD] -->\n",
1,
),
# multiple HTML comment blocks are each captured
(
b"<!-- @Md1, IMPL_MD_1, impl, [REQ_1] -->\n\nSome paragraph.\n\n<!-- @Md2, IMPL_MD_2, impl, [REQ_2] -->\n",
2,
),
# paragraph text without HTML comment produces no comments
(
b"# Heading\n\nJust a paragraph with no markers.\n",
0,
),
],
)
def test_extract_comments_markdown(code, expected_count, init_markdown_tree_sitter):
parser, query = init_markdown_tree_sitter
comments = utils.extract_comments(code, parser, query) or []
assert len(comments) == expected_count

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The added tests cover only the happy path; every sharp edge of this feature is untested.

AGENTS.md: "Test coverage: Write tests for all new functionality and bug fixes." The four behaviours most likely to bite users all pass through untested:

  1. a non-comment html_block (<div>, <details>, <script>) being captured as a comment;
  2. [R1]--> (no space before the delimiter) silently extracting nothing;
  3. a multi-line <!-- … --> block extracting nothing under the documented end_sequence;
  4. the default end_sequence leaking " -->" into a str field.

Cases 2–4 are one-line additions to tests/data/extraction/oneline.yaml; case 1 fits here as an expected_count row.



@pytest.mark.parametrize(
"code",
[
b"<!-- @Md Title, IMPL_MD, impl, [REQ_MD] -->\n",
],
)
def test_init_tree_sitter_markdown(code):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nits in the new tests.

  • @pytest.mark.parametrize("code", [ ...one value... ]) over a single input adds a parametrize id for no benefit — inline the bytes literal.
  • comments = utils.extract_comments(...) or [] in test_extract_comments_markdown masks the return contract: extract_comments returns list | None, and the expected_count == 0 case passes whether it returns None or []. Assert the shape you expect instead of coercing it.
  • init_markdown_tree_sitter re-implements utils.init_tree_sitter(CommentType.markdown) (same language, same query); the second test already proves the real factory works, so the fixture only adds a second construction path that can drift.

"""init_tree_sitter returns a working parser/query pair for markdown."""
parser, query = utils.init_tree_sitter(CommentType.markdown)
comments = utils.extract_comments(code, parser, query)
assert len(comments) == 1


@pytest.mark.parametrize(
("code", "result"),
[
Expand Down
1 change: 1 addition & 0 deletions tests/test_extraction_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"go": (CommentType.go, "go"),
"jsonc": (CommentType.jsonc, "jsonc"),
"bash": (CommentType.bash, "sh"),
"markdown": (CommentType.markdown, "md"),
}


Expand Down
3 changes: 2 additions & 1 deletion tests/test_source_discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"comment_type": "java",
},
[
"Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'python', 'rust', 'yaml']"
"Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'markdown', 'python', 'rust', 'yaml']"
],
),
(
Expand Down Expand Up @@ -183,6 +183,7 @@ def create_source_files(tmp_path: Path) -> Path:
("cpp", len(COMMENT_FILETYPE["cpp"])),
("python", len(COMMENT_FILETYPE["python"])),
("bash", len(COMMENT_FILETYPE["bash"])),
("markdown", len(COMMENT_FILETYPE["markdown"])),
],
)
def test_comment_filetype(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_src_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
[
"Project 'dcdc' has the following errors:",
"Schema validation error in field 'exclude': 123 is not of type 'string'",
"Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'python', 'rust', 'yaml']",
"Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'markdown', 'python', 'rust', 'yaml']",
"Schema validation error in field 'gitignore': '_true' is not of type 'boolean'",
"Schema validation error in field 'include': 345 is not of type 'string'",
"Schema validation error in field 'src_dir': ['../dcdc'] is not of type 'string'",
Expand Down
Loading