Step 574: add markdown spec parser

This commit is contained in:
Bill
2026-02-17 11:00:57 -07:00
parent 1966b7ee44
commit b249920685
4 changed files with 364 additions and 0 deletions

View File

@@ -4045,4 +4045,13 @@ target_link_libraries(step573_test PRIVATE
tree_sitter_javascript tree_sitter_typescript
tree_sitter_java tree_sitter_rust tree_sitter_go)
add_executable(step574_test tests/step574_test.cpp)
target_include_directories(step574_test PRIVATE src)
target_link_libraries(step574_test PRIVATE
nlohmann_json::nlohmann_json
unofficial::tree-sitter::tree-sitter
tree_sitter_python tree_sitter_cpp tree_sitter_elisp
tree_sitter_javascript tree_sitter_typescript
tree_sitter_java tree_sitter_rust tree_sitter_go)
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)

View File

@@ -0,0 +1,142 @@
#pragma once
// Step 574: Markdown Spec Parser
#include <algorithm>
#include <cctype>
#include <sstream>
#include <string>
#include <vector>
struct ParsedRequirementItem {
std::string text;
std::string sectionTitle;
std::string anchor;
int line = 0;
};
struct ParsedSection {
std::string title;
std::string anchor;
int headingLine = 0;
std::vector<std::string> content;
};
struct ParsedMarkdownSpec {
std::vector<ParsedSection> sections;
std::vector<ParsedRequirementItem> goals;
std::vector<ParsedRequirementItem> constraints;
std::vector<ParsedRequirementItem> dependencies;
std::vector<ParsedRequirementItem> acceptanceCriteria;
};
class MarkdownSpecParser {
public:
static bool parse(const std::string& markdown,
ParsedMarkdownSpec* outSpec,
std::string* error) {
if (!outSpec || !error) return false;
error->clear();
if (markdown.empty()) {
*error = "markdown_empty";
return false;
}
ParsedMarkdownSpec spec;
ParsedSection currentSection;
bool hasActiveSection = false;
std::istringstream stream(markdown);
std::string line;
int lineNo = 0;
while (std::getline(stream, line)) {
++lineNo;
if (startsWith(line, "## ")) {
if (hasActiveSection) spec.sections.push_back(currentSection);
currentSection = ParsedSection{};
currentSection.title = trim(line.substr(3));
currentSection.anchor = makeAnchor(currentSection.title);
currentSection.headingLine = lineNo;
hasActiveSection = true;
continue;
}
if (!hasActiveSection) continue;
currentSection.content.push_back(line);
if (!startsWith(trimLeft(line), "- ")) continue;
ParsedRequirementItem item;
item.text = trim(trimLeft(line).substr(2));
item.sectionTitle = currentSection.title;
item.anchor = currentSection.anchor;
item.line = lineNo;
if (item.text.empty()) continue;
const std::string lowerTitle = toLower(currentSection.title);
if (contains(lowerTitle, "goal")) {
spec.goals.push_back(item);
} else if (contains(lowerTitle, "constraint")) {
spec.constraints.push_back(item);
} else if (contains(lowerTitle, "dependenc")) {
spec.dependencies.push_back(item);
} else if (contains(lowerTitle, "acceptance")) {
spec.acceptanceCriteria.push_back(item);
}
}
if (hasActiveSection) spec.sections.push_back(currentSection);
if (spec.sections.empty()) {
*error = "no_sections_found";
return false;
}
*outSpec = spec;
return true;
}
private:
static bool startsWith(const std::string& text, const std::string& prefix) {
return text.rfind(prefix, 0) == 0;
}
static bool contains(const std::string& text, const std::string& needle) {
return text.find(needle) != std::string::npos;
}
static std::string toLower(const std::string& value) {
std::string out = value;
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return out;
}
static std::string trimLeft(const std::string& value) {
std::size_t i = 0;
while (i < value.size() && std::isspace(static_cast<unsigned char>(value[i]))) ++i;
return value.substr(i);
}
static std::string trim(const std::string& value) {
std::size_t begin = 0;
while (begin < value.size() && std::isspace(static_cast<unsigned char>(value[begin]))) ++begin;
std::size_t end = value.size();
while (end > begin && std::isspace(static_cast<unsigned char>(value[end - 1]))) --end;
return value.substr(begin, end - begin);
}
static std::string makeAnchor(const std::string& title) {
std::string anchor;
bool previousDash = false;
for (char c : toLower(title)) {
if (std::isalnum(static_cast<unsigned char>(c))) {
anchor.push_back(c);
previousDash = false;
} else if (!previousDash) {
anchor.push_back('-');
previousDash = true;
}
}
while (!anchor.empty() && anchor.front() == '-') anchor.erase(anchor.begin());
while (!anchor.empty() && anchor.back() == '-') anchor.pop_back();
return anchor.empty() ? "section" : anchor;
}
};

View File

@@ -0,0 +1,178 @@
// Step 574: Markdown Spec Parser (12 tests)
#include "MarkdownSpecParser.h"
#include <iostream>
static int passed = 0, failed = 0;
#define TEST(name) { std::cout << " " << #name << "... "; }
#define PASS() { std::cout << "PASS\n"; ++passed; }
#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; }
#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {}
static const char* kSample = R"(# Product Spec
## Goals
- Improve parser reliability
- Preserve traceability
## Constraints
- Must remain header-only
- Keep tests deterministic
## Dependencies
- CMake
- Tree-sitter runtime
## Acceptance Criteria
- Extract all goal bullets
- Include section anchor and source line
)";
void test_parse_sample_success() {
TEST(parse_sample_success);
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(kSample, &spec, &error), "parse should succeed");
CHECK(spec.sections.size() == 4, "section count mismatch");
PASS();
}
void test_parse_extracts_goals() {
TEST(parse_extracts_goals);
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(kSample, &spec, &error), "parse should succeed");
CHECK(spec.goals.size() == 2, "goal count mismatch");
CHECK(spec.goals[0].text == "Improve parser reliability", "first goal mismatch");
PASS();
}
void test_parse_extracts_constraints() {
TEST(parse_extracts_constraints);
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(kSample, &spec, &error), "parse should succeed");
CHECK(spec.constraints.size() == 2, "constraint count mismatch");
CHECK(spec.constraints[1].text == "Keep tests deterministic", "second constraint mismatch");
PASS();
}
void test_parse_extracts_dependencies() {
TEST(parse_extracts_dependencies);
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(kSample, &spec, &error), "parse should succeed");
CHECK(spec.dependencies.size() == 2, "dependency count mismatch");
CHECK(spec.dependencies[0].text == "CMake", "first dependency mismatch");
PASS();
}
void test_parse_extracts_acceptance_criteria() {
TEST(parse_extracts_acceptance_criteria);
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(kSample, &spec, &error), "parse should succeed");
CHECK(spec.acceptanceCriteria.size() == 2, "acceptance count mismatch");
CHECK(spec.acceptanceCriteria[1].text == "Include section anchor and source line", "acceptance text mismatch");
PASS();
}
void test_anchor_generation_from_heading() {
TEST(anchor_generation_from_heading);
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(kSample, &spec, &error), "parse should succeed");
CHECK(spec.sections[0].anchor == "goals", "goals anchor mismatch");
CHECK(spec.sections[3].anchor == "acceptance-criteria", "acceptance anchor mismatch");
PASS();
}
void test_requirement_items_include_traceability_fields() {
TEST(requirement_items_include_traceability_fields);
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(kSample, &spec, &error), "parse should succeed");
CHECK(spec.goals[0].sectionTitle == "Goals", "section title mismatch");
CHECK(spec.goals[0].anchor == "goals", "anchor mismatch");
CHECK(spec.goals[0].line > 0, "line should be positive");
PASS();
}
void test_ignores_bullets_outside_sections() {
TEST(ignores_bullets_outside_sections);
const char* markdown = R"(- orphan bullet
## Goals
- valid goal
)";
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(markdown, &spec, &error), "parse should succeed");
CHECK(spec.goals.size() == 1, "orphan bullet should be ignored");
PASS();
}
void test_empty_markdown_fails() {
TEST(empty_markdown_fails);
ParsedMarkdownSpec spec;
std::string error;
CHECK(!MarkdownSpecParser::parse("", &spec, &error), "empty markdown should fail");
CHECK(error == "markdown_empty", "wrong error");
PASS();
}
void test_markdown_without_sections_fails() {
TEST(markdown_without_sections_fails);
ParsedMarkdownSpec spec;
std::string error;
CHECK(!MarkdownSpecParser::parse("# Title\n- bullet", &spec, &error), "markdown without sections should fail");
CHECK(error == "no_sections_found", "wrong error");
PASS();
}
void test_whitespace_in_bullets_is_trimmed() {
TEST(whitespace_in_bullets_is_trimmed);
const char* markdown = R"(## Goals
- spaced goal text
)";
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(markdown, &spec, &error), "parse should succeed");
CHECK(spec.goals.size() == 1, "goal count mismatch");
CHECK(spec.goals[0].text == "spaced goal text", "goal text should be trimmed");
PASS();
}
void test_subsection_heading_without_double_hash_is_ignored_for_section_split() {
TEST(subsection_heading_without_double_hash_is_ignored_for_section_split);
const char* markdown = R"(## Goals
### Details
- goal one
)";
ParsedMarkdownSpec spec;
std::string error;
CHECK(MarkdownSpecParser::parse(markdown, &spec, &error), "parse should succeed");
CHECK(spec.sections.size() == 1, "only one top-level section expected");
CHECK(spec.goals.size() == 1, "goal should still be parsed");
PASS();
}
int main() {
std::cout << "Step 574: Markdown Spec Parser\n";
test_parse_sample_success(); // 1
test_parse_extracts_goals(); // 2
test_parse_extracts_constraints(); // 3
test_parse_extracts_dependencies(); // 4
test_parse_extracts_acceptance_criteria(); // 5
test_anchor_generation_from_heading(); // 6
test_requirement_items_include_traceability_fields(); // 7
test_ignores_bullets_outside_sections(); // 8
test_empty_markdown_fails(); // 9
test_markdown_without_sections_fails(); // 10
test_whitespace_in_bullets_is_trimmed(); // 11
test_subsection_heading_without_double_hash_is_ignored_for_section_split(); // 12
std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n";
return failed == 0 ? 0 : 1;
}

View File

@@ -10556,3 +10556,38 @@ all components header-only and within file-size constraints.
- `editor/src/TimeTravelDebugEventBuffer.h` within header-size limit (`86` <= `600`)
- `editor/src/PerformanceProbeOverlay.h` within header-size limit (`110` <= `600`)
- Shared validation centralization reduces repeated primitive guards and keeps module boundaries aligned with `ARCHITECTURE.md`
### Step 574: Markdown Spec Parser
**Status:** PASS (12/12 tests)
Implements markdown intake parsing for architect specs, including section
extraction, categorized requirement bullets, and source-anchor traceability.
**Files added:**
- `editor/src/MarkdownSpecParser.h` - markdown parser module:
- top-level section extraction (`##` headings)
- section anchor generation for traceability
- categorized bullet extraction (goals/constraints/dependencies/acceptance)
- source line capture per extracted requirement
- whitespace normalization and guard handling
- `editor/tests/step574_test.cpp` - 12 tests covering:
- section/category extraction behavior
- anchor/traceability field behavior
- orphan bullet handling behavior
- empty/no-section failure behavior
- bullet text trimming behavior
- subsection-heading handling behavior
**Files modified:**
- `editor/CMakeLists.txt` - `step574_test` target
**Verification run:**
- `cmake -S editor -B editor/build-native` - PASS
- `cmake --build editor/build-native --target step574_test step573_test` - PASS
- `./editor/build-native/step574_test` - PASS (12/12)
- `./editor/build-native/step573_test` - PASS (8/8) regression coverage
**Architecture gate check:**
- `editor/src/MarkdownSpecParser.h` within header-size limit (`142` <= `600`)
- `editor/tests/step574_test.cpp` within test-file size guidance (`178` lines)
- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`