## The problem AI PRs write comments that restate the line below them, mark sections with box drawing, and narrate the diff. Nothing in the repo said not to, and nothing checked. `AGENTS.md` had one line about comments and it was buried in the Python section. Banners and `Step N:` narration have zero occurrences in the 15 months before Aug 2025, so this is new. ## The fix A written standard, plus a linter that enforces the mechanical part of it on added lines only. - [devGuide/CODE_COMMENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/devGuide/CODE_COMMENTS.md) holds the reasoning and worked examples; a section in [AGENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/AGENTS.md) holds the operative rules, kept short so they stay in an agent's context. The two are split by kind rather than duplicated, because the same prose in two places drifts. - Rules in [comment-rules.mjs](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs), shared by both engines. - Two engines. `.ts` / `.tsx` / `.mjs` go to an [oxlint JS plugin](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-oxlint-plugin.mjs) so comments come from the parser rather than a line scan; `.java` / `.py` go to a [line scanner](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint.mjs). Neither reads the other's files, so they cannot disagree about one file. - Between them they read every comment form the repo writes: `//` and `/* */`, Javadoc and JSDoc, JSX comments, `#`, and Python docstrings. - Runs in `task pre-commit`, so the git hook and the `pre_commit.yml` CI job both get it, and as a Claude Code [`Stop` hook](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-hook.mjs) so an agent fixes the comment inside the turn that wrote it. ## The rules The part worth arguing about. **Every rule blocks.** A rule that only warns is a rule nobody acts on, so a finding you believe is wrong is a bug in the rule: narrow it, or mark the line and say why. | | Fires on | | --- | --- | | [CMT001](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L71) | Every word in the comment already appears in the code below it. Max 6 words, skipped for prose punctuation and for a bare Arrange/Act/Assert marker | | [CMT002](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L92) | 4+ rule or box-drawing characters, or a bare section label from [a fixed list](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L84) (`Types`, `Helpers`, `State`, `Handlers`, ...) | | [CMT003](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L110) | `Step N:` with a separator, or `Then,` / `Next,` / `Finally,`. Suppressed in test files | | [CMT004](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L129) | A comment about the code's own past: `this used to`, `renamed from`, `was previously called`. Suppressed in test files | | [CMT005](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L154) | 3+ consecutive comment lines where 2/3 [parse as code](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L143) | | [CMT006](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L31) | A run of implementation comment over 12 lines, outside the first 5 lines of a file. Doc blocks are exempt, because the standard asks for thorough contracts | | [CMT007](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L180) | A parameter or return description that adds no word its name lacks. Reads Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google `name: description` | | [CMT008](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L239) | An allow directive naming a rule that does not exist, or one that silenced nothing | | [CMT009](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L219) | A `TODO` / `FIXME` / `HACK` naming no issue or link. An owner is not accepted: a username goes stale, an issue outlives it | Each rule carries the readings it deliberately excludes, next to the rule. Those exclusions came from running the rules over this repo, not from taste: `CMT004` does not match a bare "no longer needed" because that is as often about runtime lifecycle as about history, and `CMT003` needs a separator after the number so a wrapped line beginning "step 2 unmounts + remounts the panel" reads as the prose it is. A comment sharing a line with code is judged by the rules that do not depend on the code below it, so a trailing `// TODO fix this` or `/* this used to run before the flush */` still reports, while `50L * 1024 * 1024 // 50 MB` does not. `CMT001` would have been wrong about six in seven trailing comments here, so it stays out of them. If a finding is wrong, `// comment-lint-allow: CMT002` on the line above. Rule-specific, [no blanket disable](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L229). A directive naming a rule that does not exist, or silencing nothing, is itself a `CMT008` failure, so a typo cannot quietly disable a rule and a stale one gets deleted rather than accumulating. No native linter covers `CMT007`. `eslint-plugin-jsdoc`'s `require-param-description`, Checkstyle's `NonEmptyAtclauseDescription` and ruff's D-rules all check that a description exists, not whether it says anything. ## Scoping Added comment **text** only, not lines git calls new. Reindenting a file or moving a block makes git mark untouched comments as added; findings are matched against the comment text at the base, so only genuinely new content reports. The whole file is read and every comment in it evaluated. Only the *reporting* is filtered, so a rule still sees the code a comment introduces, the full run it belongs to, and the base version of the file. Existing tree is untouched. `task pre-commit:comment-lint:all` reports it and always exits 0: | | java | ts/js | py | | --- | --- | --- | --- | | findings | 1,218 | 741 | 204 | 2,163 across 542 files, mostly `CMT002` banners (1,482) and `CMT001` restatements (456). Clearing it is separate work, by directory. Not in this PR: an advisory LLM review layer for the things no pattern can judge. ## Verification Run against [#7494](https://github.com/Stirling-Tools/Stirling-PDF/pull/7494) as CI would, in a throwaway worktree: **two findings on a 78 file, +4,512 line change, both genuine banners, in 952ms**. A whole-file scan of those same files gives 11; the other 9 were withheld because that PR's author did not write them, and they are the `@param teamId the team ID` shape this standard exists to stop. Both scanners blank string and character literals before looking for comment markers, because a partial lex desynchronises everything after it: one apostrophe in a Java comment, or one Python template whose closing quotes start a line, is enough to read dozens of lines of code as a single comment. Two fixtures carry canaries that stop being reported if either engine ever desynchronises again. The [fixture corpus](https://github.com/Stirling-Tools/Stirling-PDF/tree/claude/ai-pr-comment-quality-dd970e/scripts/lint/fixtures) pins all 9 rules against both engines, and `--selftest` fails if the two disagree about the same file. ## Two things reviewers should know **The oxlint JS plugin API is alpha.** oxlint itself is stable and already this repo's frontend linter; the plugin API is the new dependency. Its documented failure mode ([oxc#25203](https://github.com/oxc-project/oxc/issues/25203)) is being skipped silently while oxlint still reports success. That affects the standalone release binary rather than the npm package this invokes, but the class of failure reads exactly like clean code, so the run asserts `number_of_rules >= 1` from oxlint's own report and a broken engine exits 2 rather than passing. If the API ever breaks, the fallback is folding these rules into the line scanner, which already implements all nine for Java and Python. **`.claude/settings.json` is now committed**, carrying the hook and nothing else: 19 lines, no `permissions`, nothing machine-specific. That partly reverts `c35546a212` ("Ignore claude dir"), which existed because this file had twice been committed by accident with a personal `permissions` allowlist, once with absolute machine paths. Personal config still belongs in `.claude/settings.local.json`, which the new pattern keeps ignored, and hook entries merge across the two so nobody's own hooks are lost. If you already hand-wrote a `.claude/settings.json`, copy it somewhere first: that path used to be git-ignored, and git overwrites an ignored file without warning when a commit starts tracking it. Across 19 local checkouts here, 13 have `settings.local.json` and none has a hand-written `settings.json`. To turn the hook off, `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in local settings. Claude Code can only disable all hooks at once, hence the switch. The commit-time gate still applies. ## How to test ```bash task pre-commit:comment-lint:ci ``` The fixture corpus, then the diff. The corpus checks the rules themselves rather than the code under review, so it runs on CI and before a rule change, not on every local commit. ```bash task comment-lint:branch ``` `clean (34 files in scope)`. `task comment-lint` is the same thing scoped to uncommitted work, which is what the git hook and CI run. To watch it bite, add `// Is banner` above `export function isBanner` in `scripts/lint/comment-rules.mjs` and run `task comment-lint`: one `CMT001`, exit 1. The gate covers its own source, which is why these scripts have no section dividers. ```bash task pre-commit:comment-lint:all ``` The standing backlog, report-only. Verified on the pinned oxlint 1.77.0, not only the 1.79 the plugin was prototyped against.
30 KiB
AGENTS.md
This file provides guidance to AI Agents when working with code in this repository.
Taskfile (Recommended)
This project uses Task as a unified command runner. All build, dev, test, lint, and docker commands can be run from the repo root via task <command>. Run task --list to see all available commands.
Task desc: fields should describe what the task does, not how it does it. Keep them generic and stable: don't reference implementation details like aliases, internal helpers, mode flags, or which other task delegates to which. The description is for users picking a command from task --list, not a changelog of refactors.
Quick Reference
task install— install all dependenciestask dev— start backend + frontend concurrentlytask dev:all— start backend + frontend + engine concurrentlytask build— build all componentstask test— run all tests (backend + frontend + engine)task lint— run all linterstask format— auto-fix formatting across all componentstask check— full quality gate (lint + typecheck + test)task clean— clean all build artifactstask docker:build— build standard Docker imagetask docker:up— start Docker compose stack
Comments
A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it.
Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue.
Write a comment when it does one of these four jobs:
- Contract. What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring.
- Why. The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason.
- Hazard. "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z".
- Map. A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not.
Never write:
- A comment that restates the next line.
// Handle drag startabovehandleDragStartis noise. - Section banners or position markers:
// --- Types ---,// Helpers,// =====. - Step narration in a function body (
// Step 1:,// Then we). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine. - Commented-out code. Delete it.
- Doc tags that restate the signature.
@param blob - The blobsays nothing; omit the tag rather than pad it. - Docs on self-explanatory members with no constraint to state.
Two tests before keeping a comment:
- Delete it. Is any information lost? If not, it stays deleted.
- Could a name carry it instead? A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change.
A comment at the end of a line usually decodes that line, and that is worth keeping: {0x25, 0x50} // "%PDF", 50L * 1024 * 1024 // 50 MB. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other.
A reference is supplementary, never load-bearing: the comment must survive deleting it. // See #1234 is a dead end; // saving first loses every annotation (#6865) is not. Prefer a spec (RFC 3161) or CVE where one applies.
A TODO needs an issue, not an owner: // TODO(#1234): re-enable the gate once account syncing lands. If it is not worth an issue, it is not worth a TODO. A question is not a TODO.
A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo.
task comment-lint checks the mechanical part of this on the lines you add, and runs inside task pre-commit. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md
Common Development Commands
Build and Test
- Build project:
task build - Run backend locally:
task backend:dev - Run all tests:
task test(or individually:task backend:test,task frontend:test,task engine:test) - Docker integration tests:
./test.sh(builds all Docker variants and runs comprehensive tests) - Code formatting:
task format(ortask backend:formatfor Java only) - Full quality gate:
task check(runs lint + typecheck + test across all components)
After modifying any files in the project, you must run the relevant task check command that covers that area of the code. For example, when editing frontend files run task frontend:check; for Python engine files run task engine:check; for Java backend files run task backend:check.
Docker Development
- Build standard:
task docker:build(ordocker build -t stirling-pdf -f docker/embedded/Dockerfile .) - Build fat version:
task docker:build:fat - Build ultra-lite:
task docker:build:ultra-lite - Start compose stack:
task docker:up(ortask docker:up:fat,task docker:up:ultra-lite) - Stop compose stack:
task docker:down - View logs:
task docker:logs - Example compose files: Located in
exampleYmlFiles/directory
Security Mode Development
Set DOCKER_ENABLE_SECURITY=true environment variable to enable security features during development. This is required for testing the full version locally.
Python Development (AI Engine)
The engine is a Python reasoning service for Stirling: it plans and interprets work, but it does not own durable state, and it does not execute Stirling PDF operations directly. Keep the service narrow: typed contracts in, typed contracts out, with AI only where it adds reasoning value. The frontend calls the Python engine via Java as a proxy.
Python Commands
All engine commands run from the repo root using Task:
task engine:check— run all checks (typecheck + lint + format-check + test)task engine:fix— auto-fix lint + formattingtask engine:install— install Python dependencies via uvtask engine:dev— start FastAPI with hot reload (localhost:5001)task engine:test— run pytesttask engine:lint— run ruff lintingtask engine:typecheck— run pyrighttask engine:format— format code with rufftask engine:tool-models— generatetool_models.pyfrom the Java OpenAPI spec
The project structure is defined in engine/pyproject.toml. Any new dependencies should be listed there, followed by running task engine:install.
Python Code Style
- Keep
task engine:checkpassing. - Use modern Python when it improves clarity.
- Prefer explicit names to cleverness.
- Avoid nested functions and nested classes unless the language construct requires them.
- Prefer composition to inheritance when combining concepts.
- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle.
- Comments follow the repo-wide rules in the "Comments" section above.
Python Typing and Models
- Deserialize into Pydantic models as early as possible.
- Serialize from Pydantic models as late as possible.
- Do not pass raw
dict[str, Any]ordict[str, object]across important boundaries when a typed model can exist instead. - Avoid
Anywherever possible. - Avoid
cast()wherever possible (reconsider the structure first). - All shared models should subclass
stirling.models.ApiModelso the service behaves consistently. - Do not use string literals for any type annotations, including
cast().
Python Configuration
- Keep application-owned configuration in
stirling.config. - Only add
STIRLING_*environment variables that the engine itself truly owns. - Do not mirror third-party provider environment variables unless the engine is actually interpreting them.
- Let
pydantic-aiown provider authentication configuration when possible.
Python Architecture
Package roles:
stirling.contracts: request/response models and shared typed workflow contracts. If a shape crosses a module or service boundary, it probably belongs here.stirling.models: shared model primitives and generated tool models.stirling.agents: reasoning modules for individual capabilities.stirling.api: HTTP layer, dependency access, and app startup wiring.stirling.services: shared runtime and non-AI infrastructure.stirling.config: application-owned settings.
Source of truth:
stirling.models.tool_modelsis the source of truth for operation IDs and parameter models.- Do not duplicate operation lists if they can be derived from
tool_models.OPERATIONS. - Do not hand-maintain parallel parameter schemas when the generated tool models already define them.
- If a tool ID must match a parameter model, validate that relationship explicitly in code.
Boundaries:
- Keep the API layer thin. Route modules should bind requests, resolve dependencies, and call agents or services. They should not contain business logic.
- Keep agents focused on one reasoning domain. They should not own FastAPI routing, persistence, or execution of Stirling operations.
- Build long-lived runtime objects centrally at startup when possible rather than reconstructing heavy AI objects per request.
- If an agent delegates to another agent, the delegated agent should remain the source of truth for its own domain output.
Python AI Usage
- The system must work with any AI, including self-hosted models. We require that the models support structured outputs, but should minimise model-specific code beyond that.
- Use AI for reasoning-heavy outputs, not deterministic glue.
- Do not ask the model to invent data that Python can derive safely.
- Do not fabricate fallback user-facing copy in code to hide incomplete model output.
- AI output schemas should be impossible to instantiate incorrectly.
- Do not require the model to keep separate structures in sync. For example, instead of generating two lists which must be the same length, generate one list of a model containing the same data.
- Prefer Python to derive deterministic follow-up structure from a valid AI result.
- Use
NativeOutput(...)for structured model outputs. - Use
ToolOutput(...)when the model should select and call delegate functions.
Python Testing
- Test contracts directly.
- Test agents directly where behaviour matters.
- Test API routes as thin integration points.
- Prefer dependency overrides or startup-state seams to monkeypatching random globals.
Frontend Development
- Frontend dev server:
task frontend:dev— requires backend on localhost:8080 - Tech Stack: Vite + React + TypeScript + Mantine UI + TailwindCSS
- Proxy Configuration: Vite proxies
/api/*calls to backend (localhost:8080) - Build Process: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
- Package Installation:
task frontend:install - Deployment Options:
- Desktop App:
task desktop:build - Web Server:
task frontend:buildthen serve dist/ folder - Development:
task desktop:devfor desktop dev mode
- Desktop App:
Environment Variables
- All
VITE_*variables must be declared in the appropriate committed env file:frontend/editor/.env— core and shared vars (base, loaded in every mode)frontend/editor/.env.proprietary— proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of.envin proprietary mode)frontend/editor/.env.saas— SaaS-only vars (layered on top of.envin SaaS mode)frontend/editor/.env.desktop— desktop (Tauri)-only vars (layered on top of.envin desktop mode)
- These files are committed to Git and must not contain private keys
- Local overrides (API keys, machine-specific settings) go in uncommitted sibling
.env.local/.env.saas.local/.env.desktop.localfiles — Vite automatically layers them on top - Never use
|| 'hardcoded-fallback'inline — put defaults in the committed env files task frontend:preparecreates empty.localoverride files on first run; passMODE=saasorMODE=desktopto also create the mode-specific.localfile- Prepare runs automatically as a dependency of all
dev*,build*, anddesktop*tasks - See
frontend/README.md#environment-variablesfor full documentation
Import Paths - CRITICAL
ALWAYS use @app/* for imports. Do not use @core/* or @proprietary/* unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/--c-* token system and the rule that literal colours live only in primitives.css.
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
import { useFileContext } from "@app/contexts/FileContext";
import { FileContext } from "@app/contexts/FileContext";
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
import { AppLayout } from "@core/components/AppLayout";
import { useFileContext } from "@proprietary/contexts/FileContext";
Only use explicit aliases when:
- Building layer-specific override that wraps a lower layer's component
- Example:
import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"when creating proprietary/AppProviders.tsx that extends the core version
The @app/* alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend cloud/ Layer" below for the full per-flavor order.
Frontend cloud/ Layer
@app/* resolves through a per-flavor cascade — first existing file wins (shadow/override):
- core → core
- proprietary → proprietary → core
- saas → saas → cloud → proprietary → core
- desktop → desktop → cloud → proprietary → core
- cloud → cloud → proprietary → core
What goes where:
- core — OSS base.
- proprietary — licensed / offline features.
- cloud — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding.
- saas — web-only: Supabase web auth, AuthCallback, avatar canvas,
window.location. - desktop — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
cloud/ MUST NOT import @supabase/*, @tauri-apps/*, raw fetch, window.location, localStorage, sessionStorage, or import.meta.env.VITE_* (all enforced by the linter). It reaches platform-specific things only via @app/* seams: services/apiClient, auth/session.getAccessToken, auth/supabase, platform/openExternal, services/billing, hooks/useSaaSMode — each provided per-platform in saas/ and desktop/.
Rule of thumb — move, don't copy: share via cloud/, override by shadowing the same @app/* path in a leaf (saas/ or desktop/).
Cloud feature flags on desktop. The local AppConfigContext reads /api/v1/config/app-config from the LOCAL bundled backend, so cloud-only flags (aiEngineEnabled, premiumEnabled, …) are never seen on desktop. To read the cloud's view, use useSaasAppConfig() (desktop/hooks/useSaasAppConfig.ts, backed by the general saasAppConfigService — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns null outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. useAiEngineEnabled() (core reads useAppConfig(), desktop reads useSaasAppConfig()) — rather than hardcoding the flag on.
Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
How it works:
- Core defines stub component (returns null or no-op)
- Desktop/proprietary overrides with same path/name
- Core imports via
@app/*- higher layer "shadows" core in those builds - No
@ts-ignore, noisTauri()checks, no runtime conditionals!
Example - Desktop-specific footer:
// core/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (stub)
interface WorkbenchBarFooterExtensionsProps {
className?: string;
}
export function WorkbenchBarFooterExtensions(_props: WorkbenchBarFooterExtensionsProps) {
return null; // Stub - does nothing in web builds
}
// desktop/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (real implementation)
import { Box } from '@mantine/core';
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
interface WorkbenchBarFooterExtensionsProps {
className?: string;
}
export function WorkbenchBarFooterExtensions({ className }: WorkbenchBarFooterExtensionsProps) {
return (
<Box className={className}>
<BackendHealthIndicator />
</Box>
);
}
// core/components/shared/WorkbenchBar.tsx (usage - works in ALL builds)
import { WorkbenchBarFooterExtensions } from '@app/components/workbenchBar/WorkbenchBarFooterExtensions';
export function WorkbenchBar() {
return (
<div>
{/* In web builds: renders nothing (stub returns null) */}
{/* In desktop builds: renders BackendHealthIndicator */}
<WorkbenchBarFooterExtensions className="workbench-bar-footer" />
</div>
);
}
Build resolution:
- Core build:
@app/*→core/*→ Gets stub (returns null) - Desktop build:
@app/*→desktop/*→ Gets real implementation (shadows core)
Benefits:
- No runtime checks or feature flags
- Type-safe across all builds
- Clean, readable code
- Build-time optimization (dead code elimination)
Multi-Tool Workflow Architecture
Frontend designed for stateful document processing:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
- File state and processing results persist across tool switches
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
FileContext - Central State Management
Location: frontend/editor/src/core/contexts/FileContext.tsx
- Active files: Currently loaded PDFs and their variants
- Tool navigation: Current mode (viewer/pageEditor/fileEditor/toolName)
- Memory management: PDF document cleanup, blob URL lifecycle, Web Worker management
- IndexedDB persistence: File storage with thumbnail caching
- Preview system: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
Critical: All file operations go through FileContext. Don't bypass with direct file handling.
Processing Services
- enhancedPDFProcessingService: Background PDF parsing and manipulation
- thumbnailGenerationService: Web Worker-based with main-thread fallback
- fileStorage: IndexedDB with LRU cache management
Memory Management Strategy
Why manual cleanup exists: Large PDFs (up to 100GB+) through multiple tools accumulate:
- PDF.js documents that need explicit .destroy() calls
- Blob URLs from tool outputs that need revocation
- Web Workers that need termination Without cleanup: browser crashes with memory leaks.
Tool Development
Architecture: Modular hook-based system with clear separation of concerns:
-
useToolOperation (
frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts): Main orchestrator hook- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
-
Supporting Hooks:
- useToolState: UI state management (loading, progress, error, files)
- useToolApiCalls: HTTP requests and file processing
- useToolResources: Blob URLs, thumbnails, ZIP downloads
-
Utilities:
- toolErrorHandler: Standardized error extraction and i18n support
- toolResponseProcessor: API response handling (single/zip/custom)
- toolOperationTracker: FileContext integration utilities
Three Tool Patterns:
Pattern 1: Single-File Tools (Individual processing)
- Backend processes one file per API call
- Set
multiFileEndpoint: false - Examples: Compress, Rotate
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
});
Pattern 2: Multi-File Tools (Batch processing)
- Backend accepts
MultipartFile[]arrays in single API call - Set
multiFileEndpoint: true - Examples: Split, Merge, Overlay
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_',
});
Pattern 3: Complex Tools (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide
customProcessorfor full control - Examples: Convert, OCR
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
});
Benefits:
- No Timeouts: Operations run until completion (supports 100GB+ files)
- Consistent: All tools follow same pattern and interface
- Maintainable: Single responsibility hooks, easy to test and modify
- i18n Ready: Built-in internationalization support
- Type Safe: Full TypeScript support with generic interfaces
- Memory Safe: Automatic resource cleanup and blob URL management
Architecture Overview
Project Structure
- Backend: Spring Boot application
- Frontend: React-based SPA in
/frontenddirectory- File Storage: IndexedDB for client-side file persistence and thumbnails
- Internationalization: JSON-based translations (converted from backend .properties)
- PDF Processing: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
- Security: Spring Security with optional authentication (controlled by
DOCKER_ENABLE_SECURITY) - Configuration: YAML-based configuration with environment variable overrides
Controller Architecture
- API Controllers (
src/main/java/.../controller/api/): REST endpoints for PDF operations- Organized by function: converters, security, misc, pipeline
- Follow pattern:
@RestController+@RequestMapping("/api/v1/...")
Key Components
- SPDFApplication.java: Main application class with desktop UI and browser launching logic
- ConfigInitializer: Handles runtime configuration and settings files
- Pipeline System: Automated PDF processing workflows via
PipelineController - Security Layer: Authentication, authorization, and user management (when enabled)
Frontend Directory Structure
The frontend is organized with a clear separation of concerns:
-
frontend/editor/src/core/: Main application code (shared, production-ready components)core/components/: React components organized by featurecore/components/tools/: Individual PDF tool implementationscore/components/viewer/: PDF viewer componentscore/components/pageEditor/: Page manipulation UIcore/components/tooltips/: Help tooltips for toolscore/components/shared/: Reusable UI components
core/contexts/: React Context providersFileContext.tsx: Central file state managementfile/: File reducer and selectorstoolWorkflow/: Tool workflow state
core/hooks/: Custom React hookshooks/tools/: Tool-specific operation hooks (one directory per tool)hooks/tools/shared/: Shared hook utilities (useToolOperation, etc.)
core/constants/: Application constants and configurationcore/data/: Static data (tool taxonomy, etc.)core/services/: Business logic services (PDF processing, storage, etc.)
-
frontend/editor/src/desktop/: Desktop-specific (Tauri) code -
frontend/editor/src/proprietary/: Proprietary/licensed features -
frontend/editor/src-tauri/: Tauri (Rust) native desktop application code -
frontend/editor/public/: Static assets served directlypublic/locales/: Translation JSON files
Component Architecture
- Static Assets: CSS, JS, and resources in
src/main/resources/static/(legacy) +frontend/editor/public/(modern) - Internationalization:
- Backend:
messages_*.propertiesfiles - Frontend: JSON files in
frontend/editor/public/locales/(converted from .properties) - Conversion Script:
scripts/convert_properties_to_json.py
- Backend:
Configuration Modes
- Ultra-lite: Basic PDF operations only
- Standard: Full feature set
- Fat: Pre-downloaded dependencies for air-gapped environments
- Security Mode: Adds authentication, user management, and enterprise features
Testing Strategy
- Integration Tests: Cucumber tests in
testing/cucumber/ - Docker Testing:
test.shvalidates all Docker variants - Manual Testing: No unit tests currently - relies on UI and API testing
Development Workflow
- Local Development (using Taskfile):
- Backend + frontend:
task dev - All services (including AI engine):
task dev:all - Or individually:
task backend:dev(localhost:8080),task frontend:dev(localhost:5173),task engine:dev(localhost:5001)
- Backend + frontend:
- Quality Gate: Run
task checkbefore submitting PRs - Docker Testing: Use
./test.shfor full Docker integration tests - Code Style: Spotless enforces Google Java Format automatically (
task backend:format) - Translations:
- Backend: Use helper scripts in
/scriptsfor multi-language updates - Frontend: Update JSON files in
frontend/editor/public/locales/or use conversion script
- Backend: Use helper scripts in
- Documentation: API docs auto-generated and available at
/swagger-ui/index.html
Frontend Architecture Status
- Core Status: React SPA architecture complete with multi-tool workflow support
- State Management: FileContext handles all file operations and tool navigation
- File Processing: Production-ready with memory management for large PDF workflows (up to 100GB+)
- Tool Integration: Modular hook architecture with
useToolOperationorchestrator- Individual hooks:
useToolState,useToolApiCalls,useToolResources - Utilities:
toolErrorHandler,toolResponseProcessor,toolOperationTracker - Pattern: Each tool creates focused operation hook, UI consumes state/actions
- Individual hooks:
- Preview System: Tool results can be previewed without polluting file context (Split tool example)
- Performance: Web Worker thumbnails, IndexedDB persistence, background processing
Translation Rules
- CRITICAL: Always update translations in
en-USonly - all other languages (includingen-GB) are handled separately - Translation files are located in
frontend/editor/public/locales/ - After changing any translation file, run
task pre-commit:fix
Important Notes
- Java Version: Requires JDK 25.
- Lombok: Used extensively - ensure IDE plugin is installed
- File Persistence:
- Backend: Designed to be stateless - files are processed in memory/temp locations only
- Frontend: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- Security: When
DOCKER_ENABLE_SECURITY=false, security-related classes are excluded from compilation - Import Paths: ALWAYS use
@app/*for imports - never use@core/*or@proprietary/*unless explicitly wrapping/extending a lower layer - FileContext: All file operations MUST go through FileContext - never bypass with direct File handling
- Memory Management: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- Tool Development: New tools should follow
useToolOperationhook pattern (seeuseCompressOperation.ts) - Performance Target: Must handle PDFs up to 100GB+ without browser crashes
- Preview System: Tools can preview results without polluting main file context (see Split tool implementation)
- Adding Tools: See
ADDING_TOOLS.mdfor complete guide to creating new PDF tools
Communication Style
- Be direct and to the point
- No apologies or conversational filler
- Answer questions directly without preamble
- Explain reasoning concisely when asked
- Avoid unnecessary elaboration
Decision Making
- Ask clarifying questions before making assumptions
- Stop and ask when uncertain about project-specific details
- Confirm approach before making structural changes
- Request guidance on preferences (cross-platform vs specific tools, etc.)
- Verify understanding of requirements before proceeding
Stack reality check (don't trust LLM training data)
This codebase is on bleeding-edge versions of its core JVM stack: Spring Boot 4.0.6,
Jackson 3 (tools.jackson), JDK 21/25 source/target with JDK 25 toolchain.
All three are post-2024 releases and your training corpus is overwhelmingly Spring Boot 2/3 and
Jackson 2 patterns — those patterns will compile, run differently, or hallucinate APIs that no
longer exist.
Before writing or editing Spring / Jackson / JDK code:
- Open an existing module in
app/core/orapp/common/and grep for the actual imports being used —import tools.jackson...notimport com.fasterxml.jackson..., and the neworg.springframework.boot4.x package layout. - If you're not sure whether an API exists in this stack version, check the source on disk
first (the dependency JARs are downloaded under
~/.gradle/caches/modules-2/). - Do not silently downgrade a Spring Boot 4 pattern to a Spring Boot 3 equivalent. If something doesn't work, surface it to the human — don't guess.
Same goes for Jackson 3's API surface (renamed ObjectMapper builder methods, new
tools.jackson.databind namespace) and JDK 25 preview features. Ground your code in this repo's
actual imports, not what worked three years ago.