Files
ConnorYoh 4ab2505a6c Comment-quality standard, and the gate that enforces it (#7663)
## 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.
2026-08-28 10:56:50 +00:00

30 KiB

AGENTS.md

This file provides guidance to AI Agents when working with code in this repository.

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 dependencies
  • task dev — start backend + frontend concurrently
  • task dev:all — start backend + frontend + engine concurrently
  • task build — build all components
  • task test — run all tests (backend + frontend + engine)
  • task lint — run all linters
  • task format — auto-fix formatting across all components
  • task check — full quality gate (lint + typecheck + test)
  • task clean — clean all build artifacts
  • task docker:build — build standard Docker image
  • task 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 start above handleDragStart is 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 blob says 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 (or task backend:format for 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 (or docker 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 (or task 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 + formatting
  • task engine:install — install Python dependencies via uv
  • task engine:dev — start FastAPI with hot reload (localhost:5001)
  • task engine:test — run pytest
  • task engine:lint — run ruff linting
  • task engine:typecheck — run pyright
  • task engine:format — format code with ruff
  • task engine:tool-models — generate tool_models.py from 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:check passing.
  • 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] or dict[str, object] across important boundaries when a typed model can exist instead.
  • Avoid Any wherever possible.
  • Avoid cast() wherever possible (reconsider the structure first).
  • All shared models should subclass stirling.models.ApiModel so 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-ai own 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_models is 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:build then serve dist/ folder
    • Development: task desktop:dev for desktop dev mode

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 .env in proprietary mode)
    • frontend/editor/.env.saas — SaaS-only vars (layered on top of .env in SaaS mode)
    • frontend/editor/.env.desktop — desktop (Tauri)-only vars (layered on top of .env in 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.local files — Vite automatically layers them on top
  • Never use || 'hardcoded-fallback' inline — put defaults in the committed env files
  • task frontend:prepare creates empty .local override files on first run; pass MODE=saas or MODE=desktop to also create the mode-specific .local file
  • Prepare runs automatically as a dependency of all dev*, build*, and desktop* tasks
  • See frontend/README.md#environment-variables for 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:

  1. Core defines stub component (returns null or no-op)
  2. Desktop/proprietary overrides with same path/name
  3. Core imports via @app/* - higher layer "shadows" core in those builds
  4. No @ts-ignore, no isTauri() 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 customProcessor for 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 /frontend directory
    • 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 feature
      • core/components/tools/: Individual PDF tool implementations
      • core/components/viewer/: PDF viewer components
      • core/components/pageEditor/: Page manipulation UI
      • core/components/tooltips/: Help tooltips for tools
      • core/components/shared/: Reusable UI components
    • core/contexts/: React Context providers
      • FileContext.tsx: Central file state management
      • file/: File reducer and selectors
      • toolWorkflow/: Tool workflow state
    • core/hooks/: Custom React hooks
      • hooks/tools/: Tool-specific operation hooks (one directory per tool)
      • hooks/tools/shared/: Shared hook utilities (useToolOperation, etc.)
    • core/constants/: Application constants and configuration
    • core/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 directly

    • public/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_*.properties files
    • Frontend: JSON files in frontend/editor/public/locales/ (converted from .properties)
    • Conversion Script: scripts/convert_properties_to_json.py

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.sh validates all Docker variants
  • Manual Testing: No unit tests currently - relies on UI and API testing

Development Workflow

  1. 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)
  2. Quality Gate: Run task check before submitting PRs
  3. Docker Testing: Use ./test.sh for full Docker integration tests
  4. Code Style: Spotless enforces Google Java Format automatically (task backend:format)
  5. Translations:
    • Backend: Use helper scripts in /scripts for multi-language updates
    • Frontend: Update JSON files in frontend/editor/public/locales/ or use conversion script
  6. 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 useToolOperation orchestrator
    • Individual hooks: useToolState, useToolApiCalls, useToolResources
    • Utilities: toolErrorHandler, toolResponseProcessor, toolOperationTracker
    • Pattern: Each tool creates focused operation hook, UI consumes state/actions
  • 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-US only - all other languages (including en-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 useToolOperation hook pattern (see useCompressOperation.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.md for 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:

  1. Open an existing module in app/core/ or app/common/ and grep for the actual imports being used — import tools.jackson... not import com.fasterxml.jackson..., and the new org.springframework.boot 4.x package layout.
  2. 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/).
  3. 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.