Skip to content
Merged
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
73 changes: 43 additions & 30 deletions docs/design/modeling/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

## Overview

The three classes `Requirements`, `Section`, and `Requirement` together form the domain model for
requirement data in ReqStream. They are responsible for reading YAML files, merging hierarchical
section trees, validating data integrity, preventing infinite include loops and circular child
references, applying test mappings, and exporting content to Markdown reports.
The classes `Requirements`, `Section`, `Requirement`, and `LoadResult` together form the domain
model for requirement data in ReqStream. They are responsible for loading YAML files, merging
hierarchical section trees, validating data integrity, preventing infinite include loops and circular
child references, applying test mappings, and exporting content to Markdown reports.

## Data Model

Expand Down Expand Up @@ -34,14 +34,21 @@ references, applying test mappings, and exporting content to Markdown reports.

### `Requirements`

`Requirements` extends `Section` and acts as the root of the tree. In addition to the properties
inherited from `Section`, it maintains two private fields that span the lifetime of a load
operation:
`Requirements` extends `Section` and acts as the root of the tree.

| Field | Type | Purpose |
| ----- | ---- | ------- |
| `_includedFiles` | `HashSet<string>` | Absolute paths of files already processed; prevents infinite include loops |
| `_allRequirements` | `Dictionary<string, Requirement>` | Maps requirement ID to `Requirement`; detects duplicates |
### `LoadResult`

`LoadResult` encapsulates the outcome of a `Requirements.Load` call.

| Member | Type | Notes |
| ------ | ---- | ----- |
| `Requirements` | `Requirements?` | Parsed tree; `null` when error-level issues are present |
| `Issues` | `IReadOnlyList<LintIssue>` | All lint issues collected during loading |
| `HasErrors` | `bool` | `true` when any issue has `LintSeverity.Error` |
| `ReportIssues(context)` | `void` | Routes each issue to the context output |

`ReportIssues` accepts a `Context` argument. Warning-level issues are sent to `context.WriteLine`;
error-level issues are sent to `context.WriteError`.

## YAML Intermediate Types

Expand All @@ -55,34 +62,38 @@ YAML is deserialized into a set of intermediate types using `YamlDotNet` with th
| `YamlRequirement` | `requirements[]` entries | Contains `id`, `title`, `justification`, `tests`, `children`, `tags` |
| `YamlMapping` | `mappings[]` entries | Contains `id`, `tests` |

These intermediate types are discarded after `ReadFile` completes; the resulting `Requirement`,
These intermediate types are discarded after `LoadFile` completes; the resulting `Requirement`,
`Section`, and `Requirements` objects are the only long-lived representations.

## Methods

### `Requirements.Read(paths)`
### `Requirements.Load(paths)`

`Load` is the single static factory method on `Requirements`. It delegates to
`RequirementsLoader.Load` and returns a `LoadResult` containing:

- The parsed `Requirements` tree (or `null` if any error-level issues were found), and
- The complete list of `LintIssue` objects collected during the walk.

`Read` is the static factory method that constructs and returns a fully loaded `Requirements`
instance. It calls `ReadFile` for each supplied path to merge content into the tree, then calls
`ValidateCycles()` to confirm the child-requirement graph is acyclic before returning.
Callers that need to abort on errors check `result.HasErrors` or `result.Requirements == null`.
Callers that need to surface issues to the user call `result.ReportIssues(context)`.

### `ReadFile(path)`
### `LoadFile(path)`

`ReadFile` loads a single YAML file and merges its content into the `Requirements` tree. Four
`LoadFile` loads a single YAML file and merges its content into the `Requirements` tree. Four
design points govern its behavior:

- **Deduplication**: `path` is normalized to an absolute path and checked against `_includedFiles`
- **Deduplication**: `path` is normalized to an absolute path and checked against `visitedFiles`
before any work is done. If already present, the method returns immediately. This prevents
infinite loops when files include each other directly or transitively.
- **YAML deserialization**: the file text is deserialized into a `YamlDocument` using `YamlDotNet`
with `HyphenatedNamingConvention`. An empty or `null` document is silently accepted.
- **Validation and merging**: each section is validated (title must not be blank) and each
requirement is validated (ID and title must not be blank; ID must not duplicate an entry already
in `_allRequirements`). Validated sections are merged into the tree via `MergeSection`. Mapping
entries append additional test IDs to already-registered requirements.
seen). Validated sections are merged into the tree via `MergeSection`. Mapping entries append
additional test IDs to already-registered requirements.
- **Recursive includes**: each path in the document's `includes` block is resolved relative to the
current file's directory and passed to `ReadFile` recursively, enabling modular file
organization.
current file's directory and passed to `LoadFile` recursively, enabling modular file organization.

### `MergeSection(parent, yamlSection)`

Expand Down Expand Up @@ -111,10 +122,10 @@ references. It is called once after all files are loaded.
**Algorithm** (per requirement):

1. If the ID is in `visited`, return immediately.
2. If the ID is in `visiting`, a cycle is detected; throw `InvalidOperationException` with the
cycle path formatted as `REQ-A -> REQ-B -> ... -> REQ-A`.
2. If the ID is in `visiting`, a cycle is detected; add an error `LintIssue` with the cycle path
formatted as `REQ-A -> REQ-B -> ... -> REQ-A`.
3. Add the ID to `visiting` and `path`.
4. Recurse into each child ID present in `_allRequirements`.
4. Recurse into each child ID present in `allRequirements`.
5. Remove the ID from `visiting` and `path`; add it to `visited`.

Because `ValidateCycles` runs before any downstream analysis, `TraceMatrix.CollectAllTests` can
Expand All @@ -141,16 +152,18 @@ matching tag are included in the output.
| Test name | Blank entry in `tests` list | `Test name cannot be blank` |
| Mapping ID | Blank | `Mapping requirement ID cannot be blank` |

All validation errors throw `InvalidOperationException` and include the source file path for
actionable debugging.
All validation errors are reported as `LintSeverity.Error` `LintIssue` objects and include the
source file path for actionable debugging. When any error-level issue is present, `LoadResult.Requirements`
is `null` and `LoadResult.HasErrors` returns `true`.

## Interactions with Other Units

| Unit | Nature of interaction |
| ---- | --------------------- |
| `Program` | Calls `Requirements.Read`; passes file paths from `Context.RequirementsFiles` |
| `Program` | Calls `Requirements.Load`; passes file paths from `Context.RequirementsFiles`; |
| | calls `result.ReportIssues(context)` |
| `TraceMatrix` | Receives the populated `Requirements` root and iterates the tree |
| `Validation` | Exercises `Requirements.Read` with fixture YAML files in tests |
| `Validation` | Exercises `Requirements.Load` with fixture YAML files in tests |

## References

Expand Down
48 changes: 24 additions & 24 deletions docs/reqstream/modeling/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_SimpleRequirement_ParsesCorrectly
- Requirements_Load_SimpleRequirement_ParsesCorrectly
- Program_Run_WithRequirementsFiles_ProcessesSuccessfully
- Requirements_Read_ComplexStructure_ParsesCorrectly
- Requirements_Load_ComplexStructure_ParsesCorrectly

- id: ReqStream-Req-Validation
title: The tool shall validate requirements file structure.
Expand All @@ -24,11 +24,11 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_BlankSectionTitle_ThrowsExceptionWithFileLocation
- Requirements_Read_BlankRequirementId_ThrowsExceptionWithFileLocation
- Requirements_Read_BlankRequirementTitle_ThrowsExceptionWithFileLocation
- Requirements_Read_DuplicateRequirementId_ThrowsException
- Requirements_Read_DuplicateRequirementId_ExceptionIncludesFileLocation
- Requirements_Load_BlankSectionTitle_ThrowsExceptionWithFileLocation
- Requirements_Load_BlankRequirementId_ThrowsExceptionWithFileLocation
- Requirements_Load_BlankRequirementTitle_ThrowsExceptionWithFileLocation
- Requirements_Load_DuplicateRequirementId_ThrowsException
- Requirements_Load_DuplicateRequirementId_ExceptionIncludesFileLocation

- id: ReqStream-Req-YamlErrorReporting
title: The tool shall report YAML deserialization errors with the filename and location.
Expand All @@ -38,7 +38,7 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_InvalidYamlContent_ThrowsExceptionWithFileLocation
- Requirements_Load_InvalidYamlContent_ThrowsExceptionWithFileLocation

- id: ReqStream-Req-Hierarchy
title: The tool shall support hierarchical sections and subsections.
Expand All @@ -48,7 +48,7 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_NestedSections_ParsesHierarchyCorrectly
- Requirements_Load_NestedSections_ParsesHierarchyCorrectly
- Requirements_Export_NestedSections_CreatesHierarchy

- id: ReqStream-Req-Includes
Expand All @@ -59,9 +59,9 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_WithIncludes_MergesFilesCorrectly
- Requirements_Read_MultipleFiles_MergesAllFiles
- Requirements_Read_IncludeLoop_DoesNotCauseInfiniteLoop
- Requirements_Load_WithIncludes_MergesFilesCorrectly
- Requirements_Load_MultipleFiles_MergesAllFiles
- Requirements_Load_IncludeLoop_DoesNotCauseInfiniteLoop

- id: ReqStream-Req-SectionMerging
title: The tool shall merge sections with the same hierarchy path.
Expand All @@ -71,8 +71,8 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_IdenticalSections_MergesCorrectly
- Requirements_Read_MultipleFilesWithSameSections_MergesSections
- Requirements_Load_IdenticalSections_MergesCorrectly
- Requirements_Load_MultipleFilesWithSameSections_MergesSections

- title: Requirements Definition
requirements:
Expand All @@ -84,9 +84,9 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_DuplicateRequirementId_ThrowsException
- Requirements_Read_BlankRequirementId_ThrowsExceptionWithFileLocation
- Requirements_Read_MultipleFilesWithDuplicateIds_ThrowsException
- Requirements_Load_DuplicateRequirementId_ThrowsException
- Requirements_Load_BlankRequirementId_ThrowsExceptionWithFileLocation
- Requirements_Load_MultipleFilesWithDuplicateIds_ThrowsException

- id: ReqStream-Req-RequiredTitle
title: The tool shall require each requirement to have a title.
Expand All @@ -96,7 +96,7 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_BlankRequirementTitle_ThrowsExceptionWithFileLocation
- Requirements_Load_BlankRequirementTitle_ThrowsExceptionWithFileLocation

- id: ReqStream-Req-ParentChild
title: The tool shall support parent-child relationships between requirements.
Expand All @@ -106,7 +106,7 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_RequirementWithChildren_ParsesChildrenCorrectly
- Requirements_Load_RequirementWithChildren_ParsesChildrenCorrectly
- TraceMatrix_Export_WithChildRequirements_ConsidersChildTests

- id: ReqStream-Req-TestMappings
Expand All @@ -117,11 +117,11 @@ sections:
tags:
- requirements
tests:
- Requirements_Read_RequirementWithTests_ParsesTestsCorrectly
- Requirements_Read_BlankTestNameInRequirement_ThrowsExceptionWithFileLocation
- Requirements_Read_TestMappings_AppliesMappingsCorrectly
- Requirements_Read_BlankTestNameInMapping_ThrowsExceptionWithFileLocation
- Requirements_Read_BlankMappingId_ThrowsExceptionWithFileLocation
- Requirements_Load_RequirementWithTests_ParsesTestsCorrectly
- Requirements_Load_BlankTestNameInRequirement_ThrowsExceptionWithFileLocation
- Requirements_Load_TestMappings_AppliesMappingsCorrectly
- Requirements_Load_BlankTestNameInMapping_ThrowsExceptionWithFileLocation
- Requirements_Load_BlankMappingId_ThrowsExceptionWithFileLocation

- id: ReqStream-Req-UnifiedLoad
title: >-
Expand Down
2 changes: 1 addition & 1 deletion docs/reqstream/ots/mstest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ sections:
- Context_Create_HelpFlags_SetsHelpProperty
- Context_Create_ValidateFlag_SetsValidateProperty
- Context_Create_EnforceFlag_SetsEnforceProperty
- Requirements_Read_SimpleRequirement_ParsesCorrectly
- Requirements_Load_SimpleRequirement_ParsesCorrectly
- Program_Run_WithVersionFlag_PrintsVersion
- Program_Run_WithHelpFlag_PrintsHelp
79 changes: 79 additions & 0 deletions src/DemaConsulting.ReqStream/Modeling/LoadResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2026 DEMA Consulting
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

using DemaConsulting.ReqStream.Cli;

namespace DemaConsulting.ReqStream.Modeling;

/// <summary>
/// Encapsulates the result of loading one or more requirements YAML files, including the
/// parsed requirements tree and any lint issues found during loading.
/// </summary>
public sealed class LoadResult
{
/// <summary>
/// Initializes a new instance of the <see cref="LoadResult"/> class.
/// </summary>
/// <param name="requirements">
/// The parsed requirements tree, or <c>null</c> when error-level issues prevent successful loading.
/// </param>
/// <param name="issues">The read-only list of lint issues found during loading.</param>
internal LoadResult(Requirements? requirements, IReadOnlyList<LintIssue> issues)
{
Requirements = requirements;
Issues = issues;
}

/// <summary>
/// Gets the parsed requirements tree, or <c>null</c> when error-level issues are present.
/// </summary>
public Requirements? Requirements { get; }

/// <summary>
/// Gets the read-only list of lint issues found during loading.
/// </summary>
public IReadOnlyList<LintIssue> Issues { get; }

/// <summary>
/// Gets a value indicating whether any error-level lint issues were found during loading.
/// </summary>
public bool HasErrors => Issues.Any(i => i.Severity == LintSeverity.Error);

/// <summary>
/// Reports all lint issues to the supplied context.
/// Warning-level issues are sent to <see cref="Context.WriteLine"/>;
/// error-level issues are sent to <see cref="Context.WriteError"/>.
/// </summary>
/// <param name="context">The context to report issues to.</param>
public void ReportIssues(Context context)
{
foreach (var issue in Issues)
{
if (issue.Severity == LintSeverity.Error)
{
context.WriteError(issue.ToString());
}
else
{
context.WriteLine(issue.ToString());
}
}
}
}
27 changes: 3 additions & 24 deletions src/DemaConsulting.ReqStream/Modeling/Requirements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,38 +25,17 @@ namespace DemaConsulting.ReqStream.Modeling;
/// </summary>
public class Requirements : Section
{
/// <summary>
/// Reads one or more requirements YAML files and returns the parsed Requirements object.
/// Throws an exception if any error-level issues are found during loading.
/// </summary>
/// <param name="paths">One or more paths to YAML files to read.</param>
/// <returns>A Requirements object containing the parsed requirements from all files.</returns>
/// <exception cref="ArgumentException">Thrown when no paths are provided.</exception>
/// <exception cref="InvalidOperationException">Thrown when any error-level issue is found during loading.</exception>
public static Requirements Read(params string[] paths)
{
var (requirements, issues) = Load(paths);
if (requirements != null)
{
return requirements;
}

// Throw an exception conveying the first error-level issue
var firstError = issues.First(i => i.Severity == LintSeverity.Error);
throw new InvalidOperationException(firstError.ToString());
}

/// <summary>
/// Loads one or more requirements YAML files using a single YAML DOM tree walk that
/// simultaneously builds the requirements model and collects lint issues.
/// </summary>
/// <param name="paths">One or more paths to YAML files to load.</param>
/// <returns>
/// A tuple of the parsed <see cref="Requirements"/> (or <c>null</c> when error-level issues
/// are present) and a read-only list of <see cref="LintIssue"/> objects.
/// A <see cref="LoadResult"/> containing the parsed <see cref="Requirements"/> (or <c>null</c>
/// when error-level issues are present) and all lint issues found during loading.
/// </returns>
/// <exception cref="ArgumentException">Thrown when no paths are provided.</exception>
public static (Requirements? Requirements, IReadOnlyList<LintIssue> Issues) Load(params string[] paths)
public static LoadResult Load(params string[] paths)
{
return RequirementsLoader.Load(paths);
}
Expand Down
Loading
Loading