Document management with OCR, AI summarization, and full-text search. .NET 10 backend. Frontend story: the production demo UI served by compose.yaml/nginx (at /) is PaperlessUI.Blazor — an Interactive-Server Blazor port of the original PaperlessREST/wwwroot/ SPA, which it replaced at the nginx root (drag-drop upload, live OCR + AI-summary over SSE, SignalR circuit). React is the canonical/priority frontend implementation and Angular a parallel one kept for stack comparison; both consume the same /api/* backend and are built/validated in CI but are not the deployed compose UI. The wwwroot/ SPA still ships (served by the REST app's UseStaticFiles) as the original Blazor ported from.
This file is the on-disk source of truth for working in this repo. Read it before touching anything.
AGENTS.mdis the canonical copy;CLAUDE.mdis a symlink to it, so Claude Code and anyAGENTS.md-aware tool read the same guide.
Paperless.slnx # modern slnx; flat — NUKE 10 doesn't traverse <Folder> wrappers
├── PaperlessREST/ # ASP.NET Core API (REST + SSE)
│ ├── wwwroot/ # Original vanilla Bootstrap SPA (still served via UseStaticFiles); Blazor is the port that took over nginx /
│ └── sample-data/ # XML batch fixtures (input/archive/error), mounted by compose
├── PaperlessServices/ # BackgroundService worker (OCR + GenAI)
├── PaperlessREST.Tests/ # xUnit v3 + Testcontainers
├── PaperlessServices.Tests/ # xUnit v3 + Testcontainers
├── Paperless.TestSupport/ # Shared test lib — ContainerFixtureBase (template-method) + builders + TestPdf + AsyncCleanup
├── PaperlessUI.Blazor/ # Blazor Web App (Interactive Server) — production demo UI behind nginx /; in slnx, compiled by backend CI, has Dockerfile + paperless-blazor compose service
├── PaperlessUI.React/ # Vite + React 19 + TS (canonical frontend) — PaperlessUI.React.esproj
├── PaperlessUI.Angular/ # Angular 21 + pnpm (parallel implementation) — PaperlessUI.Angular.esproj
├── Pipeline/ # NUKE build (Build.csproj)
├── docker/, compose.yaml
└── docs/99_Reference/Rating-Matrix/ # course grading rubric (PDF + xlsx)
React + Angular share the backend so the same use-cases can be compared across stacks. Blazor is the deployed demo UI (a vanilla port of the wwwroot SPA): it's in Paperless.slnx, compiled by the backend CI job, and its Dockerfile + paperless-blazor compose service sit behind nginx at /.
NUKE-based, single entry point. Targets compose via Pipeline/Components/*.cs (ICompile, ITest, ICoverage, IReportCoverage). build.sh is the only bootstrapper kept at root; the Windows build.cmd/build.ps1 were retired (regenerate via NUKE setup if Windows support is ever needed).
./build.sh Compile # full slnx build
./build.sh UnitTests # MTP v2 + xUnit v3, no containers
./build.sh IntegrationTests # spins Testcontainers (Postgres, MinIO, RabbitMQ, ES)
./build.sh Coverage # Cobertura via the MTP CodeCoverage extension
./build.sh ReportCoverage --coverage-min-line 0 --coverage-min-branch 0 --coverage-format markdown --coverage-exclude-generated-param trueThe ReportCoverage gate is report-only (thresholds 0/0). CI publishes the markdown
summary and Codecov diff; regressions surface in PR review, not as a hard build fail.
The React/Angular UIs are esproj and build via their pnpm toolchains, never via NUKE. Blazor is a .csproj in the slnx, so ./build.sh Compile builds it like any backend project:
cd PaperlessUI.React && pnpm install --frozen-lockfile && pnpm dev # canonical
cd PaperlessUI.Angular && pnpm install --frozen-lockfile && pnpm start # parallel impl
dotnet run --project PaperlessUI.Blazor # deployed demo UI (also compiled by ./build.sh Compile).github/workflows/ci.yml runs three parallel jobs on every push/PR:
| job | gate | runs |
|---|---|---|
Build & Test (backend) |
required | NUKE UnitTests → IntegrationTests → Coverage → markdown summary (no hard gate) → Codecov upload |
Build (PaperlessUI.Angular) |
non-blocking | pnpm install --frozen-lockfile && pnpm run build (ng's default config is production — do NOT pass --configuration production after --; pnpm 10 passes -- literally to scripts) |
Build (PaperlessUI.React) |
non-blocking | pnpm install --frozen-lockfile && pnpm run build |
PaperlessUI.Blazor is a .csproj in the slnx, so the backend job compiles it as part of the NUKE build — there is no separate Blazor CI job; its Dockerfile + paperless-blazor compose service serve it behind nginx at /.
The workflow declares concurrency: (cancel-in-progress on PRs only) and least-privilege permissions: (read defaults; codecov/check annotations escalate as needed).
Coverage uploads to https://codecov.io/gh/ANcpLua/Paperless via tokenless OIDC. codecov.yml ignores host entry points, EF migrations, the pipeline, and test projects so the score reflects production surface only.
Coverage uses DotCov.Nuke (NuGet, owner ANcpLua/dotcov) as a NUKE component package — Pipeline/Build.csproj:29 references it, Pipeline/Build.cs:43 mixes the ICoverageReport interface into the build class. There is no dotnet tool CLI; the gate runs via ./build.sh ReportCoverage. Two parameters matter on this repo:
--coverage-exclude-generated-param true(the NUKE [Parameter] kebab-cased form of--exclude-generated) strips generators, migrations, designer files, async state-machine sequence points,Program.cs. With this flag, the gate metric is 99.8% line coverage (928/930) — see the sequence-point note in Gotchas. Disable by passingfalseto see the raw numbers (currently ~73% with generated code mixed in).--coverage-min-line/--coverage-min-branchset the gate threshold. CI passes0 / 0(report-only mode); per-file numbers and the overall summary still publish to the workflow log as markdown. Codecov-side gating is configured incodecov.yml(project: auto, patch: 80%).
Keep build logic in pure C# fluent NUKE. Reject Bash / PS1 / Make / raw CLI in Pipeline/. The reusable patterns:
- OCP via interfaces.
interface ICompile : INukeBuild { Target Compile => _ => _.Executes(...); }. Compose with.TryDependsOn<ICompile>()so components stay decoupled. - SRP per target. One concern per target (Compile vs Pack vs Deploy). Group changes by reason.
- Typed inputs.
[Parameter]for inputs,[Secret]for keys,[Solution(GenerateProjects=true)]for project access. No magic strings. - Loose dependencies.
.TryDependsOn<T>()lets a component opt in;.DependsOn<T>()is for the "knowing" side. - Fluent builders.
DotNetBuild(_ => _.SetConfiguration(c).SetVerbosity(v))— neverSetArgument(...). - Failure handling.
.AssuredAfterFailure()for cleanup,.ProceedAfterFailure()to continue on soft failure.
Anti-patterns that fail the self-check:
- Any inline shell in
Pipeline/. - Concrete class targets instead of interfaces — breaks OCP.
- Hardcoded project names instead of
Solution.GetProject("...")— and rememberSolution.GetProjectdoes NOT recurse into<Folder>wrappers in NUKE 10's slnx parsing.
- NUKE 10 + slnx +
<Folder>wrappers:Solution.GetProject(name)returns null. Keep Paperless.slnx flat (which it already is). - pnpm 10
--separator:pnpm run X -- --flagpasses-- --flagliterally to the script. Drop the--; pass flags by editingpackage.jsonscripts or by setting framework-native defaults. secrets.CODECOV_TOKENempty for tokenless upload: passingtoken: ${{ secrets.CODECOV_TOKEN }}when no secret exists expands to an empty string the Codecov CLI rejects asGot unexpected extra arguments (***). Omit the line entirely for public repos.- BackgroundService race in tests:
BackgroundService.StartAsyncreturns beforeExecuteAsyncruns. Don't wait on a log predicate that's already true for an empty snapshot (_ => true). Signal viaTaskCompletionSourcefrom a mock'sDisposeAsyncorAckAsync, then await that. - Hangfire NU1107: Hangfire + Hangfire.AspNetCore must move together. Renovate split them once and broke restore on
mainfor days. - Gemini placeholder key:
.env.testshipsGEMINI__APIKEY=test-gemini-key-placeholder. The integration test must mockITextSummarizer(FakeTextSummarizerinPaperlessServices.Tests/Integration/), not hit the real API. - The "missing 2 lines" of coverage are sequence-point artifacts, not testable code. Gate metric is 928/930 = 99.8%. The two unhit lines are the closing braces of try/catch blocks in
GenAiResultListener.csandReportProcessor.cs:120— Roslyn emits a sequence point on the fall-through-after-catch path, but every code path inside those try blocks eitherreturns early or unwinds via exception. No test can reach those sequence points without breaking the design intent. Leave them; do not chase 100% by restructuring around the coverage tool. - Custom SDK in Dockerfiles: PaperlessREST.csproj uses
<Project Sdk="ANcpLua.NET.Sdk.Web">(version pinned inglobal.jsonmsbuild-sdks). For docker builds,global.json+nuget.config+Directory.Packages.props+Version.propsmust be COPYed into the build context BEFOREdotnet restore, otherwise the SDK resolver errors with "Could not resolve SDK". Both Dockerfiles do this; if you copy a Dockerfile for a new project, preserve those COPY lines.
docs/99_Reference/Rating-Matrix/ is the rubric. The repo maps to it:
| Category | Where |
|---|---|
| Use Cases / REST API | PaperlessREST/Features/DocumentManagement/Presentation/Endpoints/ |
| Web Frontend | Blazor (PaperlessUI.Blazor/) — the production demo UI nginx serves at / (Interactive-Server port of the wwwroot SPA). React (PaperlessUI.React/, canonical) + Angular (PaperlessUI.Angular/, parallel impl) share the backend for stack comparison. The original PaperlessREST/wwwroot/ SPA still ships via UseStaticFiles. |
| Queues | SWEN3.Paperless.RabbitMq consumed by REST + Services |
| Logging | Microsoft.Extensions.Logging; FakeLogger in tests |
| Validation | Mapster + DataAnnotations + FluentValidation at the boundary |
| Stability | Microsoft.Extensions.Http.Resilience (Polly v8) on Gemini |
| Unit Tests | *.Tests/Unit/** with MockBehavior.Strict repositories |
| Integration Tests | *.Tests/Integration/** on Testcontainers |
| Clean-Code | SOLID, ErrorOr result types, vertical-slice Feature folders |
| Packaging | compose.yaml + per-project Dockerfile |
| Loose Coupling | every cross-layer call is interface-mediated |
| Mapper | Mapster (MapsterExtensions.Generator) |
| DI | IServiceCollection extension methods per feature |
| DAL | EF Core 10 + repository pattern (IDocumentRepository) |
| BL | DocumentService, OcrProcessor, GenAI worker |
| Trunk-based / CI / Docs | .github/workflows/ci.yml, branch-protected main, this file |
Inline versions drift; treat Version.props + Directory.Packages.props as the source of truth and the table below as commentary on what matters.
version (see Version.props for exact) |
|
|---|---|
| .NET SDK | 10.0.300 (global.json, rollForward latestFeature) |
| EF Core | 10.0.x (currently 10.0.8 — Version.props:MicrosoftEntityFrameworkCoreVersion) |
| Hangfire | 1.8.23 (Hangfire + Hangfire.AspNetCore share $(HangfireVersion) MSBuild var; Hangfire.PostgreSql 1.21.1) |
| xUnit | v3.2.x + MTP v2 (Version.props:XunitV3MtpV2Version) |
| SWEN3.Paperless.RabbitMq | 2.3.1 |
| React | 19.2 + Vite 8 + TypeScript 6 |
| Angular | 21 (pnpm 10.30.2 via corepack) |
| Node | 22 LTS in CI |
| Postgres / RabbitMQ / MinIO / Elasticsearch | 17-alpine / 4.3.0-management / RELEASE-date-pinned / 9.1.3 |
- Branch protection on
mainrequiresBuild & Test (backend)green. No admin bypass. - No commits to
maindirectly — PR everything. - No "AI slop" in PR titles/bodies. Write what the change does and why, no meta narration.
- Don't rewrite a test to pass; rewrite the production code (or the timing) to make the assertion truthful. See
OcrWorker.EmptyStream_CompletesGracefullyfor the canonical fix.