diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index f520722..c05fcdc 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -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) diff --git a/editor/src/MarkdownSpecParser.h b/editor/src/MarkdownSpecParser.h new file mode 100644 index 0000000..d1c5fbc --- /dev/null +++ b/editor/src/MarkdownSpecParser.h @@ -0,0 +1,142 @@ +#pragma once +// Step 574: Markdown Spec Parser + +#include +#include +#include +#include +#include + +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 content; +}; + +struct ParsedMarkdownSpec { + std::vector sections; + std::vector goals; + std::vector constraints; + std::vector dependencies; + std::vector 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(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(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(value[begin]))) ++begin; + std::size_t end = value.size(); + while (end > begin && std::isspace(static_cast(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(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; + } +}; diff --git a/editor/tests/step574_test.cpp b/editor/tests/step574_test.cpp new file mode 100644 index 0000000..27b6b08 --- /dev/null +++ b/editor/tests/step574_test.cpp @@ -0,0 +1,178 @@ +// Step 574: Markdown Spec Parser (12 tests) + +#include "MarkdownSpecParser.h" + +#include + +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; +} diff --git a/progress.md b/progress.md index 8c58e9f..abedb97 100644 --- a/progress.md +++ b/progress.md @@ -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`