From 3499b67d7b3fb536e8e1a193a354bea99d00e851 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:14:46 +0000 Subject: [PATCH 1/9] Initial plan From b92188c6d4207eb813d27a29931f636944527b9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:39:28 +0000 Subject: [PATCH 2/9] Repository cleanups: add PdfParsing requirement, update docs, improve test assert comments Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- THEORY-OF-OPERATIONS.md | 28 +++++++-- requirements.yaml | 14 +++++ .../ContextTests.cs | 61 +++++++++---------- .../IntegrationTests.cs | 16 ++--- .../PathHelpersTests.cs | 8 +-- .../ProgramTests.cs | 11 ++-- .../ReviewMarkConfigurationTests.cs | 8 +-- 7 files changed, 88 insertions(+), 58 deletions(-) diff --git a/THEORY-OF-OPERATIONS.md b/THEORY-OF-OPERATIONS.md index a00fc64..b364f53 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. @@ -314,14 +321,20 @@ PDFs whose Keywords field does not contain the required keys are skipped with a ## 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 (NUnit-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 +343,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]: requirements.md diff --git a/requirements.yaml b/requirements.yaml index d714d22..ad184d1 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -267,6 +267,20 @@ 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 to populate review evidence + entries with id, fingerprint, date, result, and file name. PDFs whose Keywords + field does not contain all required keys 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_PdfWithNoKeywords_SkipsWithWarning + - title: Platform Support requirements: - id: ReviewMark-Platform-Windows 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/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); } From 034e4015b5406cf7a1c2b2eea1e0a83f51af131c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:52:03 +0000 Subject: [PATCH 3/9] Fix ReqStream link in THEORY-OF-OPERATIONS.md and improve README.md Continuous Compliance section Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- README.md | 44 +++++++++++++++++++++++------------------ THEORY-OF-OPERATIONS.md | 2 +- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index aaded96..d38f533 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, or Missing +- 🚦 **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 diff --git a/THEORY-OF-OPERATIONS.md b/THEORY-OF-OPERATIONS.md index b364f53..234ab5e 100644 --- a/THEORY-OF-OPERATIONS.md +++ b/THEORY-OF-OPERATIONS.md @@ -345,4 +345,4 @@ The review plan and review report together provide the artifact-review evidence - **DO-178C** — peer review records for software life-cycle data [pdfsharp]: https://github.com/empira/PDFsharp -[reqstream]: requirements.md +[reqstream]: https://github.com/demaconsulting/ReqStream From 62e4060d8e95d19b8c3024265ca62aa95648d656 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:00:01 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Update=20self-validation=20test=20list=20in?= =?UTF-8?q?=20README=20and=20guide:=202=20=E2=86=92=207=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- README.md | 16 +++++++++++++--- docs/guide/guide.md | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d38f533..ad33ae4 100644 --- a/README.md +++ b/README.md @@ -139,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 ``` @@ -149,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/docs/guide/guide.md b/docs/guide/guide.md index a5eb734..b5a815d 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 From 8625876d101a666936492294a419f689daf452b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:10:47 +0000 Subject: [PATCH 5/9] Expand user guide: add .reviewmark.yaml format, review-set design guidance, and complete examples Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- docs/guide/guide.md | 336 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 332 insertions(+), 4 deletions(-) diff --git a/docs/guide/guide.md b/docs/guide/guide.md index b5a815d..481f341 100644 --- a/docs/guide/guide.md +++ b/docs/guide/guide.md @@ -174,21 +174,349 @@ 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 by hashing the contents of +every matched file. The fingerprint changes only when **file contents change** — renaming or +moving files without changing their contents does 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 +``` + +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_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 +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 From 3cd9f8ff290d5f611620a1d78f44a49ca87bf5b0 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 11 Mar 2026 08:32:04 -0400 Subject: [PATCH 6/9] Update docs/guide/guide.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/guide/guide.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/guide/guide.md b/docs/guide/guide.md index 481f341..881a65c 100644 --- a/docs/guide/guide.md +++ b/docs/guide/guide.md @@ -245,9 +245,10 @@ The same `!`-prefix syntax applies to the top-level `needs-review` list. ### Fingerprinting -ReviewMark computes a cryptographic fingerprint for each review set by hashing the contents of -every matched file. The fingerprint changes only when **file contents change** — renaming or -moving files without changing their contents does not invalidate a review. +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 From b6e1019342fe0614582d102027a8f1f619cc9de6 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 11 Mar 2026 08:32:25 -0400 Subject: [PATCH 7/9] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ad33ae4..f485428 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ DEMA Consulting tool for automated file-review evidence management in regulated - 🔐 **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, or Missing +- 📊 **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 From 2dc2d1dc0459c9074d3e547ae06442c8256b0c21 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Wed, 11 Mar 2026 08:32:51 -0400 Subject: [PATCH 8/9] Update THEORY-OF-OPERATIONS.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- THEORY-OF-OPERATIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/THEORY-OF-OPERATIONS.md b/THEORY-OF-OPERATIONS.md index 234ab5e..5998be9 100644 --- a/THEORY-OF-OPERATIONS.md +++ b/THEORY-OF-OPERATIONS.md @@ -326,7 +326,7 @@ evidence matching, and report generation using mock data — no live evidence st Two output formats are supported, selected by the `--results` file extension: ```bash -# TRX format (NUnit-compatible) +# TRX format (VSTest-compatible) dotnet reviewmark --validate --results artifacts/reviewmark-self-validation.trx # JUnit XML format From 23e7883e40304ee2da79642c0d2bcee3142bb7c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:41:31 +0000 Subject: [PATCH 9/9] Require date and result in PDF metadata; add tests for missing date/result Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- THEORY-OF-OPERATIONS.md | 7 +-- docs/guide/guide.md | 5 +- requirements.yaml | 10 ++-- src/DemaConsulting.ReviewMark/Index.cs | 25 ++++++--- .../IndexTests.cs | 54 +++++++++++++++++++ 5 files changed, 85 insertions(+), 16 deletions(-) diff --git a/THEORY-OF-OPERATIONS.md b/THEORY-OF-OPERATIONS.md index 5998be9..e639278 100644 --- a/THEORY-OF-OPERATIONS.md +++ b/THEORY-OF-OPERATIONS.md @@ -172,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 @@ -316,7 +316,8 @@ 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 diff --git a/docs/guide/guide.md b/docs/guide/guide.md index 881a65c..12a63b0 100644 --- a/docs/guide/guide.md +++ b/docs/guide/guide.md @@ -387,8 +387,9 @@ and embeds the review metadata in the PDF Keywords field: id=core-module fingerprint=a3f9c2d1... date=2026-03-15 result=pass ``` -The PDF is deposited in the evidence store folder. ReviewMark never dictates file names — the -reviewer uses whatever name the QMS requires. +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 diff --git a/requirements.yaml b/requirements.yaml index ad184d1..b4cc44c 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -271,14 +271,16 @@ sections: 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 to populate review evidence - entries with id, fingerprint, date, result, and file name. PDFs whose Keywords - field does not contain all required keys must be skipped with a warning, ensuring - the index only contains complete, valid entries. + 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 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/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.