diff --git a/README.md b/README.md index aaded96..f485428 100644 --- a/README.md +++ b/README.md @@ -13,25 +13,31 @@ DEMA Consulting tool for automated file-review evidence management in regulated ## Features -- **Cryptographic Fingerprinting**: Computes SHA256 fingerprints of defined file-sets so that - content changes are detected and reviews go stale automatically -- **Evidence Querying**: Queries a review evidence store (URL or file-share) for corresponding - code-review PDFs via an `index.json` catalogue -- **Coverage Reporting**: Generates a review plan showing which files are covered and flagging - any files that are not included in any review-set -- **Status Reporting**: Generates a review report showing whether each review-set is Current, - Stale, or Missing -- **Enforcement**: `--enforce` flag causes a non-zero exit code if any review-set is stale or - missing, or if any file is not covered by a review-set -- **Re-indexing**: `--index` command scans PDF evidence files matching a glob path and writes - `index.json` to the current directory (or to the directory specified by `--dir`) -- **Self-Validation**: Built-in validation tests with TRX/JUnit output -- **Multi-Platform Support**: Builds and runs on Windows, Linux, and macOS -- **Multi-Runtime Support**: Targets .NET 8, 9, and 10 -- **Comprehensive CI/CD**: GitHub Actions workflows with quality checks, builds, and - integration tests -- **Continuous Compliance**: Compliance evidence generated automatically on every CI run, following - the [Continuous Compliance][link-continuous-compliance] methodology +- 🔐 **Cryptographic Fingerprinting** - SHA256 fingerprints detect content changes automatically +- 📂 **Evidence Querying** - Queries URL or file-share evidence stores via an `index.json` catalogue +- 📋 **Coverage Reporting** - Review plan shows which files are covered and flags uncovered files +- 📊 **Status Reporting** - Review report shows whether each review-set is Current, Stale, Missing, or Failed +- 🚦 **Enforcement** - `--enforce` exits non-zero if any review-set is stale or missing, or any file is uncovered +- 🔄 **Re-indexing** - `--index` scans PDF evidence files and writes an up-to-date `index.json` +- ✅ **Self-Validation** - Built-in validation tests with TRX and JUnit output +- 🌐 **Multi-Platform** - Builds and runs on Windows, Linux, and macOS +- 🎯 **Multi-Runtime** - Targets .NET 8, 9, and 10 +- 🚀 **CI/CD Integration** - Automate review evidence generation in your pipelines +- 📜 **Continuous Compliance** - Proves file coverage and currency ([Continuous Compliance][link-continuous-compliance]) + +## Role in Continuous Compliance + +In the [Continuous Compliance][link-continuous-compliance] methodology, every compliance artifact +is generated automatically on each CI/CD run. ReviewMark fills the **file-review evidence** role: + +| Artifact | Description | +| :------- | :---------- | +| Review Plan | Proves every file requiring review is covered by at least one named review-set | +| Review Report | Proves each review-set is current — the review evidence matches the current file-set fingerprint | + +These Markdown documents are published as PDF/A-3u release artifacts alongside the requirements +trace matrix and code quality report, giving auditors a complete, automatically-maintained evidence +package on every release. ## Review Definition @@ -133,9 +139,14 @@ Running self-validation produces a report containing the following information: ✓ ReviewMark_VersionDisplay - Passed ✓ ReviewMark_HelpDisplay - Passed - -Total Tests: 2 -Passed: 2 +✓ ReviewMark_DefinitionPlan - Passed +✓ ReviewMark_DefinitionReport - Passed +✓ ReviewMark_IndexScan - Passed +✓ ReviewMark_Dir - Passed +✓ ReviewMark_Enforce - Passed + +Total Tests: 7 +Passed: 7 Failed: 0 ``` @@ -143,6 +154,11 @@ Each test in the report proves: - **`ReviewMark_VersionDisplay`** - `--version` outputs a valid version string. - **`ReviewMark_HelpDisplay`** - `--help` outputs usage and options information. +- **`ReviewMark_DefinitionPlan`** - `--definition` + `--plan` generates a review plan. +- **`ReviewMark_DefinitionReport`** - `--definition` + `--report` generates a review report. +- **`ReviewMark_IndexScan`** - `--index` scans PDF evidence files and writes `index.json`. +- **`ReviewMark_Dir`** - `--dir` overrides the working directory for file operations. +- **`ReviewMark_Enforce`** - `--enforce` exits with non-zero code when reviews have issues. See the [User Guide][link-guide] for more details on the self-validation tests. diff --git a/THEORY-OF-OPERATIONS.md b/THEORY-OF-OPERATIONS.md index a00fc64..e639278 100644 --- a/THEORY-OF-OPERATIONS.md +++ b/THEORY-OF-OPERATIONS.md @@ -128,10 +128,10 @@ review has ever been recorded for this review-set. The index is not maintained by hand. Instead, ReviewMark provides a `--index` command that scans PDF evidence files matching a glob path, reads the embedded metadata from each PDF using -[PdfSharp](https://github.com/empira/PDFsharp), and writes an up-to-date `index.json` to the -working directory. +[PdfSharp][pdfsharp], and writes an up-to-date `index.json` to the same directory. -Use `--dir` to set the working directory without changing the process directory: +The `--dir` flag sets the root directory against which `--index` glob patterns are resolved, and +is also where `index.json` is written. Without `--dir`, the current working directory is used: ```bash dotnet reviewmark --dir \\reviews.example.com\evidence\ --index "**/*.pdf" @@ -144,6 +144,13 @@ cd \\reviews.example.com\evidence\ dotnet reviewmark --index "**/*.pdf" ``` +The `--index` flag may be repeated to supply multiple glob patterns. This is useful when evidence +is organized across subdirectories: + +```bash +dotnet reviewmark --dir \\reviews.example.com\evidence\ --index "2024/**/*.pdf" --index "2025/**/*.pdf" +``` + Review teams deposit completed review PDFs into the evidence store folder with whatever file name their QMS document-numbering standard requires. Running `--index` regenerates the index from the PDF metadata — the tool never dictates file names. @@ -165,8 +172,8 @@ id=Core-Logic fingerprint=a3f9c2d1e4b5... date=2026-03-08 result=pass | `result` | Outcome of the review: `pass` or `fail` | Using the standard Keywords field means the metadata is readable by any PDF viewer or document -management system without requiring custom property support. PDFs that do not carry the expected -keys in their Keywords field are skipped with a warning during indexing. +management system without requiring custom property support. PDFs that do not carry all four +required fields (`id`, `fingerprint`, `date`, and `result`) are skipped with a warning during indexing. #### Credentials @@ -309,19 +316,26 @@ dotnet reviewmark --index "**/*.pdf" ReviewMark scans all PDF files matching the glob path, reads the Keywords field from each using PdfSharp, parses the `name=value` pairs, and writes a fresh `index.json` to the working directory. -PDFs whose Keywords field does not contain the required keys are skipped with a warning. +PDFs missing any of the four required fields (`id`, `fingerprint`, `date`, `result`) are skipped +with a warning. ## Self-Validation ReviewMark includes a built-in `--validate` command that verifies fingerprinting, index parsing, -evidence matching, and report generation using mock data — no live evidence store required: +evidence matching, and report generation using mock data — no live evidence store required. + +Two output formats are supported, selected by the `--results` file extension: ```bash +# TRX format (VSTest-compatible) dotnet reviewmark --validate --results artifacts/reviewmark-self-validation.trx + +# JUnit XML format +dotnet reviewmark --validate --results artifacts/reviewmark-self-validation.xml ``` -The resulting TRX file is consumed by [ReqStream](requirements.md) as test coverage evidence for -ReviewMark's own requirements. +The TRX file is consumed by [ReqStream][reqstream] as test coverage evidence for ReviewMark's own +requirements. ## Standards Alignment @@ -330,3 +344,6 @@ The review plan and review report together provide the artifact-review evidence - **IEC 62443** — design review and verification records - **ISO 26262** — software unit and integration review evidence - **DO-178C** — peer review records for software life-cycle data + +[pdfsharp]: https://github.com/empira/PDFsharp +[reqstream]: https://github.com/demaconsulting/ReqStream diff --git a/docs/guide/guide.md b/docs/guide/guide.md index a5eb734..12a63b0 100644 --- a/docs/guide/guide.md +++ b/docs/guide/guide.md @@ -97,9 +97,14 @@ Example validation report: ✓ ReviewMark_VersionDisplay - Passed ✓ ReviewMark_HelpDisplay - Passed - -Total Tests: 2 -Passed: 2 +✓ ReviewMark_DefinitionPlan - Passed +✓ ReviewMark_DefinitionReport - Passed +✓ ReviewMark_IndexScan - Passed +✓ ReviewMark_Dir - Passed +✓ ReviewMark_Enforce - Passed + +Total Tests: 7 +Passed: 7 Failed: 0 ``` @@ -109,6 +114,11 @@ Each test proves specific functionality works correctly: - **`ReviewMark_VersionDisplay`** - `--version` outputs a valid version string. - **`ReviewMark_HelpDisplay`** - `--help` outputs usage and options information. +- **`ReviewMark_DefinitionPlan`** - `--definition` + `--plan` generates a review plan. +- **`ReviewMark_DefinitionReport`** - `--definition` + `--report` generates a review report. +- **`ReviewMark_IndexScan`** - `--index` scans PDF evidence files and writes `index.json`. +- **`ReviewMark_Dir`** - `--dir` overrides the working directory for file operations. +- **`ReviewMark_Enforce`** - `--enforce` exits with non-zero code when reviews have issues. ## Silent Mode @@ -164,21 +174,351 @@ are used exactly as provided and are **not** re-rooted under `--dir`. This keeps argument independent: specifying one argument's path cannot silently change another argument's path. +# Configuration + +ReviewMark is configured through a `.reviewmark.yaml` file, normally placed at the repository root. +The file has three top-level keys: + +| Key | Required | Description | +| :---------------- | :------- | :------------------------------------------------------- | +| `needs-review` | No | Glob patterns identifying all files that require review | +| `evidence-source` | Yes | Location of the review evidence index | +| `reviews` | Yes | List of review sets, each grouping related files | + +A complete annotated example: + +```yaml +# .reviewmark.yaml + +# Patterns identifying every file in the repository that requires review. +# Processed in order; prefix a pattern with '!' to exclude. +needs-review: + - "src/**/*.cs" + - "src/**/*.yaml" + - "docs/**/*.md" + - "!**/obj/**" # exclude build output + - "!src/Generated/**" # exclude auto-generated files + +# Where to find the evidence index (index.json). +evidence-source: + type: fileshare + location: \\reviews.example.com\evidence\ + +# Review sets: groups of related files reviewed together. +reviews: + - id: core-logic + title: Review of core business logic + paths: + - "src/Core/**/*.cs" + - "!src/Core/obj/**" # exclude build output within the set + + - id: security-layer + title: Review of authentication and authorization + paths: + - "src/Auth/**/*.cs" + - "tests/Auth/**/*.cs" +``` + +## Review Sets + +A **review set** is a named group of files that are reviewed together as a single unit. Each set +has three fields: + +| Field | Required | Description | +| :------ | :------- | :------------------------------------------------------- | +| `id` | Yes | Stable identifier used in evidence PDFs | +| `title` | Yes | Human-readable description shown in the plan and report | +| `paths` | Yes | Ordered list of glob include/exclude patterns | + +### Glob Patterns + +Patterns are applied in the order they appear. A pattern prefixed with `!` is an **exclusion**: + +```yaml +paths: + - "src/Payments/**/*.cs" # include all C# files under Payments + - "tests/Payments/**/*.cs" # include corresponding tests + - "!src/Payments/obj/**" # exclude build artifacts +``` + +The same `!`-prefix syntax applies to the top-level `needs-review` list. + +### Fingerprinting + +ReviewMark computes a cryptographic fingerprint for each review set from the hashes of all +matched files. The fingerprint changes whenever files are **added, removed, or modified**, but is +stable across renames or moves that keep the same set of file contents, so those do not +invalidate a review. + +When a reviewer creates evidence, they record the current fingerprint in the PDF Keywords field. +ReviewMark matches that recorded fingerprint against the current fingerprint to determine whether +the review is still current. + +### Design Guidance + +Good review sets share these properties: + +- **Cohesive** — group implementation files with their corresponding tests and any documentation + they are paired with (e.g. a module's `.md` file alongside its `.cs` files). +- **Stable `id`** — choose an identifier that will not need to change as the code evolves, such + as `authentication-module` or `payment-api`. The `id` is embedded in every evidence PDF, so + renaming it breaks the evidence chain. +- **Right-sized** — a set that is too large is difficult to review in a single sitting; a set that + is too small creates an unmanageable number of review artifacts. +- **Exclude generated files** — use `!` patterns to omit `obj/`, `bin/`, and other build outputs + that change frequently without meaningful content changes. + +Example of a well-structured set that groups a feature module with its tests and documentation: + +```yaml +reviews: + - id: payment-processor + title: Payment processing module + paths: + - "src/Payments/**/*.cs" + - "tests/Payments/**/*.cs" + - "docs/payments.md" + - "!src/Payments/obj/**" + - "!tests/Payments/obj/**" +``` + +## Evidence Source + +The `evidence-source` block tells ReviewMark where to find `index.json` — the catalogue of +completed review PDFs. + +### Source Types + +| Type | Description | +| :----------- | :---------------------------------------------- | +| `fileshare` | UNC path or local directory | +| `url` | HTTP or HTTPS endpoint | + +#### File Share + +```yaml +evidence-source: + type: fileshare + location: \\reviews.example.com\evidence\ +``` + +#### URL + +```yaml +evidence-source: + type: url + location: https://reviews.example.com/evidence/ +``` + +### Credentials + +For authenticated URL sources, supply credentials through environment variables so that secrets +are never stored in the definition file or source control: + +```yaml +evidence-source: + type: url + location: https://reviews.example.com/evidence/ + credentials: + username-env: REVIEWMARK_USER # name of the environment variable holding the username + password-env: REVIEWMARK_TOKEN # name of the environment variable holding the password +``` + +In a CI/CD pipeline, map repository secrets to those environment variables: + +```yaml +- name: Run ReviewMark + env: + REVIEWMARK_USER: ${{ secrets.REVIEW_USER }} + REVIEWMARK_TOKEN: ${{ secrets.REVIEW_TOKEN }} + run: reviewmark --plan plan.md --report report.md --enforce +``` + +# Typical Workflow + +The following steps describe the end-to-end workflow for a repository that uses ReviewMark. + +## Step 1 — Create the Definition File + +Add a `.reviewmark.yaml` at the repository root. Define `needs-review` patterns to identify every +file that must be reviewed, an `evidence-source` pointing to your review evidence store, and at +least one review set grouping related files. + +```yaml +needs-review: + - "src/**/*.cs" + - "docs/**/*.md" + - "!**/obj/**" + +evidence-source: + type: fileshare + location: \\reviews.example.com\evidence\ + +reviews: + - id: core-module + title: Core module implementation + paths: + - "src/Core/**/*.cs" + - "tests/Core/**/*.cs" + - "docs/core.md" + - "!**/obj/**" +``` + +## Step 2 — Generate the Review Plan + +Run ReviewMark with `--plan` to produce the Review Plan document. The plan lists every review set, +how many files it covers, its current fingerprint, and reports any files that are not covered by +any review set. + +```bash +reviewmark --plan docs/review/review-plan.md +``` + +The plan is checked into the repository alongside the source code so that reviewers have a structured +starting point. + +## Step 3 — Perform and Record the Review + +A reviewer works through the review plan, examining each file in the listed review sets. When the +review is complete, the reviewer creates a PDF (following your organization's QMS numbering standard) +and embeds the review metadata in the PDF Keywords field: + +```text +id=core-module fingerprint=a3f9c2d1... date=2026-03-15 result=pass +``` + +All four fields are **required** — a PDF without any one of them will be skipped with a warning +when the evidence store is scanned. The PDF is deposited in the evidence store folder. +ReviewMark never dictates file names — the reviewer uses whatever name the QMS requires. + +## Step 4 — Update the Evidence Index + +Scan the evidence store to regenerate `index.json`. This step is typically run on the evidence +server after each new PDF is deposited: + +```bash +reviewmark --dir \\reviews.example.com\evidence\ --index "**/*.pdf" +``` + +The `--index` flag may be repeated to cover evidence organized across multiple sub-directories: + +```bash +reviewmark --dir \\reviews.example.com\evidence\ --index "2025/**/*.pdf" --index "2026/**/*.pdf" +``` + +## Step 5 — Generate the Review Report + +Run ReviewMark with both `--plan` and `--report` to produce the Review Report alongside the plan. +The report shows the status of each review set — Current, Stale, Failed, or Missing — and lists +the referenced evidence documents. + +```bash +reviewmark --plan docs/review/review-plan.md --report docs/review/review-report.md +``` + +## Step 6 — Enforce Compliance in CI + +Add `--enforce` to fail the CI pipeline when any review set is missing, stale, or failed, or when +any file matching `needs-review` is not covered by a review set: + +```bash +reviewmark \ + --plan docs/review/review-plan.md \ + --report docs/review/review-report.md \ + --enforce +``` + +With `--enforce`, the pipeline blocks until all outstanding reviews are completed and the evidence +index is updated. + # Examples -## Example 1: Basic Usage +## Example 1: Complete CI/CD Run + +Generate the review plan and report, and enforce compliance in a single command. Credentials are +supplied as environment variables mapped from CI secrets. ```bash -reviewmark +REVIEWMARK_USER=${{ secrets.REVIEW_USER }} \ +REVIEWMARK_TOKEN=${{ secrets.REVIEW_TOKEN }} \ +reviewmark \ + --definition .reviewmark.yaml \ + --plan docs/review/review-plan.md \ + --report docs/review/review-report.md \ + --enforce +``` + +With a corresponding definition file: + +```yaml +# .reviewmark.yaml +needs-review: + - "src/**/*.cs" + - "docs/**/*.md" + - "!**/obj/**" + +evidence-source: + type: url + location: https://reviews.example.com/evidence/ + credentials: + username-env: REVIEWMARK_USER + password-env: REVIEWMARK_TOKEN + +reviews: + - id: core-module + title: Core module implementation + paths: + - "src/Core/**/*.cs" + - "tests/Core/**/*.cs" + - "docs/core.md" + - "!**/obj/**" + + - id: api-layer + title: Public API layer + paths: + - "src/Api/**/*.cs" + - "tests/Api/**/*.cs" + - "docs/api.md" + - "!**/obj/**" +``` + +## Example 2: Indexing Evidence Across Year Directories + +When evidence PDFs are organized by year, supply multiple `--index` patterns to scan all of them +in a single run: + +```bash +reviewmark \ + --dir \\reviews.example.com\evidence\ \ + --index "2024/**/*.pdf" \ + --index "2025/**/*.pdf" \ + --index "2026/**/*.pdf" +``` + +## Example 3: Excluding Generated Files in a Review Set + +Use `!`-prefixed patterns to exclude build outputs and auto-generated files from a review set, +keeping the fingerprint stable across builds: + +```yaml +reviews: + - id: data-layer + title: Data access layer + paths: + - "src/Data/**/*.cs" # include all C# source files + - "tests/Data/**/*.cs" # include all corresponding tests + - "!src/Data/obj/**" # exclude build output + - "!src/Data/bin/**" # exclude compiled binaries + - "!src/Data/Generated/**" # exclude auto-generated entity classes ``` -## Example 2: Self-Validation with Results +## Example 4: Self-Validation with Results ```bash reviewmark --validate --results validation-results.trx ``` -## Example 3: Silent Mode with Logging +## Example 5: Silent Mode with Logging ```bash reviewmark --silent --log tool-output.log diff --git a/requirements.yaml b/requirements.yaml index d714d22..b4cc44c 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -267,6 +267,22 @@ sections: - ReviewIndex_Load_EvidenceSource_Url_InvalidJson_ThrowsInvalidOperationException - ReviewIndex_Load_EvidenceSource_NullHttpClient_ThrowsArgumentNullException + - id: ReviewMark-Index-PdfParsing + title: The tool shall parse PDF metadata from the Keywords field when indexing evidence files. + justification: | + When scanning PDF evidence files, the tool must read the standard PDF Keywords + field and extract space-separated `name=value` pairs. All four fields — id, + fingerprint, date, and result — are required for an entry to be indexed; PDFs + whose Keywords field is missing any of these fields (or is entirely absent) must + be skipped with a warning, ensuring the index only contains complete, valid entries. + tests: + - ReviewIndex_Scan_PdfWithValidMetadata_PopulatesIndex + - ReviewIndex_Scan_PdfWithMissingId_SkipsWithWarning + - ReviewIndex_Scan_PdfWithMissingFingerprint_SkipsWithWarning + - ReviewIndex_Scan_PdfWithMissingDate_SkipsWithWarning + - ReviewIndex_Scan_PdfWithMissingResult_SkipsWithWarning + - ReviewIndex_Scan_PdfWithNoKeywords_SkipsWithWarning + - title: Platform Support requirements: - id: ReviewMark-Platform-Windows diff --git a/src/DemaConsulting.ReviewMark/Index.cs b/src/DemaConsulting.ReviewMark/Index.cs index aeceffe..a754d4c 100644 --- a/src/DemaConsulting.ReviewMark/Index.cs +++ b/src/DemaConsulting.ReviewMark/Index.cs @@ -513,7 +513,9 @@ internal static ReviewIndex Scan(string directory, IReadOnlyList paths, /// /// Opens a single PDF file, reads its Keywords metadata, and adds a - /// entry if the required fields are present. + /// entry if all required fields are present. + /// All four fields — id, fingerprint, date, and result — + /// must be non-empty; any PDF missing one or more is skipped with a warning. /// /// The absolute file-system path to the PDF. /// @@ -532,7 +534,7 @@ private void ProcessPdf(string fullPath, string relativePath, Action? on // Parse the space-separated name=value pairs into a dictionary var pairs = ParseKeywordPairs(keywords); - // The 'id' and 'fingerprint' keys are required; skip with a warning if absent + // All four keys are required; skip with a warning if any are absent or empty if (!pairs.TryGetValue("id", out var id) || string.IsNullOrWhiteSpace(id)) { onWarning?.Invoke($"Skipping '{relativePath}': PDF Keywords missing required 'id' field."); @@ -545,15 +547,24 @@ private void ProcessPdf(string fullPath, string relativePath, Action? on return; } - // Build the evidence record from the parsed metadata - pairs.TryGetValue("date", out var date); - pairs.TryGetValue("result", out var result); + if (!pairs.TryGetValue("date", out var date) || string.IsNullOrWhiteSpace(date)) + { + onWarning?.Invoke($"Skipping '{relativePath}': PDF Keywords missing required 'date' field."); + return; + } + if (!pairs.TryGetValue("result", out var result) || string.IsNullOrWhiteSpace(result)) + { + onWarning?.Invoke($"Skipping '{relativePath}': PDF Keywords missing required 'result' field."); + return; + } + + // Build the evidence record from the parsed metadata var evidence = new ReviewEvidence( Id: id, Fingerprint: fingerprint, - Date: date ?? string.Empty, - Result: result ?? string.Empty, + Date: date, + Result: result, File: relativePath); // Store the evidence at [id][fingerprint], overwriting any previous entry diff --git a/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs b/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs index 7c603cb..f2f443f 100644 --- a/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/ContextTests.cs @@ -35,7 +35,7 @@ public void Context_Create_NoArguments_ReturnsDefaultContext() // Act using var context = Context.Create([]); - // Assert + // Assert — default context has all flags false and exit code is zero Assert.IsFalse(context.Version); Assert.IsFalse(context.Help); Assert.IsFalse(context.Silent); @@ -52,7 +52,7 @@ public void Context_Create_VersionFlag_SetsVersionTrue() // Act using var context = Context.Create(["--version"]); - // Assert + // Assert — Version is true, Help remains false, and exit code is zero Assert.IsTrue(context.Version); Assert.IsFalse(context.Help); Assert.AreEqual(0, context.ExitCode); @@ -67,7 +67,7 @@ public void Context_Create_ShortVersionFlag_SetsVersionTrue() // Act using var context = Context.Create(["-v"]); - // Assert + // Assert — short flag also sets Version to true, Help remains false, and exit code is zero Assert.IsTrue(context.Version); Assert.IsFalse(context.Help); Assert.AreEqual(0, context.ExitCode); @@ -82,7 +82,7 @@ public void Context_Create_HelpFlag_SetsHelpTrue() // Act using var context = Context.Create(["--help"]); - // Assert + // Assert — Help is true, Version remains false, and exit code is zero Assert.IsFalse(context.Version); Assert.IsTrue(context.Help); Assert.AreEqual(0, context.ExitCode); @@ -97,7 +97,7 @@ public void Context_Create_ShortHelpFlag_H_SetsHelpTrue() // Act using var context = Context.Create(["-h"]); - // Assert + // Assert — -h flag sets Help to true, Version remains false, and exit code is zero Assert.IsFalse(context.Version); Assert.IsTrue(context.Help); Assert.AreEqual(0, context.ExitCode); @@ -112,7 +112,7 @@ public void Context_Create_ShortHelpFlag_Question_SetsHelpTrue() // Act using var context = Context.Create(["-?"]); - // Assert + // Assert — -? flag sets Help to true, Version remains false, and exit code is zero Assert.IsFalse(context.Version); Assert.IsTrue(context.Help); Assert.AreEqual(0, context.ExitCode); @@ -127,7 +127,7 @@ public void Context_Create_SilentFlag_SetsSilentTrue() // Act using var context = Context.Create(["--silent"]); - // Assert + // Assert — Silent is true and exit code is zero Assert.IsTrue(context.Silent); Assert.AreEqual(0, context.ExitCode); } @@ -141,7 +141,7 @@ public void Context_Create_ValidateFlag_SetsValidateTrue() // Act using var context = Context.Create(["--validate"]); - // Assert + // Assert — Validate is true and exit code is zero Assert.IsTrue(context.Validate); Assert.AreEqual(0, context.ExitCode); } @@ -155,7 +155,7 @@ public void Context_Create_ResultsFlag_SetsResultsFile() // Act using var context = Context.Create(["--results", "test.trx"]); - // Assert + // Assert — ResultsFile is set to the provided path and exit code is zero Assert.AreEqual("test.trx", context.ResultsFile); Assert.AreEqual(0, context.ExitCode); } @@ -177,8 +177,7 @@ public void Context_Create_LogFlag_OpensLogFile() Assert.AreEqual(0, context.ExitCode); } - // Assert - // Verify log file was written + // Assert — log file exists and contains the written message Assert.IsTrue(File.Exists(logFile)); var logContent = File.ReadAllText(logFile); Assert.Contains("Test message", logContent); @@ -242,7 +241,7 @@ public void Context_WriteLine_NotSilent_WritesToConsole() // Act context.WriteLine("Test message"); - // Assert + // Assert — the message appears in console output when not silent var output = outWriter.ToString(); Assert.Contains("Test message", output); } @@ -269,7 +268,7 @@ public void Context_WriteLine_Silent_DoesNotWriteToConsole() // Act context.WriteLine("Test message"); - // Assert + // Assert — the message is suppressed from console output when silent var output = outWriter.ToString(); Assert.DoesNotContain("Test message", output); } @@ -296,7 +295,7 @@ public void Context_WriteError_Silent_DoesNotWriteToConsole() // Act context.WriteError("Test error message"); - // Assert - error output should be suppressed in silent mode + // Assert — error output should be suppressed in silent mode var output = errWriter.ToString(); Assert.DoesNotContain("Test error message", output); } @@ -323,7 +322,7 @@ public void Context_WriteError_SetsErrorExitCode() // Act context.WriteError("Test error message"); - // Assert + // Assert — exit code is set to 1 after writing an error Assert.AreEqual(1, context.ExitCode); } finally @@ -349,7 +348,7 @@ public void Context_WriteError_NotSilent_WritesToConsole() // Act context.WriteError("Test error message"); - // Assert + // Assert — the error message appears in stderr when not silent var output = errWriter.ToString(); Assert.Contains("Test error message", output); } @@ -376,7 +375,7 @@ public void Context_WriteError_WritesToLogFile() Assert.AreEqual(1, context.ExitCode); } - // Assert - log file should contain the error message + // Assert — log file should contain the error message Assert.IsTrue(File.Exists(logFile)); var logContent = File.ReadAllText(logFile); Assert.Contains("Test error in log", logContent); @@ -399,7 +398,7 @@ public void Context_Create_DefinitionFlag_SetsDefinitionFile() // Act - create context specifying a definition YAML file using var context = Context.Create(["--definition", "spec.yaml"]); - // Assert - DefinitionFile is set to the provided path and exit code is 0 + // Assert — DefinitionFile is set to the provided path and exit code is 0 Assert.AreEqual("spec.yaml", context.DefinitionFile); Assert.AreEqual(0, context.ExitCode); } @@ -424,7 +423,7 @@ public void Context_Create_PlanFlag_SetsPlanFile() // Act - create context specifying a plan output file using var context = Context.Create(["--plan", "plan.yaml"]); - // Assert - PlanFile is set to the provided path and exit code is 0 + // Assert — PlanFile is set to the provided path and exit code is 0 Assert.AreEqual("plan.yaml", context.PlanFile); Assert.AreEqual(0, context.ExitCode); } @@ -438,7 +437,7 @@ public void Context_Create_PlanDepthFlag_SetsPlanDepth() // Act - create context specifying a heading depth of 3 using var context = Context.Create(["--plan-depth", "3"]); - // Assert - PlanDepth is set to the parsed integer value and exit code is 0 + // Assert — PlanDepth is set to the parsed integer value and exit code is 0 Assert.AreEqual(3, context.PlanDepth); Assert.AreEqual(0, context.ExitCode); } @@ -474,7 +473,7 @@ public void Context_Create_ReportFlag_SetsReportFile() // Act - create context specifying a report output file using var context = Context.Create(["--report", "report.md"]); - // Assert - ReportFile is set to the provided path and exit code is 0 + // Assert — ReportFile is set to the provided path and exit code is 0 Assert.AreEqual("report.md", context.ReportFile); Assert.AreEqual(0, context.ExitCode); } @@ -488,7 +487,7 @@ public void Context_Create_ReportDepthFlag_SetsReportDepth() // Act - create context specifying a heading depth of 2 using var context = Context.Create(["--report-depth", "2"]); - // Assert - ReportDepth is set to the parsed integer value and exit code is 0 + // Assert — ReportDepth is set to the parsed integer value and exit code is 0 Assert.AreEqual(2, context.ReportDepth); Assert.AreEqual(0, context.ExitCode); } @@ -532,7 +531,7 @@ public void Context_Create_IndexFlag_AddsIndexPath() // Act - create context specifying one glob pattern for PDF evidence files using var context = Context.Create(["--index", "*.pdf"]); - // Assert - IndexPaths contains the provided glob pattern and exit code is 0 + // Assert — IndexPaths contains the provided glob pattern and exit code is 0 Assert.HasCount(1, context.IndexPaths); Assert.AreEqual("*.pdf", context.IndexPaths[0]); Assert.AreEqual(0, context.ExitCode); @@ -547,7 +546,7 @@ public void Context_Create_IndexFlag_MultipleTimes_AddsAllPaths() // Act - create context with two different --index glob patterns using var context = Context.Create(["--index", "*.pdf", "--index", "docs/**/*.md"]); - // Assert - IndexPaths contains both patterns and exit code is 0 + // Assert — IndexPaths contains both patterns and exit code is 0 Assert.HasCount(2, context.IndexPaths); Assert.Contains("*.pdf", context.IndexPaths); Assert.Contains("docs/**/*.md", context.IndexPaths); @@ -563,7 +562,7 @@ public void Context_Create_NoArguments_IndexPathsEmpty() // Act - create context with no arguments using var context = Context.Create([]); - // Assert - IndexPaths is empty when no --index flags are provided + // Assert — IndexPaths is empty when no --index flags are provided Assert.HasCount(0, context.IndexPaths); } @@ -576,7 +575,7 @@ public void Context_Create_NoArguments_PlanDepthDefaultsToOne() // Act - create context with no arguments using var context = Context.Create([]); - // Assert - PlanDepth defaults to 1 + // Assert — PlanDepth defaults to 1 Assert.AreEqual(1, context.PlanDepth); } @@ -589,7 +588,7 @@ public void Context_Create_NoArguments_ReportDepthDefaultsToOne() // Act - create context with no arguments using var context = Context.Create([]); - // Assert - ReportDepth defaults to 1 + // Assert — ReportDepth defaults to 1 Assert.AreEqual(1, context.ReportDepth); } @@ -602,7 +601,7 @@ public void Context_Create_EnforceFlag_SetsEnforceTrue() // Act - create context with the --enforce flag using var context = Context.Create(["--enforce"]); - // Assert - Enforce is set to true and exit code is 0 + // Assert — Enforce is set to true and exit code is 0 Assert.IsTrue(context.Enforce); Assert.AreEqual(0, context.ExitCode); } @@ -616,7 +615,7 @@ public void Context_Create_NoArguments_EnforceFalse() // Act - create context with no arguments using var context = Context.Create([]); - // Assert - Enforce defaults to false + // Assert — Enforce defaults to false Assert.IsFalse(context.Enforce); } @@ -649,7 +648,7 @@ public void Context_Create_DirFlag_SetsWorkingDirectory() // Act - create context specifying a working directory using var context = Context.Create(["--dir", "/evidence"]); - // Assert - WorkingDirectory is set to the provided path and exit code is 0 + // Assert — WorkingDirectory is set to the provided path and exit code is 0 Assert.AreEqual("/evidence", context.WorkingDirectory); Assert.AreEqual(0, context.ExitCode); } @@ -663,7 +662,7 @@ public void Context_Create_NoArguments_WorkingDirectoryIsNull() // Act - create context with no arguments using var context = Context.Create([]); - // Assert - WorkingDirectory is null when --dir is not specified + // Assert — WorkingDirectory is null when --dir is not specified Assert.IsNull(context.WorkingDirectory); } diff --git a/test/DemaConsulting.ReviewMark.Tests/IndexTests.cs b/test/DemaConsulting.ReviewMark.Tests/IndexTests.cs index 7d6c2c4..9e5cd4f 100644 --- a/test/DemaConsulting.ReviewMark.Tests/IndexTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/IndexTests.cs @@ -723,6 +723,60 @@ public void ReviewIndex_Scan_PdfWithNoKeywords_SkipsWithWarning() "No entry should be added when a PDF has no keywords."); } + /// + /// Test that a PDF with id and fingerprint but no date keyword is skipped + /// and the warning callback is invoked. + /// + [TestMethod] + public void ReviewIndex_Scan_PdfWithMissingDate_SkipsWithWarning() + { + // Arrange — create a PDF that has id and fingerprint but no date + var pdfPath = PathHelpers.SafePathCombine(_testDirectory, "missing-date.pdf"); + using (var document = new PdfDocument()) + { + document.AddPage(); + document.Info.Keywords = "id=Core-Logic fingerprint=abc123 result=pass"; + document.Save(pdfPath); + } + + var warnings = new List(); + + // Act + var index = ReviewIndex.Scan(_testDirectory, ["**/*.pdf"], onWarning: msg => warnings.Add(msg)); + + // Assert — the file is skipped and at least one warning is emitted; no entry in the index + Assert.IsTrue(warnings.Count > 0, "A warning should be emitted for a PDF missing 'date'."); + Assert.IsFalse(index.HasId("Core-Logic"), + "No entry should be added when the 'date' keyword is missing."); + } + + /// + /// Test that a PDF with id, fingerprint, and date but no result keyword is skipped + /// and the warning callback is invoked. + /// + [TestMethod] + public void ReviewIndex_Scan_PdfWithMissingResult_SkipsWithWarning() + { + // Arrange — create a PDF that has id, fingerprint, and date but no result + var pdfPath = PathHelpers.SafePathCombine(_testDirectory, "missing-result.pdf"); + using (var document = new PdfDocument()) + { + document.AddPage(); + document.Info.Keywords = "id=Core-Logic fingerprint=abc123 date=2026-03-08"; + document.Save(pdfPath); + } + + var warnings = new List(); + + // Act + var index = ReviewIndex.Scan(_testDirectory, ["**/*.pdf"], onWarning: msg => warnings.Add(msg)); + + // Assert — the file is skipped and at least one warning is emitted; no entry in the index + Assert.IsTrue(warnings.Count > 0, "A warning should be emitted for a PDF missing 'result'."); + Assert.IsFalse(index.HasId("Core-Logic"), + "No entry should be added when the 'result' keyword is missing."); + } + /// /// Test that scanning a directory with two PDFs, each with distinct metadata, /// populates the index with both entries. diff --git a/test/DemaConsulting.ReviewMark.Tests/IntegrationTests.cs b/test/DemaConsulting.ReviewMark.Tests/IntegrationTests.cs index a0e4f8c..f164ce2 100644 --- a/test/DemaConsulting.ReviewMark.Tests/IntegrationTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/IntegrationTests.cs @@ -55,7 +55,7 @@ public void IntegrationTest_VersionFlag_OutputsVersion() _dllPath, "--version"); - // Assert + // Assert — exit succeeds, output is non-empty, and contains no error or copyright text Assert.AreEqual(0, exitCode); Assert.IsFalse(string.IsNullOrWhiteSpace(output)); Assert.DoesNotContain("Error", output); @@ -75,7 +75,7 @@ public void IntegrationTest_HelpFlag_OutputsUsageInformation() _dllPath, "--help"); - // Assert + // Assert — exit succeeds and output contains usage, options, and version flag Assert.AreEqual(0, exitCode); Assert.Contains("Usage:", output); Assert.Contains("Options:", output); @@ -95,7 +95,7 @@ public void IntegrationTest_ValidateFlag_RunsValidation() _dllPath, "--validate"); - // Assert + // Assert — exit succeeds and output contains the validation summary Assert.AreEqual(0, exitCode); Assert.Contains("Total Tests:", output); Assert.Contains("Passed:", output); @@ -122,7 +122,7 @@ public void IntegrationTest_ValidateWithResults_GeneratesTrxFile() "--results", resultsFile); - // Assert + // Assert — exit succeeds, results file is created, and contains valid TRX XML Assert.AreEqual(0, exitCode); Assert.IsTrue(File.Exists(resultsFile), "Results file was not created"); @@ -152,7 +152,7 @@ public void IntegrationTest_SilentFlag_SuppressesOutput() _dllPath, "--silent"); - // Assert + // Assert — exit code is zero, proving silent mode did not cause an error Assert.AreEqual(0, exitCode); // Output check removed since silent mode may still produce some output @@ -177,7 +177,7 @@ public void IntegrationTest_LogFlag_WritesOutputToFile() "--log", logFile); - // Assert + // Assert — exit succeeds, log file is created, and contains the version banner Assert.AreEqual(0, exitCode); Assert.IsTrue(File.Exists(logFile), "Log file was not created"); @@ -214,7 +214,7 @@ public void IntegrationTest_ValidateWithResults_GeneratesJUnitFile() "--results", resultsFile); - // Assert + // Assert — exit succeeds, results file is created, and contains JUnit XML root element Assert.AreEqual(0, exitCode); Assert.IsTrue(File.Exists(resultsFile), "Results file was not created"); @@ -243,7 +243,7 @@ public void IntegrationTest_UnknownArgument_ReturnsError() _dllPath, "--unknown"); - // Assert + // Assert — unknown argument produces a non-zero exit code and an error message Assert.AreNotEqual(0, exitCode); Assert.Contains("Error", output); } diff --git a/test/DemaConsulting.ReviewMark.Tests/PathHelpersTests.cs b/test/DemaConsulting.ReviewMark.Tests/PathHelpersTests.cs index 1c42a4c..e399e24 100644 --- a/test/DemaConsulting.ReviewMark.Tests/PathHelpersTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/PathHelpersTests.cs @@ -39,7 +39,7 @@ public void PathHelpers_SafePathCombine_ValidPaths_CombinesCorrectly() // Act var result = PathHelpers.SafePathCombine(basePath, relativePath); - // Assert + // Assert — result equals the expected combined path Assert.AreEqual(Path.Combine(basePath, relativePath), result); } @@ -112,7 +112,7 @@ public void PathHelpers_SafePathCombine_CurrentDirectoryReference_CombinesCorrec // Act var result = PathHelpers.SafePathCombine(basePath, relativePath); - // Assert + // Assert — current directory reference is preserved in the combined path Assert.AreEqual(Path.Combine(basePath, relativePath), result); } @@ -129,7 +129,7 @@ public void PathHelpers_SafePathCombine_NestedPaths_CombinesCorrectly() // Act var result = PathHelpers.SafePathCombine(basePath, relativePath); - // Assert + // Assert — nested path segments are combined correctly Assert.AreEqual(Path.Combine(basePath, relativePath), result); } @@ -146,7 +146,7 @@ public void PathHelpers_SafePathCombine_EmptyRelativePath_ReturnsBasePath() // Act var result = PathHelpers.SafePathCombine(basePath, relativePath); - // Assert + // Assert — empty relative path results in the base path unchanged Assert.AreEqual(Path.Combine(basePath, relativePath), result); } } diff --git a/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs b/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs index d3ccaa6..47fd475 100644 --- a/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/ProgramTests.cs @@ -43,8 +43,9 @@ public void Program_Run_WithVersionFlag_DisplaysVersionOnly() // Act Program.Run(context); - // Assert + // Assert — output is exactly the version string; copyright and banner text are absent var output = outWriter.ToString(); + Assert.AreEqual(Program.Version, output.Trim()); Assert.DoesNotContain("Copyright", output); Assert.DoesNotContain("ReviewMark version", output); } @@ -71,7 +72,7 @@ public void Program_Run_WithHelpFlag_DisplaysUsageInformation() // Act Program.Run(context); - // Assert + // Assert — output contains usage and options sections listing known flags var output = outWriter.ToString(); Assert.Contains("Usage:", output); Assert.Contains("Options:", output); @@ -101,7 +102,7 @@ public void Program_Run_WithValidateFlag_RunsValidation() // Act Program.Run(context); - // Assert + // Assert — output contains the validation summary with a total test count var output = outWriter.ToString(); Assert.Contains("Total Tests:", output); } @@ -128,7 +129,7 @@ public void Program_Run_NoArguments_DisplaysDefaultBehavior() // Act Program.Run(context); - // Assert + // Assert — output contains the version banner and copyright notice var output = outWriter.ToString(); Assert.Contains("ReviewMark version", output); Assert.Contains("Copyright", output); @@ -148,7 +149,7 @@ public void Program_Version_ReturnsNonEmptyString() // Act var version = Program.Version; - // Assert + // Assert — Version is a non-empty, non-whitespace string Assert.IsFalse(string.IsNullOrWhiteSpace(version)); } } diff --git a/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs b/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs index a8ece07..a351d9d 100644 --- a/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs +++ b/test/DemaConsulting.ReviewMark.Tests/ReviewMarkConfigurationTests.cs @@ -95,7 +95,7 @@ public void ReviewMarkConfiguration_Parse_ValidYaml_ReturnsConfiguration() // Act var config = ReviewMarkConfiguration.Parse(MinimalYaml); - // Assert + // Assert — a non-null configuration is returned from valid YAML Assert.IsNotNull(config); } @@ -135,7 +135,7 @@ public void ReviewMarkConfiguration_Parse_EvidenceSource_ParsedCorrectly() // Act var config = ReviewMarkConfiguration.Parse(MinimalYaml); - // Assert + // Assert — evidence-source type, location, and absent credentials are parsed correctly Assert.AreEqual("url", config.EvidenceSource.Type); Assert.AreEqual("https://reviews.example.com/", config.EvidenceSource.Location); Assert.IsNull(config.EvidenceSource.UsernameEnv); @@ -179,7 +179,7 @@ public void ReviewMarkConfiguration_Parse_EvidenceSourceWithCredentials_ParsedCo // Act var config = ReviewMarkConfiguration.Parse(yaml); - // Assert + // Assert — credential environment variable names are parsed correctly Assert.AreEqual("REVIEWMARK_USER", config.EvidenceSource.UsernameEnv); Assert.AreEqual("REVIEWMARK_TOKEN", config.EvidenceSource.PasswordEnv); } @@ -325,7 +325,7 @@ public void ReviewMarkConfiguration_Load_FileshareRelativeLocation_ResolvesToAbs // Act - load the configuration var config = ReviewMarkConfiguration.Load(configPath); - // Assert - relative location is resolved to an absolute path under the config directory + // Assert — relative location is resolved to an absolute path under the config directory Assert.IsTrue(Path.IsPathRooted(config.EvidenceSource.Location)); Assert.AreEqual(PathHelpers.SafePathCombine(_testDirectory, "index.json"), config.EvidenceSource.Location); }