diff --git a/docs/design/linter.md b/docs/design/linter.md index 4d3eca0..401ac47 100644 --- a/docs/design/linter.md +++ b/docs/design/linter.md @@ -47,31 +47,41 @@ causes the process to exit with code `1`. `Lint` is the single public entry point. -1. Initialize a `Dictionary seenIds` (maps requirement ID to source file path) and +1. If `files` is empty, print `"No requirements files specified."` and return. +2. Initialize a `Dictionary seenIds` (maps requirement ID to source file path) and a `HashSet visitedFiles` shared across all files in `files`. -2. For each path in `files`, call `LintFile(context, path, seenIds, visitedFiles)`. -3. Count the total number of issues written (tracked via `context`'s error state or a local - counter). +3. For each path in `files`, accumulate the return value of `LintFile(context, path, seenIds, visitedFiles)` + into a local `issueCount`. 4. If the total issue count is zero, print `"{files[0]}: No issues found"` via `context.WriteLine`. ### `LintFile(context, path, seenIds, visitedFiles)` -`LintFile` lints a single YAML file. - -1. Resolve `path` to its full absolute path. -2. If `path` is already in `visitedFiles`, return immediately. -3. Add `path` to `visitedFiles`. -4. Read the file text. -5. Attempt to parse the text with `YamlStream.Load()`. If a `YamlException` is thrown (malformed - YAML), call `context.WriteError` with the exception message and return; do not attempt to lint - further. -6. If the stream has no documents (empty file), return immediately — empty files are valid. -7. If the root node is present but is not a `YamlMappingNode` (e.g. a top-level sequence or scalar), - emit an error at the node's position and return. -8. Call `LintDocumentRoot(context, path, root, seenIds)` with the mapping root. -9. After all documents are linted, locate the `includes:` sequence in the root mapping (if present) - and for each scalar entry call `LintFile` recursively, resolving the include path relative to - the directory of the current file. +`LintFile` lints a single YAML file and follows its `includes:` entries. + +1. Resolve `path` to its full absolute path (`fullPath`). On failure, emit an error and return `1`. +2. If `fullPath` is already in `visitedFiles`, return `0` immediately (deduplication). +3. Add `fullPath` to `visitedFiles`. +4. Verify the file exists; emit an error and return `1` if not. +5. Read the file text. On I/O failure, emit an error and return `1`. +6. Parse the text via `ParseYaml`. If a `YamlException` or `InvalidOperationException` is thrown + (malformed YAML), emit an error at the reported source position and return `1`. +7. If the parsed root is `null` (empty document), return `0` — empty files are valid. +8. If the root node is not a `YamlMappingNode`, emit a type-mismatch error and return `1`. +9. Delegate to `LintDocumentRoot(context, path, root, seenIds)`. +10. Delegate to `LintIncludes(context, fullPath, GetStringList(root, "includes"), seenIds, visitedFiles)` + to follow included files. +11. Return the accumulated issue count from steps 9–10. + +### `LintIncludes(context, parentFullPath, includes, seenIds, visitedFiles)` + +`LintIncludes` resolves and recursively lints all files listed in an `includes:` sequence. + +1. If `includes` is `null`, return `0`. +2. Derive `baseDirectory` from `parentFullPath`. +3. Filter out any blank entries (using `!string.IsNullOrWhiteSpace`). +4. For each non-blank include path, call `LintFile(context, Path.Combine(baseDirectory, include), + seenIds, visitedFiles)` and accumulate the returned issue count. +5. Return the total issue count. ### `LintDocumentRoot(context, path, root, seenIds)` @@ -79,59 +89,121 @@ causes the process to exit with code `1`. 1. For each key in the root mapping, check that it is a member of `KnownDocumentFields`; if not, emit an unknown-field error at the key's position. -2. Locate the `sections:` node. If the key exists but its value is not a `YamlSequenceNode`, emit a - type-mismatch error. Otherwise delegate to `LintSections`. -3. Locate the `mappings:` node. If the key exists but its value is not a `YamlSequenceNode`, emit a - type-mismatch error. Otherwise delegate to `LintMappings`. +2. Delegate to `LintDocumentSections(context, path, root, seenIds)`. +3. Delegate to `LintDocumentMappings(context, path, root)`. -### `LintSections(context, path, sectionsNode, seenIds)` +### `LintDocumentSections(context, path, root, seenIds)` -`LintSections` iterates a `YamlSequenceNode` of section entries and calls `LintSection` for each. +`LintDocumentSections` retrieves the `sections:` sequence from the document root and lints each +child. -### `LintSection(context, path, sectionNode, seenIds)` +1. Call `GetSequenceChecked` for the `"sections"` key on `root`. If the key is absent, return `0`. + If it is present but not a sequence, emit a type-mismatch error and return `1`. +2. Iterate `sections.Children`. For each child: -`LintSection` validates one section mapping node. + - If it is a `YamlMappingNode`, call `LintSection(context, path, child, seenIds)`. + - Otherwise emit a `"Section must be a mapping"` error. -1. Assert `sectionNode` is a `YamlMappingNode`; emit an error and return if not. -2. For each key, check against `KnownSectionFields`; emit an unknown-field error for any unknown key. -3. Check that `title` is present and non-blank; emit an error if missing or blank. -4. If `sections:` key is present but its value is not a sequence, emit a type-mismatch error; - otherwise call `LintSections` recursively. -5. If `requirements:` key is present but its value is not a sequence, emit a type-mismatch error; - otherwise call `LintRequirements`. +### `LintDocumentMappings(context, path, root)` -### `LintRequirements(context, path, requirementsNode, seenIds)` +`LintDocumentMappings` retrieves the `mappings:` sequence from the document root and lints each +child. -`LintRequirements` iterates a `YamlSequenceNode` of requirement entries and calls -`LintRequirement` for each. +1. Call `GetSequenceChecked` for the `"mappings"` key on `root`. If the key is absent, return `0`. + If it is present but not a sequence, emit a type-mismatch error and return `1`. +2. Iterate `mappings.Children`. For each child: -### `LintRequirement(context, path, requirementNode, seenIds)` + - If it is a `YamlMappingNode`, call `LintMapping(context, path, child)`. + - Otherwise emit a `"Mapping must be a mapping node"` error. -`LintRequirement` validates one requirement mapping node. +### `LintSection(context, path, section, seenIds)` -1. Assert `requirementNode` is a `YamlMappingNode`; emit an error and return if not. -2. For each key, check against `KnownRequirementFields`; emit an unknown-field error for any - unknown key. -3. Check that `id` is present and non-blank; emit an error if missing or blank. -4. If `id` is valid, check `seenIds` for a duplicate; if found, emit a duplicate-ID error - referencing both the current file position and the previously seen file. Add the ID to - `seenIds` if not already present. -5. Check that `title` is present and non-blank; emit an error if missing or blank. -6. If `tests:` is present, iterate each entry and emit an error for any blank scalar. -7. If `tags:` is present, iterate each entry and emit an error for any blank scalar. +`LintSection` validates one section mapping node. The caller (`LintDocumentSections` or +`LintSectionChildren`) has already asserted that the node is a `YamlMappingNode`. -### `LintMappings(context, path, mappingsNode, seenIds)` +1. For each key in `section`, check against `KnownSectionFields`; emit an unknown-field error for + any unknown key. +2. Check that `title` is present and non-blank via `GetScalar`; emit an error at the section start + if missing, or at the scalar start if blank. +3. Delegate to `LintSectionRequirements(context, path, section, seenIds)`. +4. Delegate to `LintSectionChildren(context, path, section, seenIds)`. -`LintMappings` iterates a `YamlSequenceNode` of mapping entries and calls `LintMapping` for each. +### `LintSectionRequirements(context, path, section, seenIds)` -### `LintMapping(context, path, mappingNode, seenIds)` +`LintSectionRequirements` retrieves the `requirements:` sequence from a section and lints each +child. -`LintMapping` validates one mapping entry. +1. Call `GetSequenceChecked` for the `"requirements"` key on `section`. If the key is absent, + return `0`. If it is present but not a sequence, emit a type-mismatch error and return `1`. +2. Iterate `requirements.Children`. For each child: -1. Assert `mappingNode` is a `YamlMappingNode`; emit an error and return if not. -2. For each key, check against `KnownMappingFields`; emit an unknown-field error for any unknown key. -3. Check that `id` is present and non-blank; emit an error if missing or blank. -4. If `tests:` is present, iterate each entry and emit an error for any blank scalar. + - If it is a `YamlMappingNode`, call `LintRequirement(context, path, child, seenIds)`. + - Otherwise emit a `"Requirement must be a mapping"` error. + +### `LintSectionChildren(context, path, section, seenIds)` + +`LintSectionChildren` retrieves the `sections:` sequence from a section and lints each child +section recursively. + +1. Call `GetSequenceChecked` for the `"sections"` key on `section`. If the key is absent, return + `0`. If it is present but not a sequence, emit a type-mismatch error and return `1`. +2. Iterate `sections.Children`. For each child: + + - If it is a `YamlMappingNode`, call `LintSection(context, path, child, seenIds)` recursively. + - Otherwise emit a `"Section must be a mapping"` error. + +### `LintRequirement(context, path, requirement, seenIds)` + +`LintRequirement` validates one requirement mapping node. The caller (`LintSectionRequirements`) +has already asserted that the node is a `YamlMappingNode`. + +1. For each key in `requirement`, check against `KnownRequirementFields`; emit an unknown-field + error for any unknown key. +2. Call `LintRequirementId(context, path, requirement, seenIds, ref issueCount)` to validate and + register the `id` field; capture the returned ID string (or `null` on error). +3. Call `LintRequirementTitle(context, path, requirement, reqId)` to validate the `title` field. +4. If `tests:` is present, find blank entries using + `.OfType().Where(s => string.IsNullOrWhiteSpace(s.Value)).Select(s => s.Start)` + and emit a `"Test name cannot be blank"` error for each. +5. If `tags:` is present, apply the same method chain and emit a `"Tag name cannot be blank"` error + for each blank entry. + +### `LintRequirementId(context, path, requirement, seenIds, ref issueCount)` + +`LintRequirementId` validates the `id` field of a requirement, checks for duplicates, and +registers the ID. + +1. Look up the `id` scalar via `GetScalar`. If absent, emit a `"Requirement missing required field + 'id'"` error (at the mapping start), increment `issueCount`, and return `null`. +2. If the scalar value is blank, emit a `"Requirement 'id' cannot be blank"` error (at the scalar + start), increment `issueCount`, and return `null`. +3. Check `seenIds` for the ID. If already present, emit a duplicate-ID error referencing the first + file, increment `issueCount`, and return `reqId` (the ID is still returned so downstream + validation can include it in error messages). +4. Register `seenIds[reqId] = path` and return the ID string. + +### `LintRequirementTitle(context, path, requirement, reqId)` + +`LintRequirementTitle` validates the `title` field of a requirement. + +1. Look up the `title` scalar via `GetScalar`. If absent, emit an error whose description uses + `"requirement '{reqId}'"` when `reqId` is non-null, or `"requirement"` otherwise (at the + mapping start), and return `1`. +2. If the scalar value is blank, emit a `"Requirement 'title' cannot be blank"` error (at the + scalar start) and return `1`. +3. Return `0`. + +### `LintMapping(context, path, mapping)` + +`LintMapping` validates one mapping entry. The caller (`LintDocumentMappings`) has already +asserted that the node is a `YamlMappingNode`. + +1. For each key in `mapping`, check against `KnownMappingFields`; emit an unknown-field error for + any unknown key. +2. Check that `id` is present and non-blank; emit an error at the mapping start if missing, or at + the scalar start if blank. +3. If `tests:` is present, apply the same blank-entry method chain used in `LintRequirement` and + emit a `"Test name cannot be blank"` error for each blank entry. ## Issue Accumulation and No-Issues Message diff --git a/src/DemaConsulting.ReqStream/Linter.cs b/src/DemaConsulting.ReqStream/Linter.cs index 73f6870..274d664 100644 --- a/src/DemaConsulting.ReqStream/Linter.cs +++ b/src/DemaConsulting.ReqStream/Linter.cs @@ -179,20 +179,38 @@ private static int LintFile( issueCount += LintDocumentRoot(context, path, root, seenIds); // Follow includes - var includes = GetStringList(root, "includes"); - if (includes != null) + issueCount += LintIncludes(context, fullPath, GetStringList(root, "includes"), seenIds, visitedFiles); + + return issueCount; + } + + /// + /// Lints all included files referenced from a parent file. + /// + /// The context for output. + /// The resolved full path of the parent file. + /// The list of include paths, or null if none. + /// Dictionary of requirement IDs already seen and the file they came from. + /// Set of files already visited to avoid re-linting. + /// The number of issues found in all included files. + private static int LintIncludes( + Context context, + string parentFullPath, + List? includes, + Dictionary seenIds, + HashSet visitedFiles) + { + if (includes == null) { - var baseDirectory = Path.GetDirectoryName(fullPath) ?? string.Empty; - foreach (var include in includes) - { - if (string.IsNullOrWhiteSpace(include)) - { - continue; - } + return 0; + } - var includePath = Path.Combine(baseDirectory, include); - issueCount += LintFile(context, includePath, seenIds, visitedFiles); - } + var baseDirectory = Path.GetDirectoryName(parentFullPath) ?? string.Empty; + var issueCount = 0; + + foreach (var include in includes.Where(includePath => !string.IsNullOrWhiteSpace(includePath))) + { + issueCount += LintFile(context, Path.Combine(baseDirectory, include), seenIds, visitedFiles); } return issueCount; @@ -247,40 +265,82 @@ private static int LintDocumentRoot( } // Lint sections + issueCount += LintDocumentSections(context, path, root, seenIds); + + // Lint mappings + issueCount += LintDocumentMappings(context, path, root); + + return issueCount; + } + + /// + /// Lints the sections sequence within a document root. + /// + /// The context for output. + /// The file path for error messages. + /// The root mapping node. + /// Dictionary of requirement IDs already seen. + /// The number of issues found. + private static int LintDocumentSections( + Context context, + string path, + YamlMappingNode root, + Dictionary seenIds) + { + var issueCount = 0; var sections = GetSequenceChecked(context, path, root, "sections", ref issueCount); - if (sections != null) + if (sections == null) { - foreach (var sectionNode in sections.Children) + return issueCount; + } + + foreach (var sectionNode in sections.Children) + { + if (sectionNode is YamlMappingNode sectionMapping) { - if (sectionNode is YamlMappingNode sectionMapping) - { - issueCount += LintSection(context, path, sectionMapping, seenIds); - } - else - { - context.WriteError( - $"{path}({sectionNode.Start.Line},{sectionNode.Start.Column}): error: Section must be a mapping"); - issueCount++; - } + issueCount += LintSection(context, path, sectionMapping, seenIds); + } + else + { + context.WriteError( + $"{path}({sectionNode.Start.Line},{sectionNode.Start.Column}): error: Section must be a mapping"); + issueCount++; } } - // Lint mappings + return issueCount; + } + + /// + /// Lints the mappings sequence within a document root. + /// + /// The context for output. + /// The file path for error messages. + /// The root mapping node. + /// The number of issues found. + private static int LintDocumentMappings( + Context context, + string path, + YamlMappingNode root) + { + var issueCount = 0; var mappings = GetSequenceChecked(context, path, root, "mappings", ref issueCount); - if (mappings != null) + if (mappings == null) { - foreach (var mappingNode in mappings.Children) + return issueCount; + } + + foreach (var mappingNode in mappings.Children) + { + if (mappingNode is YamlMappingNode mappingMapping) { - if (mappingNode is YamlMappingNode mappingMapping) - { - issueCount += LintMapping(context, path, mappingMapping); - } - else - { - context.WriteError( - $"{path}({mappingNode.Start.Line},{mappingNode.Start.Column}): error: Mapping must be a mapping node"); - issueCount++; - } + issueCount += LintMapping(context, path, mappingMapping); + } + else + { + context.WriteError( + $"{path}({mappingNode.Start.Line},{mappingNode.Start.Column}): error: Mapping must be a mapping node"); + issueCount++; } } @@ -331,40 +391,84 @@ private static int LintSection( } // Lint requirements + issueCount += LintSectionRequirements(context, path, section, seenIds); + + // Lint child sections + issueCount += LintSectionChildren(context, path, section, seenIds); + + return issueCount; + } + + /// + /// Lints the requirements sequence within a section. + /// + /// The context for output. + /// The file path for error messages. + /// The section mapping node. + /// Dictionary of requirement IDs already seen. + /// The number of issues found. + private static int LintSectionRequirements( + Context context, + string path, + YamlMappingNode section, + Dictionary seenIds) + { + var issueCount = 0; var requirements = GetSequenceChecked(context, path, section, "requirements", ref issueCount); - if (requirements != null) + if (requirements == null) { - foreach (var reqNode in requirements.Children) + return issueCount; + } + + foreach (var reqNode in requirements.Children) + { + if (reqNode is YamlMappingNode reqMapping) { - if (reqNode is YamlMappingNode reqMapping) - { - issueCount += LintRequirement(context, path, reqMapping, seenIds); - } - else - { - context.WriteError( - $"{path}({reqNode.Start.Line},{reqNode.Start.Column}): error: Requirement must be a mapping"); - issueCount++; - } + issueCount += LintRequirement(context, path, reqMapping, seenIds); + } + else + { + context.WriteError( + $"{path}({reqNode.Start.Line},{reqNode.Start.Column}): error: Requirement must be a mapping"); + issueCount++; } } - // Lint child sections + return issueCount; + } + + /// + /// Lints the child sections sequence within a section. + /// + /// The context for output. + /// The file path for error messages. + /// The section mapping node. + /// Dictionary of requirement IDs already seen. + /// The number of issues found. + private static int LintSectionChildren( + Context context, + string path, + YamlMappingNode section, + Dictionary seenIds) + { + var issueCount = 0; var sections = GetSequenceChecked(context, path, section, "sections", ref issueCount); - if (sections != null) + if (sections == null) + { + return issueCount; + } + + foreach (var childNode in sections.Children) { - foreach (var childNode in sections.Children) + if (childNode is YamlMappingNode childMapping) { - if (childNode is YamlMappingNode childMapping) - { - issueCount += LintSection(context, path, childMapping, seenIds); - } - else - { - context.WriteError( - $"{path}({childNode.Start.Line},{childNode.Start.Column}): error: Section must be a mapping"); - issueCount++; - } + issueCount += LintSection(context, path, childMapping, seenIds); + } + else + { + context.WriteError( + $"{path}({childNode.Start.Line},{childNode.Start.Column}): error: Section must be a mapping"); + issueCount++; } } @@ -400,84 +504,123 @@ private static int LintRequirement( } // Check required 'id' field + var reqId = LintRequirementId(context, path, requirement, seenIds, ref issueCount); + + // Check required 'title' field + issueCount += LintRequirementTitle(context, path, requirement, reqId); + + // Check tests list for blank entries + var tests = GetSequence(requirement, "tests"); + if (tests != null) + { + var blankTestStarts = tests.Children + .OfType() + .Where(s => string.IsNullOrWhiteSpace(s.Value)) + .Select(s => s.Start); + foreach (var start in blankTestStarts) + { + context.WriteError( + $"{path}({start.Line},{start.Column}): error: Test name cannot be blank"); + issueCount++; + } + } + + // Check tags list for blank entries + var tags = GetSequence(requirement, "tags"); + if (tags != null) + { + var blankTagStarts = tags.Children + .OfType() + .Where(s => string.IsNullOrWhiteSpace(s.Value)) + .Select(s => s.Start); + foreach (var start in blankTagStarts) + { + context.WriteError( + $"{path}({start.Line},{start.Column}): error: Tag name cannot be blank"); + issueCount++; + } + } + + return issueCount; + } + + /// + /// Validates the 'id' field of a requirement, checks for duplicates, and registers the ID. + /// + /// The context for output. + /// The file path for error messages. + /// The requirement mapping node. + /// Dictionary of requirement IDs already seen and the file they came from. + /// Incremented for each issue found. + /// The requirement ID if it can be parsed, or null if the ID is missing or blank. + private static string? LintRequirementId( + Context context, + string path, + YamlMappingNode requirement, + Dictionary seenIds, + ref int issueCount) + { var idNode = GetScalar(requirement, "id"); - string? reqId = null; if (idNode == null) { context.WriteError( $"{path}({requirement.Start.Line},{requirement.Start.Column}): error: Requirement missing required field 'id'"); issueCount++; + return null; } - else if (string.IsNullOrWhiteSpace(idNode.Value)) + + if (string.IsNullOrWhiteSpace(idNode.Value)) { context.WriteError( $"{path}({idNode.Start.Line},{idNode.Start.Column}): error: Requirement 'id' cannot be blank"); issueCount++; + return null; } - else - { - reqId = idNode.Value; - // Check for duplicate IDs - if (seenIds.TryGetValue(reqId, out var firstFile)) - { - context.WriteError( - $"{path}({idNode.Start.Line},{idNode.Start.Column}): error: Duplicate requirement ID '{reqId}' (first seen in {firstFile})"); - issueCount++; - } - else - { - seenIds[reqId] = path; - } + var reqId = idNode.Value; + if (seenIds.TryGetValue(reqId, out var firstFile)) + { + context.WriteError( + $"{path}({idNode.Start.Line},{idNode.Start.Column}): error: Duplicate requirement ID '{reqId}' (first seen in {firstFile})"); + issueCount++; + return reqId; } - // Check required 'title' field + seenIds[reqId] = path; + return reqId; + } + + /// + /// Validates the 'title' field of a requirement. + /// + /// The context for output. + /// The file path for error messages. + /// The requirement mapping node. + /// The requirement ID, used for error messages. + /// The number of issues found. + private static int LintRequirementTitle( + Context context, + string path, + YamlMappingNode requirement, + string? reqId) + { var titleNode = GetScalar(requirement, "title"); if (titleNode == null) { var location = reqId != null ? $"requirement '{reqId}'" : "requirement"; context.WriteError( $"{path}({requirement.Start.Line},{requirement.Start.Column}): error: {location} missing required field 'title'"); - issueCount++; + return 1; } - else if (string.IsNullOrWhiteSpace(titleNode.Value)) + + if (string.IsNullOrWhiteSpace(titleNode.Value)) { context.WriteError( $"{path}({titleNode.Start.Line},{titleNode.Start.Column}): error: Requirement 'title' cannot be blank"); - issueCount++; - } - - // Check tests list for blank entries - var tests = GetSequence(requirement, "tests"); - if (tests != null) - { - foreach (var testNode in tests.Children) - { - if (testNode is YamlScalarNode testScalar && string.IsNullOrWhiteSpace(testScalar.Value)) - { - context.WriteError( - $"{path}({testNode.Start.Line},{testNode.Start.Column}): error: Test name cannot be blank"); - issueCount++; - } - } - } - - // Check tags list for blank entries - var tags = GetSequence(requirement, "tags"); - if (tags != null) - { - foreach (var tagNode in tags.Children) - { - if (tagNode is YamlScalarNode tagScalar && string.IsNullOrWhiteSpace(tagScalar.Value)) - { - context.WriteError( - $"{path}({tagNode.Start.Line},{tagNode.Start.Column}): error: Tag name cannot be blank"); - issueCount++; - } - } + return 1; } - return issueCount; + return 0; } /// @@ -525,14 +668,15 @@ private static int LintMapping( var tests = GetSequence(mapping, "tests"); if (tests != null) { - foreach (var testNode in tests.Children) + var blankTestStarts = tests.Children + .OfType() + .Where(s => string.IsNullOrWhiteSpace(s.Value)) + .Select(s => s.Start); + foreach (var start in blankTestStarts) { - if (testNode is YamlScalarNode testScalar && string.IsNullOrWhiteSpace(testScalar.Value)) - { - context.WriteError( - $"{path}({testNode.Start.Line},{testNode.Start.Column}): error: Test name cannot be blank in mapping"); - issueCount++; - } + context.WriteError( + $"{path}({start.Line},{start.Column}): error: Test name cannot be blank in mapping"); + issueCount++; } } @@ -611,15 +755,10 @@ private static int LintMapping( return null; } - var result = new List(); - foreach (var node in sequence.Children) - { - if (node is YamlScalarNode scalar && scalar.Value != null) - { - result.Add(scalar.Value); - } - } - - return result; + return sequence.Children + .OfType() + .Where(s => s.Value != null) + .Select(s => s.Value!) + .ToList(); } } diff --git a/test/DemaConsulting.ReqStream.Tests/IntegrationTests.cs b/test/DemaConsulting.ReqStream.Tests/IntegrationTests.cs index 37087d5..3b39137 100644 --- a/test/DemaConsulting.ReqStream.Tests/IntegrationTests.cs +++ b/test/DemaConsulting.ReqStream.Tests/IntegrationTests.cs @@ -121,14 +121,13 @@ This is a test justification. // Assert: report contains the requirement ID and title var reportContent = File.ReadAllText(reportFile); - Assert.IsTrue(reportContent.Contains("Integration-System-DoSomethingUseful"), "Requirements report should contain the requirement ID."); - Assert.IsTrue(reportContent.Contains("The system shall do something useful."), - "Requirements report should contain the requirement title."); + Assert.Contains("Integration-System-DoSomethingUseful", reportContent); + Assert.Contains("The system shall do something useful.", reportContent); // Assert: trace matrix contains the satisfied requirement var matrixContent = File.ReadAllText(matrixFile); - Assert.IsTrue(matrixContent.Contains("Integration-System-DoSomethingUseful"), "Trace matrix should contain the requirement ID."); - Assert.IsTrue(matrixContent.Contains("satisfied with tests"), "Trace matrix should show requirements as satisfied."); + Assert.Contains("Integration-System-DoSomethingUseful", matrixContent); + Assert.Contains("satisfied with tests", matrixContent); } ///