diff --git a/.claude/commands/bug-fix.md b/.claude/commands/bug-fix.md
deleted file mode 100644
index 84c4ec8..0000000
--- a/.claude/commands/bug-fix.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-name: bug-fix
-description: >
- Runs the structured 8-step bug fix workflow for Signalist.
- Trigger this whenever the user describes something that is broken, not working
- as expected, producing an error, or behaving incorrectly — test failures,
- exceptions, wrong output, a feature that used to work and no longer does.
- Trigger even without the word "bug": phrases like "this is broken", "I'm
- getting an error", "it's not working", "the test fails", "something is wrong
- with..." are all clear signals. Use this skill proactively for any defect
- investigation and fix, not just when the user explicitly asks for a bug fix.
----
-
-Read `.claude/workflows/bug-fix.md` and start the bug fix workflow for: $ARGUMENTS
diff --git a/.claude/commands/hotfix.md b/.claude/commands/hotfix.md
deleted file mode 100644
index 7b73c4d..0000000
--- a/.claude/commands/hotfix.md
+++ /dev/null
@@ -1,54 +0,0 @@
----
-name: hotfix
-description: >
- Runs an expedited minimal hotfix workflow for Signalist.
- Trigger this when the user describes a critical or urgent issue that needs
- an immediate, minimal fix — production is broken, a live bug is affecting
- users, something needs to be patched right now. Key signals: "production",
- "prod", "urgent", "critical", "hotfix", "emergency", "live issue", "users
- are affected". Distinct from a regular bug fix: hotfixes are about speed and
- minimal blast radius, not thorough investigation. Use this skill proactively
- whenever urgency and minimalism are the priority.
----
-
-Start an expedited hotfix workflow for the production issue: $ARGUMENTS
-
-This is a HOTFIX — keep changes minimal and focused on the fix only.
-
-## Steps
-
-1. **Create hotfix branch from master**
- ```bash
- git checkout master && git pull
- git checkout -b hotfix/$ARGUMENTS
- ```
-
-2. **Diagnose quickly**
- - Check logs: `docker compose logs app | grep -i error`
- - Identify the exact file and line causing the issue
- - Root cause in one sentence
-
-3. **Minimal fix only**
- - Change the least amount of code necessary
- - No refactoring, no unrelated improvements
- - If the root cause requires a larger fix, implement a safe temporary
- workaround and create a follow-up task
-
-4. **Regression test**
- - Write one test that reproduces the bug and passes after the fix
- - Run `make tests-unit`
-
-5. **Commit**
- ```
- fix(domain): [HOTFIX] brief description
-
- Root cause: [one line]
- Regression test added.
- ```
-
-6. **PR and deploy**
- - `gh pr create` targeting `master`
- - After merge, monitor logs for 10 minutes
-
-Read `.claude/workflows/bug-fix.md` for the full workflow if the issue requires
-deeper investigation.
diff --git a/.claude/commands/new-feature.md b/.claude/commands/new-feature.md
deleted file mode 100644
index c4a51cf..0000000
--- a/.claude/commands/new-feature.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-name: new-feature
-description: >
- Runs the structured 9-step feature implementation workflow for Signalist.
- Trigger this whenever the user asks to add, build, implement, or create any
- new functionality — a new API endpoint, a new page, a new component, a new
- domain handler, a new integration, or any new capability that doesn't exist
- yet. Trigger even if the user doesn't say "feature" explicitly: phrases like
- "I want to add...", "can you build...", "let's implement...", "we need a..."
- are all strong signals. Use this skill proactively — it's the right workflow
- for anything that involves writing new code from scratch.
----
-
-Read `.claude/workflows/new-feature.md` and start the new feature workflow for: $ARGUMENTS
diff --git a/.claude/commands/quality.md b/.claude/commands/quality.md
deleted file mode 100644
index 11ce864..0000000
--- a/.claude/commands/quality.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-name: quality
-description: >
- Runs the full quality check suite (lint, static analysis, rector, unit tests)
- and reports results for Signalist.
- Trigger this whenever the user wants to verify code quality before committing
- or opening a PR, after finishing an implementation, when CI is failing, or
- when they ask about linting, PHPStan errors, test results, or code style.
- Signals: "run quality", "check the code", "does it pass?", "run the tests",
- "any lint errors?", "is it ready to commit?", "make quality". Use this skill
- proactively after any implementation session to catch issues early.
----
-
-Run the full quality check suite and report results.
-
-Execute in order:
-1. `make lint` — PHP CS Fixer
-2. `make analyse` — PHPStan level 9
-3. `make rector` — Rector refactoring check
-4. `make tests-unit` — PHPUnit unit tests
-
-For each step that fails, explain:
-- What failed and why
-- The exact fix needed
-- Whether it can be auto-fixed (e.g. `make lint` can auto-fix CS issues)
-
-Context (optional): $ARGUMENTS
diff --git a/.claude/commands/review.md b/.claude/commands/review.md
deleted file mode 100644
index de4b638..0000000
--- a/.claude/commands/review.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-name: review
-description: >
- Performs a structured code review of current changes against Signalist's
- architecture, quality, security, and testing standards.
- Trigger this whenever the user wants feedback on code they've written or
- that was just implemented — before opening a PR, after finishing a feature,
- when they ask "does this look right?", "is this correct?", "can you review
- this?", or "check my changes". Also trigger when the user explicitly says
- "review" or asks whether the code follows the project's conventions.
- Use this skill proactively at the end of any feature or fix session.
----
-
-Perform a code review of the current changes. Context: $ARGUMENTS
-
-If no context is given, run `git diff HEAD` and `git diff --staged` to get changes.
-
-Review against these criteria:
-
-**Architecture**
-- [ ] CQRS respected: no business logic in controllers, handlers only orchestrate
-- [ ] No coupling between domains
-- [ ] Interfaces used for dependencies (not concrete classes)
-- [ ] Async for RSS/AI operations
-
-**Code Quality**
-- [ ] `declare(strict_types=1)` present
-- [ ] No `mixed` or untyped arrays without docblock
-- [ ] Meaningful names (no `FeedService`, `FeedManager`, etc.)
-- [ ] PHP CS Fixer camelCase test names (no underscores)
-
-**Security**
-- [ ] No hardcoded secrets
-- [ ] Input validated via InputDTO before reaching handler
-- [ ] No raw personal data sent to external AI services
-
-**Testing**
-- [ ] Unit test for every new handler
-- [ ] Behat scenario for every new API endpoint
-- [ ] Test naming: `testInvokeWithValidDataReturnsFeedId()` (camelCase)
-
-**Frontend (if applicable)**
-- [ ] No `any` type
-- [ ] All strings use `t('key')` from react-i18next
-- [ ] Keys added to both `en.json` and `fr.json`
-
-Report findings as: **Critical** (must fix) / **High** (should fix) / **Low** (suggestion).
diff --git a/.claude/commands/simplify.md b/.claude/commands/simplify.md
deleted file mode 100644
index 2057574..0000000
--- a/.claude/commands/simplify.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-name: simplify
-description: >
- Reviews recently changed code for clarity, consistency, and maintainability,
- then fixes any issues found — without changing behavior or adding features.
- Trigger this when the user wants to clean up, tighten, or polish code that
- was just written or modified. Signals: "simplify", "clean this up", "refactor
- this", "make it cleaner", "too complex", "can we improve this?", or after a
- feature is done and the user wants a final pass before committing. Focus on
- removing duplication, improving readability, and applying project conventions
- from CLAUDE.md. Do NOT change behavior or add features.
----
-
-Review the recently changed code for clarity, consistency, and maintainability,
-then fix any issues found. Focus on: removing duplication, improving readability,
-applying project conventions from `CLAUDE.md`. Do not change behavior or add
-features.
diff --git a/.claude/commands/symfony/bug-fix.md b/.claude/commands/symfony/bug-fix.md
new file mode 100644
index 0000000..628bef5
--- /dev/null
+++ b/.claude/commands/symfony/bug-fix.md
@@ -0,0 +1,31 @@
+Fix a Signalist bug using a small, repeatable regression-first workflow.
+
+Bug report or scope: `$ARGUMENTS`
+
+Execution order:
+1. Reproduce the bug from the user report, failing test, logs, or current behavior.
+2. Find the narrowest code path that explains the failure.
+3. Identify the root cause before changing code.
+4. Implement the smallest fix that resolves the bug cleanly.
+5. Add or update a regression test in the same session.
+6. Run the relevant repository quality gates before reporting completion.
+
+Default expectations unless the repo clearly differs:
+- Prefer the smallest understandable fix over broad refactoring.
+- Keep the fix easy for a human reviewer to understand.
+- If API Platform, React Query, or another existing project feature already provides the correct behavior directly, use it instead of adding extra layers.
+- If performance and readability conflict and there is no measured bottleneck, choose readability.
+
+Checklist:
+1. State expected behavior versus actual behavior.
+2. Point to the concrete failing path: endpoint, state processor, handler, repository, message handler, hook, component, or adapter.
+3. Reuse local patterns instead of introducing a new structure during a bug fix.
+4. Add one regression test for the reproduced scenario.
+5. Add extra coverage only for closely related edge cases.
+6. Verify with the commands exposed by `Makefile` and the frontend package scripts when relevant.
+
+Avoid:
+- mixing unrelated cleanup into the fix
+- speculative refactors during an urgent bug fix
+- skipping the regression test unless the repo truly cannot express one
+- introducing extra indirection for a local issue
diff --git a/.claude/commands/symfony/hotfix.md b/.claude/commands/symfony/hotfix.md
new file mode 100644
index 0000000..f374746
--- /dev/null
+++ b/.claude/commands/symfony/hotfix.md
@@ -0,0 +1,20 @@
+Prepare and implement an urgent Signalist hotfix with the smallest safe change set.
+
+Production issue or urgent scope: `$ARGUMENTS`
+
+Workflow:
+1. Confirm the issue is truly urgent and production-facing.
+2. Reproduce or localize the failure quickly from logs, alerts, or the current diff.
+3. Identify the narrowest safe fix with the smallest blast radius.
+4. Prefer a dedicated `hotfix/...` branch if the current branch is shared or protected.
+5. Implement the minimal change only. No opportunistic refactor.
+6. Add the smallest regression coverage the repo can express.
+7. Run the narrowest relevant verification, then the broader checks required by the touched area.
+8. Prepare a Conventional Commit message and deployment notes.
+9. Ask for confirmation before any `git commit` or `git push`.
+
+Rules:
+- Speed matters, but correctness still beats panic.
+- If the real fix is large, prefer a safe containment change plus a follow-up task.
+- Keep the diff extremely narrow and easy to review.
+- Surface any skipped verification explicitly.
diff --git a/.claude/commands/symfony/improve-instructions.md b/.claude/commands/symfony/improve-instructions.md
new file mode 100644
index 0000000..8e9d4ee
--- /dev/null
+++ b/.claude/commands/symfony/improve-instructions.md
@@ -0,0 +1,42 @@
+Review the repository guidance and propose improvements to instruction files without editing them silently.
+
+Scope or context: `$ARGUMENTS`
+
+Target files:
+- `AGENTS.md`
+- `CLAUDE.md`
+- `.claude/rules/*.md`
+- `.claude/patterns.md`
+- `.claude/commands/symfony/*.md`
+- `.claude/skills/*/SKILL.md`
+
+Workflow:
+1. Inspect the current repository shape, quality gates, and recent workflow expectations.
+2. Look for instruction drift:
+ - repeated corrections in recent work
+ - `Makefile`, `composer.json`, `frontend/package.json`, or repo structure changes
+ - architecture or testing conventions no longer reflected in instructions
+ - duplicated or conflicting guidance
+3. Classify each candidate update:
+ - policy
+ - workflow
+ - pattern
+ - project fact
+ - temporary note
+4. Keep only durable, reusable updates. Ignore one-off preferences.
+5. Produce a proposed patch and rationale.
+6. Ask for confirmation before editing any instruction file.
+
+Rules:
+- Never edit instruction files silently.
+- `AGENTS.md` remains the canonical source of repository-specific rules.
+- `CLAUDE.md` must stay a thin pointer.
+- Update `.claude` files only when the improvement is reusable and not just project noise.
+- If commands and skills overlap, keep them aligned with the same underlying rules and patterns.
+
+Output format:
+- `Detected drift`
+- `Proposed updates`
+- `Why each update is useful`
+- `Patch preview`
+- `Confirmation request`
diff --git a/.claude/commands/symfony/new-feature.md b/.claude/commands/symfony/new-feature.md
new file mode 100644
index 0000000..e331c93
--- /dev/null
+++ b/.claude/commands/symfony/new-feature.md
@@ -0,0 +1,44 @@
+Implement a new Signalist feature by mirroring the local repository patterns.
+
+User request: `$ARGUMENTS`
+
+Execution order:
+1. Run the equivalent of `/symfony:scan-project` if context is incomplete.
+2. Find one nearby example in the same backend or frontend area and mirror its structure.
+3. Implement the smallest coherent slice that satisfies the request.
+4. Write tests in the same session.
+5. Run the repository quality gates before reporting completion.
+
+Default expectations unless the repo clearly differs:
+- Follow SOLID principles pragmatically.
+- Prefer clean architecture and hexagonal boundaries when the surrounding code already uses them.
+- If API Platform already provides a direct, readable solution for the requested backend behavior, use the API Platform feature instead of adding extra layers.
+- If the existing frontend pattern already solves the behavior cleanly, reuse it instead of introducing a new client abstraction.
+- Keep API Platform resources, processors, controllers, hooks, and components thin.
+- Keep business logic in handlers, use-cases, domain services, or message handlers.
+- Keep repositories and infrastructure adapters focused on persistence or integration concerns.
+- Keep async boundaries explicit for RSS, AI, email, and sync work.
+- Prefer simple, readable code over clever or highly optimized code.
+- If performance and readability conflict and there is no measured bottleneck, choose readability.
+- Keep the result easy for a human reviewer to follow.
+
+Checklist:
+1. Confirm the target flow: API Platform native, handler-based write flow, async message flow, frontend flow, or a combination.
+2. Reuse existing naming and file placement conventions.
+3. Keep `declare(strict_types=1);` and modern PHP syntax where relevant.
+4. Add or update validation at the input boundary.
+5. Keep exceptions, HTTP error mapping, and frontend error behavior aligned with the existing project.
+6. Add the right tests:
+ - backend unit tests for behavior and orchestration
+ - integration tests when persistence or adapter behavior changes
+ - Behat tests when endpoint behavior changes
+ - frontend tests when UI behavior changes
+7. Verify with the commands exposed by `Makefile` and `frontend/package.json`.
+
+Avoid:
+- business logic in controllers, state processors, or large components
+- new dependencies without explicit approval
+- schema changes without explicit approval
+- adding hexagonal or CQRS indirection when API Platform or the existing frontend stack can solve the case directly and cleanly
+- premature optimization or indirection that hurts readability
+- project reshaping when the request only needs a local change
diff --git a/.claude/commands/symfony/prepare-commit.md b/.claude/commands/symfony/prepare-commit.md
new file mode 100644
index 0000000..32f084f
--- /dev/null
+++ b/.claude/commands/symfony/prepare-commit.md
@@ -0,0 +1,37 @@
+Prepare a commit for the current changes using Conventional Commits and repo-aware verification notes.
+
+Scope or context: `$ARGUMENTS`
+
+Workflow:
+1. Inspect `git status`, the current branch, and the current diff.
+2. Stage the intended files with `git add`.
+3. Run the repository quality checks relevant to the current change, using the real project gates from `Makefile` and `frontend/package.json`.
+4. Review the change for correctness, architecture, validation, regressions, and test completeness.
+5. Review the change with a security lens: auth/authz, input validation, secrets, external calls, privacy, and data exposure.
+6. If the current branch is `main`, `master`, `develop`, or another protected/shared branch, create a dedicated branch before committing.
+7. Build a Conventional Commit message that matches the actual change scope.
+8. Prepare PR-ready notes that explain:
+ - what changed
+ - why it changed
+ - how it was implemented
+ - what was verified
+ - remaining risks or follow-up points
+9. Prepare a verification checklist based on the real project gates from `Makefile` and the frontend scripts.
+10. If the user explicitly asks to commit, ask for confirmation before running `git commit`.
+11. If the user explicitly asks to push, ask for confirmation before running `git push`.
+
+Rules:
+- Never commit or push silently.
+- Treat `git commit` and `git push` as confirmation-required actions even when the user previously asked for preparation work.
+- Stage only the files that belong to the intended change.
+- Prefer a short, accurate Conventional Commit title over a clever one.
+- Do not invent verification steps that do not exist in the repository.
+- If quality, review, or security checks reveal blockers, surface them before proposing the final commit.
+
+Output format:
+- `Pre-commit checks`
+- `Suggested branch`
+- `Suggested commit title`
+- `Suggested commit body`
+- `PR notes`
+- `Verification checklist`
diff --git a/.claude/commands/symfony/review-change.md b/.claude/commands/symfony/review-change.md
new file mode 100644
index 0000000..201dd0f
--- /dev/null
+++ b/.claude/commands/symfony/review-change.md
@@ -0,0 +1,29 @@
+Perform a structured code review for a Signalist change.
+
+Review scope: `$ARGUMENTS`
+
+If the scope is omitted, inspect the current git diff.
+
+Review checklist:
+1. Confirm the change respects the repository architecture and `AGENTS.md`.
+2. Check that API Platform resources, state processors, controllers, hooks, and components remain orchestration-focused.
+3. Check that business rules stay in handlers, domain services, or message handlers.
+4. Check validation at the backend input boundary and safe handling in the frontend.
+5. Check persistence, AI, RSS, and external integration code for leaked business logic or unsafe coupling.
+6. Check error handling and user-visible behavior consistency.
+7. Check test completeness for backend, API, and frontend behavior.
+8. Check that the change can pass the repo quality gates.
+
+Prioritize findings:
+- correctness bugs
+- security regressions
+- architectural regressions
+- missing validation or tests
+- maintainability issues
+
+Response format:
+- findings first, ordered by severity
+- then open questions or assumptions
+- then a short summary only if useful
+
+Do not focus on style nits already covered by formatters, ESLint, or static analysis unless they expose real risk.
diff --git a/.claude/commands/symfony/scan-project.md b/.claude/commands/symfony/scan-project.md
new file mode 100644
index 0000000..eaf5492
--- /dev/null
+++ b/.claude/commands/symfony/scan-project.md
@@ -0,0 +1,32 @@
+Review this Signalist repository and produce an implementation-ready map.
+
+User request: `$ARGUMENTS`
+
+Workflow:
+1. Read `AGENTS.md`, `CLAUDE.md`, `README.md`, `composer.json`, `Makefile`, and `frontend/package.json`.
+2. Inspect the project layout before proposing any change:
+ - `src/`
+ - `frontend/`
+ - `config/`
+ - `tests/`
+3. Detect the active conventions:
+ - Symfony and PHP versions
+ - API Platform usage style
+ - React/Vite/testing usage
+ - Messenger and async flows
+ - AI, RSS, MCP, and privacy-sensitive areas
+ - test stack and quality gates
+4. Identify the nearest existing pattern for the requested area.
+5. Call out project-specific constraints that matter before coding.
+
+Output format:
+- `Context`: 4-8 bullets with relevant architecture and tooling facts
+- `Existing patterns`: file paths worth mirroring
+- `Files likely to change`: exact paths or tight glob patterns
+- `Risks`: regressions, hidden coupling, or prerequisites
+- `Implementation plan`: short numbered list
+
+Rules:
+- Prefer local project patterns over framework defaults.
+- Do not invent new folders or layers when the repo already has a clear shape.
+- Surface any policy in `AGENTS.md` that requires explicit confirmation before changes.
diff --git a/.claude/commands/symfony/security-review.md b/.claude/commands/symfony/security-review.md
new file mode 100644
index 0000000..3947ff8
--- /dev/null
+++ b/.claude/commands/symfony/security-review.md
@@ -0,0 +1,42 @@
+Perform a focused security review for a Signalist change.
+
+Review scope: `$ARGUMENTS`
+
+If the scope is omitted, inspect the current git diff.
+
+Security checklist:
+1. Authentication:
+ - protected routes and operations are explicit
+ - anonymous access is intentional
+2. Authorization:
+ - server-side access checks exist where needed
+ - object ownership, tenant, or user boundaries are enforced
+3. Input handling:
+ - DTOs, validators, or frontend input boundaries constrain user input
+ - identifiers, enums, URLs, uploaded data, and rich content are handled safely
+4. External interactions:
+ - secrets come from configuration, not code
+ - outbound RSS, AI, sync, and MCP-related flows are bounded and validated
+ - user-provided URLs or remote targets are handled safely
+5. Data exposure:
+ - only intended fields are returned or rendered
+ - stack traces, tokens, prompts, and internal details are not leaked
+6. Privacy:
+ - personal data is minimized before external AI calls
+ - logs, prompts, and fixtures do not expose sensitive user data
+7. Tests:
+ - negative tests exist for forbidden or invalid paths when relevant
+
+Prioritize findings:
+- auth or authz bypass
+- sensitive data leaks
+- unsafe input or external call handling
+- privacy regressions in AI or MCP flows
+- missing negative tests on protected behavior
+
+Response format:
+- findings first, ordered by severity
+- then open questions or assumptions
+- then a short hardening summary
+
+Tie every finding to concrete code paths and missing or incorrect enforcement.
diff --git a/.claude/commands/symfony/verify-quality.md b/.claude/commands/symfony/verify-quality.md
new file mode 100644
index 0000000..bab6694
--- /dev/null
+++ b/.claude/commands/symfony/verify-quality.md
@@ -0,0 +1,39 @@
+Verify a Signalist change using the repository's real quality gates.
+
+Verification scope: `$ARGUMENTS`
+
+Workflow:
+1. Read `Makefile`, `frontend/package.json`, and `AGENTS.md`.
+2. Identify the canonical commands exposed by the repo.
+3. Prefer the project wrappers over raw vendor commands.
+4. Run the narrowest relevant tests first, then the broader required checks.
+5. Report failures with the command, impacted files, and minimal fix direction.
+
+Typical order:
+1. `make quality`
+2. `make tests-unit`
+3. `make tests-integration`
+4. `make tests`
+5. `make tests-api`
+6. `make front-lint`
+7. `make front-test`
+8. `npm run typecheck` from `frontend/` when frontend TypeScript changed
+9. `make grumphp` when the broader pre-commit gate is requested
+
+For targeted runs only (skip if running `make quality`):
+- `make lint`
+- `make analyse`
+- `make rector`
+
+Output format:
+- `Commands run`
+- `Pass/fail summary`
+- `Remaining gaps`
+
+Rules:
+- If a command is unavailable, say so instead of inventing a substitute.
+- If Docker is required, use the project commands or wrappers already defined.
+- If a change affects endpoint behavior, do not stop at unit tests only.
+- If a change affects frontend behavior, do not stop at backend checks only.
+- When fixing PHPStan issues, prefer correcting the code, types, or annotations instead of changing `phpstan.neon`.
+- Treat edits to `phpstan.neon` as exceptional and ask first unless the user explicitly requested a PHPStan configuration change.
diff --git a/.claude/hooks/guardrails.py b/.claude/hooks/guardrails.py
new file mode 100644
index 0000000..3be9a8c
--- /dev/null
+++ b/.claude/hooks/guardrails.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+PROTECTED_BRANCHES = {"main", "master", "develop"}
+INSTRUCTION_FILES = {"AGENTS.md", "CLAUDE.md"}
+INSTRUCTION_PREFIX = ".claude/"
+BACKEND_PREFIXES = ("src/", "config/", "tests/")
+FRONTEND_PREFIXES = ("frontend/",)
+ASYNCHRONOUS_ENTRYPOINT_PREFIXES = (
+ "src/UI/Controller/",
+ "src/Infrastructure/ApiPlatform/State/",
+)
+SENSITIVE_SURFACE_PREFIXES = (
+ "src/Domain/Auth/",
+ "src/Infrastructure/Auth/",
+ "src/Infrastructure/AI/",
+ "src/Infrastructure/MCP/",
+ "config/packages/security",
+ "config/routes",
+)
+ENV_FILE_PATTERN = re.compile(r"(^|/)\.env(\..+)?$")
+
+
+def load_tool_input() -> dict:
+ raw = os.environ.get("CLAUDE_TOOL_INPUT", "{}")
+ try:
+ return json.loads(raw) if raw else {}
+ except json.JSONDecodeError:
+ return {}
+
+
+def repo_path(path: str) -> str:
+ try:
+ resolved = Path(path).resolve()
+ return resolved.relative_to(REPO_ROOT).as_posix()
+ except Exception:
+ return path.replace("\\", "/")
+
+
+def collect_paths(value: object) -> list[str]:
+ paths: list[str] = []
+
+ if isinstance(value, str):
+ normalized = value.replace("\\", "/")
+ if "/" in normalized or normalized.startswith("."):
+ paths.append(repo_path(normalized))
+ return paths
+
+ if isinstance(value, list):
+ for item in value:
+ paths.extend(collect_paths(item))
+ return paths
+
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if key in {"path", "file_path", "target_file", "paths", "files"}:
+ paths.extend(collect_paths(item))
+ return paths
+
+ return paths
+
+
+def current_branch() -> str | None:
+ try:
+ return (
+ subprocess.check_output(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=REPO_ROOT,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ .strip()
+ )
+ except Exception:
+ return None
+
+
+def git_changed_files(*args: str) -> list[str]:
+ try:
+ output = subprocess.check_output(
+ ["git", *args],
+ cwd=REPO_ROOT,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ return [line.strip() for line in output.splitlines() if line.strip()]
+ except Exception:
+ return []
+
+
+def all_changed_files() -> list[str]:
+ files = set(git_changed_files("diff", "--name-only"))
+ files.update(git_changed_files("diff", "--cached", "--name-only"))
+ return sorted(files)
+
+
+def is_instruction_file(path: str) -> bool:
+ return path in INSTRUCTION_FILES or path.startswith(INSTRUCTION_PREFIX)
+
+
+def is_env_file(path: str) -> bool:
+ return bool(ENV_FILE_PATTERN.search(path))
+
+
+def touches_backend(path: str) -> bool:
+ return path.startswith(BACKEND_PREFIXES)
+
+
+def touches_frontend(path: str) -> bool:
+ return path.startswith(FRONTEND_PREFIXES)
+
+
+def touches_async_entrypoint(path: str) -> bool:
+ return path.startswith(ASYNCHRONOUS_ENTRYPOINT_PREFIXES)
+
+
+def touches_sensitive_surface(path: str) -> bool:
+ return path.startswith(SENSITIVE_SURFACE_PREFIXES)
+
+
+def emit_unique(warnings: list[str]) -> int:
+ seen: set[str] = set()
+ for warning in warnings:
+ if warning in seen:
+ continue
+ seen.add(warning)
+ print(f"[Signalist guardrail] {warning}", file=sys.stderr)
+ return 0
+
+
+def handle_bash(data: dict) -> int:
+ command = str(data.get("command", "")).strip()
+ warnings: list[str] = []
+
+ if re.search(r"\bgit\s+(commit|push)\b", command):
+ branch = current_branch()
+ if branch in PROTECTED_BRANCHES:
+ warnings.append(
+ f"Current branch is `{branch}`. Shared branches are protected by project policy; prefer a dedicated feature, fix, or hotfix branch before commit/push."
+ )
+
+ if re.search(r"\bgit\s+add\b", command):
+ staged_files = git_changed_files("diff", "--cached", "--name-only")
+
+ if any(is_instruction_file(path) for path in staged_files):
+ warnings.append(
+ "Instruction files are staged. Changes to `AGENTS.md`, `CLAUDE.md`, or `.claude/*` require explicit confirmation; keep `CLAUDE.md` as a thin pointer."
+ )
+
+ if "phpstan.neon" in staged_files:
+ warnings.append(
+ "`phpstan.neon` is staged. PHPStan config changes are exceptional; prefer fixing code, types, or PHPDoc first and justify any config relaxation before commit preparation."
+ )
+
+ if any(is_env_file(path) for path in staged_files):
+ warnings.append(
+ "Sensitive env files are staged. Do not expose, stage casually, or commit `.env*` files unless there is an explicit and justified need."
+ )
+
+ if any(touches_backend(path) for path in staged_files) and any(
+ touches_frontend(path) for path in staged_files
+ ):
+ warnings.append(
+ "Both backend and frontend files are staged. Verify both surfaces: `make quality`, `make tests`, `make tests-api`, `make front-lint`, `make front-test`, and `npm run typecheck` in `frontend/`."
+ )
+
+ if any(touches_async_entrypoint(path) for path in staged_files):
+ warnings.append(
+ "Synchronous entrypoint files are staged. If this change adds outbound HTTP, RSS, or AI work in the request cycle, prefer Messenger/message-handler patterns for slow side effects."
+ )
+
+ if any(touches_sensitive_surface(path) for path in staged_files):
+ warnings.append(
+ "Auth, AI, MCP, or other sensitive surfaces are staged. Run a security/privacy review and ensure negative-path coverage before commit preparation."
+ )
+
+ return emit_unique(warnings)
+
+
+def handle_file_tool(data: dict, mode: str) -> int:
+ warnings: list[str] = []
+ paths = collect_paths(data)
+ changed_files = all_changed_files()
+
+ if any(is_instruction_file(path) for path in paths):
+ warnings.append(
+ "Instruction files are being changed. This repository requires explicit confirmation for instruction updates; keep `CLAUDE.md` as a thin pointer and avoid duplicated guidance."
+ )
+
+ if any(path == "phpstan.neon" for path in paths):
+ warnings.append(
+ "`phpstan.neon` is being changed. Prefer fixing PHPStan issues in code, types, or PHPDoc before touching static-analysis configuration."
+ )
+
+ if any(is_env_file(path) for path in paths):
+ warnings.append(
+ "Sensitive `.env*` files are in scope. Do not expose them in prompts, logs, comments, or commits."
+ )
+
+ if any(touches_async_entrypoint(path) for path in paths):
+ warnings.append(
+ "This change touches synchronous entrypoints. Keep slow outbound RSS, AI, or HTTP side effects behind explicit async boundaries when applicable."
+ )
+
+ if any(touches_sensitive_surface(path) for path in paths):
+ warnings.append(
+ "This change touches auth, AI, MCP, or other sensitive surfaces. Security/privacy review and negative-path testing are expected."
+ )
+
+ if mode in {"edit", "write"} and any(touches_backend(path) for path in changed_files) and any(
+ touches_frontend(path) for path in changed_files
+ ):
+ warnings.append(
+ "Current worktree spans backend and frontend changes. Remember to verify both stacks before preparing a commit."
+ )
+
+ return emit_unique(warnings)
+
+
+def main() -> int:
+ mode = sys.argv[1] if len(sys.argv) > 1 else ""
+ data = load_tool_input()
+
+ if mode == "bash":
+ return handle_bash(data)
+ if mode in {"edit", "write", "read"}:
+ return handle_file_tool(data, mode)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.claude/patterns.md b/.claude/patterns.md
new file mode 100644
index 0000000..d0242bd
--- /dev/null
+++ b/.claude/patterns.md
@@ -0,0 +1,176 @@
+# Signalist Patterns
+
+Use these patterns as generic guidance. Always prefer nearby repository examples when they exist.
+
+Design intent:
+
+- apply SOLID principles without multiplying abstraction layers
+- keep clean architecture and hexagonal boundaries readable
+- use native API Platform features directly when they already solve the need cleanly
+- keep async side effects explicit for RSS, AI, email, and sync operations
+- choose readability over premature optimization
+- write code that is easy for a human reviewer to understand
+
+## API Platform State Processor
+
+Use this pattern when API Platform receives the request but business work belongs in a handler.
+
+```php
+handler)($data);
+ }
+}
+```
+
+## Handler / Use Case
+
+```php
+repository->create($command->url, $command->categoryId);
+
+ $this->messageBus->dispatch(new CrawlFeedMessage($feed->getId()));
+ }
+}
+```
+
+## Input DTO Validation
+
+```php
+crawler->crawl($message->feedId);
+ }
+}
+```
+
+## React Query Hook
+
+```ts
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from '../lib/apiClient';
+
+export function useArticles(categoryId?: string) {
+ return useQuery({
+ queryKey: ['articles', categoryId],
+ queryFn: async () => apiClient.getArticles({ categoryId }),
+ enabled: undefined !== categoryId,
+ });
+}
+```
+
+## Frontend Component Test
+
+```tsx
+import { render, screen } from '@testing-library/react';
+import { ArticleCard } from './ArticleCard';
+
+describe('ArticleCard', () => {
+ it('renders the article title', () => {
+ render();
+
+ expect(screen.getByText('Signalist')).toBeInTheDocument();
+ });
+});
+```
diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md
new file mode 100644
index 0000000..4f5c7f3
--- /dev/null
+++ b/.claude/rules/architecture.md
@@ -0,0 +1,17 @@
+# Architecture Rules
+
+- Read `AGENTS.md` first. It is the canonical source of repository-specific rules.
+- Apply SOLID pragmatically.
+- Prefer clean architecture, hexagonal boundaries, and CQRS when the surrounding area already uses them.
+- If API Platform already supports the required backend behavior cleanly, prefer the built-in API Platform feature over extra layers.
+- If the existing frontend stack already supports the behavior cleanly through React Query, React Router, or a local hook pattern, prefer that over a new abstraction.
+- Keep API Platform resources, state processors, controllers, and components thin. They should orchestrate, not decide.
+- Keep business rules in handlers, domain services, or message handlers.
+- Keep repositories and infrastructure adapters focused on persistence, transports, RSS, AI providers, or third-party integrations.
+- Keep async boundaries explicit for RSS crawling, AI generation, newsletter scheduling, and other slow side effects.
+- Do not introduce blocking outbound work in the request cycle when the repo already models it asynchronously.
+- Mirror the local project shape instead of forcing a generic Symfony-only layout onto backend and frontend code.
+- Prefer incremental changes that reuse existing conventions over broad restructuring.
+- Optimize first for readability, reviewability, and testability.
+- If there is no measured performance issue, prefer the simpler and more readable solution.
+- The final code should be easy for a human reviewer to understand quickly.
diff --git a/.claude/rules/security.md b/.claude/rules/security.md
new file mode 100644
index 0000000..7e5ecd3
--- /dev/null
+++ b/.claude/rules/security.md
@@ -0,0 +1,21 @@
+# Security Rules
+
+- Never hardcode secrets, tokens, or credentials.
+- Enforce authentication and authorization on the server side when access is scoped.
+- Validate user-controlled input before database writes, queue dispatches, or outbound calls.
+- Do not send raw personal data to external AI providers.
+- Keep outbound requests bounded and safe when user input influences feeds, URLs, or remote targets.
+- Minimize response data to the fields actually intended for clients.
+- Sanitize or safely render user-controlled HTML or rich content in the frontend.
+- Preserve the repository's existing error-handling strategy instead of leaking internals.
+- Add negative-path coverage for forbidden, invalid, or unsafe requests when relevant.
+- Do not weaken static-analysis protections to make a warning disappear.
+- Prefer fixing PHPStan findings in code, types, or PHPDoc rather than adding ignores or broadening `phpstan.neon`.
+
+## AI and MCP Policy
+
+- Agent runtime MCP access is blocked project-wide (`allowedMcpServers: []` in `settings.json`). Any exception requires explicit team approval.
+- The project may expose its own `/mcp/` endpoints; treat them as application surface that needs explicit authz, least privilege, and careful output shaping.
+- Do not expose secrets, internal-only actions, or private user data through MCP tools or AI adapters.
+- Do not log private prompts, access tokens, or user data in plain text.
+- When in doubt, ask before changing flows that touch authentication, external AI providers, MCP routes, or personal-data handling.
diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md
new file mode 100644
index 0000000..36a8f01
--- /dev/null
+++ b/.claude/rules/testing.md
@@ -0,0 +1,14 @@
+# Testing Rules
+
+- Tests are part of delivery, not follow-up work.
+- Bug fixes should ship with a regression test whenever the behavior can be reproduced in an automated way.
+- Choose test scope based on the changed behavior:
+ - backend unit tests for handlers, domain services, message handlers, and validators
+ - backend integration tests for persistence behavior, Doctrine wiring, and runtime adapters
+ - Behat tests for endpoint behavior and API-level contracts
+ - frontend Vitest/RTL tests for component behavior, hooks, and interaction flows
+- When endpoint behavior changes, cover both the happy path and at least one failure path when practical.
+- When frontend behavior changes, cover the user-visible interaction or state transition instead of only implementation details.
+- Keep test naming consistent with the repository convention.
+- Run the narrowest relevant tests first, then the broader repository checks.
+- If a behavior changes and no test is added or updated, explain why explicitly.
diff --git a/.claude/settings.json b/.claude/settings.json
index 1b6bd2b..1306cd5 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,4 +1,6 @@
{
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
+ "allowedMcpServers": [],
"hooks": {
"PostToolUse": [
{
@@ -6,10 +8,75 @@
"hooks": [
{
"type": "command",
- "command": "bash -c 'CMD=$(echo \"$CLAUDE_TOOL_INPUT\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get(\\\"command\\\",\\\"\\\"))\" 2>/dev/null || true); if echo \"$CMD\" | grep -qE \"(git commit|git push)\"; then echo \"Reminder: ensure make quality passed before this commit\" >&2; fi'"
+ "command": "python3 \"$(git rev-parse --show-toplevel)/.claude/hooks/guardrails.py\" bash"
+ }
+ ]
+ },
+ {
+ "matcher": "Edit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 \"$(git rev-parse --show-toplevel)/.claude/hooks/guardrails.py\" edit"
+ }
+ ]
+ },
+ {
+ "matcher": "Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 \"$(git rev-parse --show-toplevel)/.claude/hooks/guardrails.py\" write"
+ }
+ ]
+ },
+ {
+ "matcher": "Read",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 \"$(git rev-parse --show-toplevel)/.claude/hooks/guardrails.py\" read"
}
]
}
]
+ },
+ "permissions": {
+ "allow": [
+ "Bash(docker compose:*)",
+ "Bash(make lint:*)",
+ "Bash(make analyse:*)",
+ "Bash(make rector:*)",
+ "Bash(make quality:*)",
+ "Bash(make tests:*)",
+ "Bash(make tests-unit:*)",
+ "Bash(make tests-integration:*)",
+ "Bash(make tests-api:*)",
+ "Bash(make front-lint:*)",
+ "Bash(make front-test:*)",
+ "Bash(make front-build:*)",
+ "Bash(make grumphp:*)",
+ "Bash(npm run typecheck:*)",
+ "Bash(git status:*)",
+ "Bash(git diff:*)",
+ "Bash(git log:*)",
+ "Bash(git show:*)",
+ "Bash(git branch:*)",
+ "Bash(git add:*)",
+ "Bash(git checkout:*)",
+ "Bash(git fetch:*)",
+ "Read",
+ "Write",
+ "Edit"
+ ],
+ "deny": [
+ "Bash(rm -rf:*)",
+ "Bash(rm -r:*)",
+ "Bash(rm -f:*)",
+ "Bash(curl:*)",
+ "Bash(wget:*)",
+ "Read(./.env)",
+ "Read(./.env.*)"
+ ]
}
}
diff --git a/.claude/skills/bug-fix/SKILL.md b/.claude/skills/bug-fix/SKILL.md
new file mode 100644
index 0000000..980e625
--- /dev/null
+++ b/.claude/skills/bug-fix/SKILL.md
@@ -0,0 +1,32 @@
+---
+name: bug-fix
+description: Use this skill when the user reports broken behavior, errors, regressions, failing tests, or asks to fix a bug in Signalist.
+---
+
+# Bug Fix
+
+Use this skill for a small, repeatable regression-first bug-fix workflow.
+
+Read first:
+- `AGENTS.md`
+- `.claude/rules/architecture.md`
+- `.claude/rules/testing.md`
+- `.claude/rules/security.md`
+
+Read as needed:
+- `.claude/patterns.md`
+- nearby files in the failing path
+
+Workflow:
+1. Reproduce the issue from the report, logs, failing test, or current behavior.
+2. Isolate the narrowest code path that explains the failure.
+3. Identify the root cause before editing code.
+4. Implement the smallest clean fix.
+5. Add or update a regression test when the bug can be reproduced automatically.
+6. Run the relevant quality gates before reporting completion.
+
+Rules:
+- Reuse local patterns instead of introducing a new structure.
+- Prefer the smallest understandable fix over broad refactoring.
+- If API Platform or the existing frontend stack already provides the correct behavior directly, use it.
+- Choose readability over performance unless there is a measured bottleneck.
diff --git a/.claude/skills/hotfix/SKILL.md b/.claude/skills/hotfix/SKILL.md
new file mode 100644
index 0000000..133f5b3
--- /dev/null
+++ b/.claude/skills/hotfix/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: hotfix
+description: Use this skill when the user describes a critical production issue that needs an immediate, minimal-risk fix in Signalist.
+---
+
+# Hotfix
+
+Use this skill for urgent production-facing fixes where the blast radius must stay minimal.
+
+Read first:
+- `AGENTS.md`
+- `.claude/rules/architecture.md`
+- `.claude/rules/testing.md`
+- `.claude/rules/security.md`
+
+Read as needed:
+- relevant logs
+- the failing path
+
+Workflow:
+1. Confirm the issue is urgent and production-facing.
+2. Localize the failing path quickly.
+3. Choose the smallest safe fix.
+4. Prefer a dedicated `hotfix/...` branch when working from a shared branch.
+5. Implement only the containment or corrective change needed for the incident.
+6. Add the smallest meaningful regression coverage.
+7. Run the narrowest relevant verification, then broader checks if the touched area requires them.
+8. Prepare commit and PR notes.
+9. Ask for confirmation before any commit or push.
+
+Rules:
+- Do not refactor during a hotfix.
+- If the durable fix is larger, propose a follow-up task after the incident is contained.
+- Be explicit about any skipped verification.
diff --git a/.claude/skills/improve-instructions/SKILL.md b/.claude/skills/improve-instructions/SKILL.md
new file mode 100644
index 0000000..606970d
--- /dev/null
+++ b/.claude/skills/improve-instructions/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: improve-instructions
+description: Use this skill when the user asks to improve agent instructions, update Claude or Codex guidance, review drift in AGENTS.md or .claude files, or keep the repo guidance aligned with how the project actually evolves.
+---
+
+# Improve Instructions
+
+Use this skill to propose improvements to repository instruction files in a continuous-improvement workflow.
+
+Read first:
+- `AGENTS.md`
+- `CLAUDE.md`
+- `.claude/rules/architecture.md`
+- `.claude/rules/testing.md`
+- `.claude/rules/security.md`
+
+Read as needed:
+- `.claude/patterns.md`
+- `.claude/commands/symfony/*.md`
+- `.claude/skills/*/SKILL.md`
+- `Makefile`
+- `composer.json`
+- `frontend/package.json`
+- `README.md`
+
+Workflow:
+1. Inspect the repository and current guidance.
+2. Identify durable drift between the codebase, workflows, and instruction files.
+3. Keep only reusable improvements, not one-off comments or temporary context.
+4. Propose exact updates for `AGENTS.md`, `.claude/rules`, `.claude/patterns.md`, commands, or skills.
+5. Present the proposed patch and rationale.
+6. Ask for confirmation before applying any edit.
+
+Rules:
+- Never edit instruction files silently.
+- Treat `AGENTS.md` as the canonical project-specific source of truth.
+- Keep `CLAUDE.md` as a pointer only.
+- Avoid adding project noise or temporary guidance to shared instruction files.
+- Keep commands and skills aligned when a workflow exists in both forms.
diff --git a/.claude/skills/karpathy-guidelines/SKILL.md b/.claude/skills/karpathy-guidelines/SKILL.md
new file mode 100644
index 0000000..feb50e6
--- /dev/null
+++ b/.claude/skills/karpathy-guidelines/SKILL.md
@@ -0,0 +1,53 @@
+---
+name: karpathy-guidelines
+description: Use for bug fixes, refactors, reviews, or ambiguity-heavy implementation tasks to reinforce explicit assumptions, minimal diffs, simplicity, and direct verification.
+license: MIT
+---
+
+# Karpathy Guidelines
+
+Use this skill when the task risks overcomplication, silent assumptions, or broad changes that are not tightly tied to the request.
+
+This skill reinforces the repository rules in `AGENTS.md`; it does not replace them.
+
+## Apply This Skill When
+
+- the request is ambiguous and multiple interpretations are plausible
+- the change could easily grow into unnecessary abstraction or speculative flexibility
+- the task is a bug fix, refactor, or code review where disciplined scope control matters
+- you need to keep the diff tightly coupled to the requested outcome
+
+## What To Emphasize
+
+### 1. Think Before Coding
+
+- state assumptions that materially affect the implementation
+- surface ambiguity instead of choosing silently
+- if a simpler approach exists, say so
+- ask when uncertainty is risky enough to produce the wrong change
+
+### 2. Simplicity First
+
+- implement the minimum code that solves the requested problem
+- avoid speculative abstractions, configurability, and future-proofing that were not requested
+- prefer the version a senior engineer would describe as clear and unsurprising
+
+### 3. Surgical Changes
+
+- touch only what the request and its verification require
+- do not refactor adjacent code unless it is necessary for correctness
+- clean up only the imports, variables, dead code, or formatting made obsolete by your own change
+
+### 4. Goal-Driven Execution
+
+- define success in verifiable terms before implementing non-trivial changes
+- bug fix: reproduce first when practical, then verify the fix
+- behavior change: add or update tests that prove the requested outcome
+- refactor: verify behavior before and after with the relevant test suite
+
+## Expected Outcome
+
+- fewer unnecessary lines in diffs
+- clearer assumptions and tradeoffs before implementation
+- simpler code with less speculative structure
+- verification that directly proves the request was satisfied
diff --git a/.claude/skills/new-feature/SKILL.md b/.claude/skills/new-feature/SKILL.md
new file mode 100644
index 0000000..4235900
--- /dev/null
+++ b/.claude/skills/new-feature/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: new-feature
+description: Use this skill when the user asks to add, create, implement, or build new functionality in Signalist.
+---
+
+# New Feature
+
+Use this skill to implement new functionality while staying aligned with the local project architecture.
+
+Read first:
+- `AGENTS.md`
+- `.claude/rules/architecture.md`
+- `.claude/rules/testing.md`
+- `.claude/rules/security.md`
+
+Read as needed:
+- `.claude/patterns.md`
+- nearby files in the same backend or frontend area
+
+Workflow:
+1. If context is incomplete, inspect the repo shape and find a nearby example first.
+2. Mirror the local naming, placement, and flow instead of generating framework-default code.
+3. Follow SOLID pragmatically and keep clean or hexagonal boundaries readable.
+4. If API Platform already supports the requested backend behavior cleanly, use it directly instead of adding extra layers.
+5. If the existing frontend stack already supports the requested UI behavior cleanly, reuse it directly instead of adding a new abstraction.
+6. Keep the implementation simple and easy for a human reviewer to follow.
+7. Add the right tests in the same session.
+8. Run the repository quality gates before reporting completion.
+
+Rules:
+- Keep entrypoints thin.
+- Keep business rules in handlers, use-cases, domain services, or message handlers.
+- Keep repositories and infrastructure adapters focused on their boundary concerns.
+- Prefer readability over premature optimization.
diff --git a/.claude/skills/prepare-commit/SKILL.md b/.claude/skills/prepare-commit/SKILL.md
new file mode 100644
index 0000000..c176402
--- /dev/null
+++ b/.claude/skills/prepare-commit/SKILL.md
@@ -0,0 +1,43 @@
+---
+name: prepare-commit
+description: Use this skill when the user asks to prepare a commit, write a commit message, stage files, create a branch, or prepare PR notes for Signalist.
+---
+
+# Prepare Commit
+
+Use this skill to prepare a commit and PR context without silently performing protected git actions.
+
+Read first:
+- `AGENTS.md`
+- `Makefile`
+- `frontend/package.json`
+
+Read as needed:
+- `git status`
+- `git diff`
+- `git log`
+
+Workflow:
+1. Inspect the current branch and working tree.
+2. Stage the intended files with `git add`.
+3. Run the repository quality checks relevant to the current change.
+4. Review the change for correctness, architecture, validation, regressions, and test coverage.
+5. Review the change with a security lens.
+6. If the current branch is `main`, `master`, `develop`, or another protected/shared branch, create a dedicated working branch first.
+7. Build a Conventional Commit title and optional body from the actual change.
+8. Prepare PR-ready notes:
+ - what
+ - why
+ - how
+ - tests or verification
+ - risks or follow-up
+9. Prepare a checkbox-style verification list using the repo's actual quality gates.
+10. If the user explicitly asks to commit, ask for confirmation before running `git commit`.
+11. If the user explicitly asks to push, ask for confirmation before running `git push`.
+
+Rules:
+- Never commit or push without explicit confirmation in the current conversation.
+- Do not assume all changed files belong to the intended commit.
+- Keep the commit message human-readable and reviewer-friendly.
+- Prefer the repository's real verification commands over generic checklist items.
+- If pre-commit checks find blockers, report them before proposing the final commit.
diff --git a/.claude/skills/review-change/SKILL.md b/.claude/skills/review-change/SKILL.md
new file mode 100644
index 0000000..b590c72
--- /dev/null
+++ b/.claude/skills/review-change/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: review-change
+description: Use this skill when the user asks for a code review, a correctness pass, a check before PR, or asks whether a Signalist change follows project conventions.
+---
+
+# Review Change
+
+Use this skill to review a change for correctness, architecture, and test completeness.
+
+Read first:
+- `AGENTS.md`
+- `.claude/rules/architecture.md`
+- `.claude/rules/testing.md`
+- `.claude/rules/security.md`
+
+Read as needed:
+- `.claude/patterns.md`
+- the relevant diff and nearby files
+
+Workflow:
+1. Inspect the diff or the requested scope.
+2. Check architecture and layer ownership across backend and frontend.
+3. Check validation, persistence boundaries, async boundaries, and error handling consistency.
+4. Check test coverage for the changed behavior.
+5. Check whether the change is likely to pass the repository quality gates.
+
+Response format:
+- findings first, ordered by severity
+- then assumptions or open questions
+- then a short summary only if useful
+
+Rules:
+- Focus on correctness, security, regressions, and missing tests.
+- Do not spend time on style nits already covered by tooling unless they expose real risk.
diff --git a/.claude/skills/scan-project/SKILL.md b/.claude/skills/scan-project/SKILL.md
new file mode 100644
index 0000000..d8a4c60
--- /dev/null
+++ b/.claude/skills/scan-project/SKILL.md
@@ -0,0 +1,31 @@
+---
+name: scan-project
+description: Use this skill when the user asks to explore, inspect, understand, map, or analyze the Signalist repository before making changes.
+---
+
+# Scan Project
+
+Use this skill to build an implementation-ready map of the repository before coding.
+
+Read first:
+- `AGENTS.md`
+- `.claude/rules/architecture.md`
+- `.claude/rules/testing.md`
+
+Read as needed:
+- `README.md`
+- `composer.json`
+- `Makefile`
+- `frontend/package.json`
+- `.claude/patterns.md`
+
+Workflow:
+1. Inspect the project shape: `src/`, `frontend/`, `config/`, `tests/`.
+2. Identify the active conventions: Symfony/API Platform usage, React/Vite usage, Messenger flows, quality gates, and testing stack.
+3. Find the nearest local example for the area the user wants to change.
+4. Summarize architecture, likely files to touch, and main risks.
+
+Rules:
+- Prefer local repository patterns over framework defaults.
+- Do not invent new layers or folders during the exploration phase.
+- Surface any `AGENTS.md` rule that constrains implementation decisions.
diff --git a/.claude/skills/security-review/SKILL.md b/.claude/skills/security-review/SKILL.md
new file mode 100644
index 0000000..659401a
--- /dev/null
+++ b/.claude/skills/security-review/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: security-review
+description: Use this skill when the user asks for a security review, mentions auth/authz concerns, input validation, secret handling, GDPR, AI-provider safety, MCP routes, or wants a stricter review of a Signalist change.
+---
+
+# Security Review
+
+Use this skill to review a change with a security-first lens.
+
+Read first:
+- `AGENTS.md`
+- `.claude/rules/security.md`
+- `.claude/rules/architecture.md`
+- `.claude/rules/testing.md`
+
+Read as needed:
+- `.claude/patterns.md`
+- the relevant diff and nearby files
+
+Workflow:
+1. Inspect authentication and authorization boundaries.
+2. Check input validation before side effects.
+3. Check secrets, outbound calls, RSS/AI flows, and user-controlled remote targets.
+4. Check output exposure, privacy, and error leakage.
+5. Check negative-path tests for forbidden or invalid behavior.
+
+Response format:
+- findings first, ordered by severity
+- then assumptions or open questions
+- then a short hardening summary
+
+Rules:
+- Tie each finding to a concrete code path.
+- Prefer actionable security findings over generic warnings.
diff --git a/.claude/skills/verify-quality/SKILL.md b/.claude/skills/verify-quality/SKILL.md
new file mode 100644
index 0000000..cf2a739
--- /dev/null
+++ b/.claude/skills/verify-quality/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: verify-quality
+description: Use this skill when the user asks to run checks, validate a change, see whether code is ready, or investigate lint, analysis, test, or security-gate results in Signalist.
+---
+
+# Verify Quality
+
+Use this skill to run the repository's real quality gates in a predictable order.
+
+Read first:
+- `AGENTS.md`
+- `.claude/rules/testing.md`
+
+Read as needed:
+- `Makefile`
+- `composer.json`
+- `frontend/package.json`
+
+Workflow:
+1. Discover the canonical commands exposed by the repository.
+2. Prefer project wrappers such as `make` targets over raw vendor binaries.
+3. Run the narrowest relevant tests first, then broader required checks.
+4. Report failures with the command, affected area, and smallest likely fix direction.
+
+Typical order:
+1. `make quality`
+2. `make tests-unit`
+3. `make tests-integration`
+4. `make tests`
+5. `make tests-api`
+6. `make front-lint`
+7. `make front-test`
+8. `npm run typecheck` from `frontend/` when frontend TypeScript changed
+9. `make grumphp` when the broader pre-commit gate is requested
+
+Rules:
+- Do not invent substitute commands silently.
+- If endpoint behavior changed, do not stop at unit tests only.
+- If frontend behavior changed, do not stop at backend checks only.
diff --git a/.claude/workflows/bug-fix.md b/.claude/workflows/bug-fix.md
deleted file mode 100644
index e13ed9e..0000000
--- a/.claude/workflows/bug-fix.md
+++ /dev/null
@@ -1,371 +0,0 @@
-# Workflow: Bug Fix
-
-Use this workflow when fixing bugs in Signalist.
-
----
-
-## Overview
-
-```
-1. REPRODUCE → Confirm the bug exists
-2. DIAGNOSE → Find root cause
-3. PLAN → Determine fix approach
-4. FIX → Implement solution
-5. TEST → Add regression test
-6. REVIEW → Code review
-7. ACCEPT → Verify bug is truly fixed
-8. MERGE → Complete
-```
-
----
-
-## Step 1: Reproduce the Bug
-
-### Gather Information
-- [ ] What is the expected behavior?
-- [ ] What is the actual behavior?
-- [ ] Steps to reproduce
-- [ ] Environment (browser, PHP version, etc.)
-- [ ] Error messages or logs
-
-### Reproduction Template
-```markdown
-## Bug Report
-
-### Summary
-[One line description]
-
-### Expected
-[What should happen]
-
-### Actual
-[What actually happens]
-
-### Steps to Reproduce
-1. [Step 1]
-2. [Step 2]
-3. [Step 3]
-
-### Environment
-- PHP: 8.5
-- Browser: Chrome 120
-- OS: macOS 14
-
-### Logs/Errors
-```
-[Error message or stack trace]
-```
-```
-
-### Confirm Reproduction
-- [ ] Bug reproduced locally
-- [ ] Bug is not a configuration issue
-- [ ] Bug is not user error
-
----
-
-## Step 2: Diagnose Root Cause
-
-### Investigation Steps
-
-1. **Check Logs**
- ```bash
- docker compose logs app | grep -i error
- tail -f var/log/dev.log
- ```
-
-2. **Trace the Flow**
- - Start from the failing endpoint/component
- - Follow CQRS: Controller → Handler → Repository
- - Add temporary logging if needed
-
-3. **Check Recent Changes**
- ```bash
- git log --oneline -20
- git diff HEAD~5
- ```
-
-4. **Read Related Tests**
- - Are there existing tests that should catch this?
- - Why didn't they?
-
-### Root Cause Template
-```markdown
-## Root Cause Analysis
-
-### Bug
-[Summary]
-
-### Root Cause
-[Why it happens]
-
-### Location
-- File: `src/Domain/X/Handler/XHandler.php`
-- Line: 45
-- Code: `$this->repo->find()` returns null when...
-
-### Why Not Caught
-- Missing test case for [scenario]
-- Edge case not considered
-```
-
----
-
-## Step 3: Plan the Fix
-
-### Fix Approaches
-
-| Approach | Pros | Cons |
-|----------|------|------|
-| Quick fix | Fast | May not address root cause |
-| Proper fix | Complete | Takes longer |
-| Refactor | Prevents future bugs | Scope creep risk |
-
-### Fix Plan Template
-```markdown
-## Fix Plan
-
-### Approach
-[Quick fix / Proper fix / Refactor]
-
-### Changes
-1. [File 1]: [Change description]
-2. [File 2]: [Change description]
-
-### New Tests
-- [ ] Test for the exact scenario that caused the bug
-- [ ] Test for related edge cases
-
-### Risk Assessment
-- Low/Medium/High risk
-- Reason: [Why this risk level]
-```
-
-**If High Risk:** Get user approval before proceeding.
-
----
-
-## Step 4: Implement the Fix
-
-### Fix Checklist
-
-- [ ] Change is minimal and focused
-- [ ] No unrelated changes included
-- [ ] Code follows existing patterns
-- [ ] Error handling is appropriate
-
-### Common Fix Patterns
-
-**Null Check Missing**
-```php
-// Before (bug)
-$result = $this->repo->find($id);
-return $result->getName();
-
-// After (fix)
-$result = $this->repo->find($id);
-if ($result === null) {
- throw new NotFoundException($id);
-}
-return $result->getName();
-```
-
-**Validation Missing**
-```php
-// Before (bug)
-public function __construct(
- public string $url,
-) {}
-
-// After (fix)
-public function __construct(
- #[Assert\NotBlank]
- #[Assert\Url]
- public string $url,
-) {}
-```
-
-**Race Condition**
-```php
-// Before (bug)
-if (!$this->repo->exists($id)) {
- $this->repo->create($entity); // Another request could create between check and create
-}
-
-// After (fix)
-try {
- $this->repo->create($entity);
-} catch (UniqueConstraintViolationException $e) {
- throw new AlreadyExistsException($id);
-}
-```
-
----
-
-## Step 5: Add Regression Test
-
-### Test Requirements
-
-**Every bug fix MUST include a test that:**
-1. Fails before the fix
-2. Passes after the fix
-3. Prevents regression
-
-### Test Template
-```php
-/**
- * Regression test for bug: [description]
- * @see [Issue link if exists]
- */
-public function testMethodNameBugScenarioCorrectBehavior(): void
-{
- // Arrange: Set up the exact conditions that caused the bug
-
- // Act: Perform the action that triggered the bug
-
- // Assert: Verify correct behavior
-}
-```
-
-### Run Tests
-```bash
-# Run specific test
-./vendor/bin/phpunit tests/Unit/Domain/X/XHandlerTest.php --filter testMethodName
-
-# Run all tests
-make tests
-
-# Verify the test fails without the fix
-git stash
-./vendor/bin/phpunit tests/Unit/Domain/X/XHandlerTest.php --filter testMethodName
-# Should FAIL
-git stash pop
-./vendor/bin/phpunit tests/Unit/Domain/X/XHandlerTest.php --filter testMethodName
-# Should PASS
-```
-
----
-
-## Step 6: Review
-
-### Self-Review Checklist
-
-- [ ] Fix addresses root cause, not just symptoms
-- [ ] No unrelated changes
-- [ ] Regression test added
-- [ ] All existing tests pass
-- [ ] Code follows conventions
-
-Use `/review` for a structured code review of the fix before merging.
-
----
-
-## Step 7: Acceptance Check
-
-**Verify the bug is truly fixed and no regressions introduced.**
-
-### Acceptance Checklist
-
-- [ ] **Original bug fixed** - Reproduce steps no longer trigger the issue
-- [ ] **Root cause addressed** - Not just a symptom fix
-- [ ] **No regressions** - Related functionality still works
-- [ ] **No scope creep** - Only the bug was fixed, no unrelated changes
-
-### User Validation
-
-Present to user:
-```markdown
-## Bug Fix Ready for Acceptance
-
-### Original Bug
-[Description of the bug]
-
-### Root Cause
-[What was causing it]
-
-### Fix Applied
-[What was changed]
-
-### Verification Steps
-1. [Step to verify bug is fixed]
-2. [Step to verify no regression]
-
-### Regression Test
-`tests/Unit/.../Test.php::testMethod_BugScenario_CorrectBehavior`
-
-**Can you confirm the bug is fixed?**
-```
-
-### If Acceptance Fails
-1. Document what's still broken
-2. Return to Step 2 (Diagnose) - root cause may be wrong
-3. Do NOT merge incomplete fixes
-
----
-
-## Step 8: Merge
-
-### Commit Message
-```
-fix(domain): brief description of fix
-
-Root cause: [explanation]
-Regression test added.
-
-Fixes #123 (if applicable)
-```
-
-### Pre-Merge Checklist
-- [ ] Tests passing
-- [ ] Lint passing
-- [ ] Review approved
-- [ ] No merge conflicts
-
----
-
-## Hotfix (Production Bug)
-
-For critical production bugs:
-
-### Expedited Process
-1. Create `hotfix/` branch from `main`
-2. Minimal fix only
-3. Expedited review
-4. Merge to `main`
-5. Deploy immediately
-6. Create follow-up ticket for proper fix if needed
-
-### Hotfix Commit
-```
-fix(domain): [HOTFIX] critical bug description
-
-Emergency fix for production issue.
-Follow-up: #456
-```
-
----
-
-## Post-Mortem (Optional)
-
-For significant bugs, document lessons learned:
-
-```markdown
-## Post-Mortem: [Bug Summary]
-
-### Impact
-- Duration: X hours
-- Users affected: Y
-
-### Timeline
-- [Time]: Bug reported
-- [Time]: Root cause identified
-- [Time]: Fix deployed
-
-### Root Cause
-[Detailed explanation]
-
-### Prevention
-- [ ] Add validation at [location]
-- [ ] Add monitoring for [metric]
-- [ ] Update documentation for [process]
-```
diff --git a/.claude/workflows/new-feature.md b/.claude/workflows/new-feature.md
deleted file mode 100644
index 3cbf167..0000000
--- a/.claude/workflows/new-feature.md
+++ /dev/null
@@ -1,343 +0,0 @@
-# Workflow: New Feature Implementation
-
-Use this workflow when adding new functionality to Signalist.
-
----
-
-## Overview
-
-```
-1. UNDERSTAND → Clarify requirements
-2. EXPLORE → Map affected domains and files
-3. PLAN → Design implementation approach
-4. APPROVE → Get user sign-off
-5. IMPLEMENT → Build incrementally
-6. TEST → Write and run tests
-7. SECURITY → Security checklist
-8. REVIEW → Code review
-9. ACCEPT → Verify against original request
-10. MERGE → Complete
-```
-
----
-
-## Step 1: Understand Requirements
-
-### Questions to Answer
-- [ ] What problem does this feature solve?
-- [ ] Who is the user persona?
-- [ ] What are the acceptance criteria?
-- [ ] What are the edge cases?
-- [ ] Are there any constraints (performance, security)?
-
-### If Unclear
-Use this template to ask the user:
-```markdown
-## Clarification Needed
-
-### Feature: [Name]
-
-### My Understanding
-[What I think the feature should do]
-
-### Questions
-1. [Specific question]
-2. [Specific question]
-
-### Assumptions (please confirm)
-- [Assumption 1]
-- [Assumption 2]
-```
-
----
-
-## Step 2: Explore Context
-
-### Check Existing Code
-- [ ] Related entities in `src/Entity/`
-- [ ] Similar handlers in `src/Domain/`
-- [ ] Existing tests for patterns
-- [ ] API conventions in existing controllers
-
-### Identify Affected Areas
-| Area | Impact |
-|------|--------|
-| Domain(s) | Which domains are touched |
-| Entities | New or modified |
-| API | New endpoints |
-| Frontend | New components |
-| AI | LLM integration needed? |
-| Infra | Schema changes? |
-
----
-
-## Step 3: Plan Implementation
-
-### Create Implementation Plan
-```markdown
-## Implementation Plan: [Feature Name]
-
-### Scope
-[1-2 sentence description]
-
-### Components
-
-#### 1. Backend
-- [ ] `CreateXCommand` - [purpose]
-- [ ] `CreateXHandler` - [purpose]
-- [ ] `CreateXInput` - [validation]
-- [ ] `CreateXController` - [route]
-
-#### 2. Frontend (if applicable)
-- [ ] `XComponent` - [purpose]
-- [ ] `useX` hook - [purpose]
-
-#### 3. Tests
-- [ ] `CreateXHandlerTest`
-- [ ] `CreateXControllerTest`
-
-### Files to Create
-- `src/Domain/X/Command/CreateXCommand.php`
-- `src/Domain/X/Handler/CreateXHandler.php`
-- ...
-
-### Files to Modify
-- `src/Entity/X.php` - Add field Y
-- ...
-
-### Dependencies
-- Requires: [other task/feature]
-- Blocks: [nothing/other task]
-
-### Risks
-- [Potential issue and mitigation]
-```
-
----
-
-## Step 4: Get Approval
-
-Present plan to user with:
-```markdown
-## Ready for Approval
-
-### Feature
-[Name]
-
-### Summary
-[What will be built]
-
-### Changes
-- X new files
-- Y modified files
-- Z new API endpoints
-
-### Questions Before Proceeding
-1. [Any remaining uncertainties]
-
-**Proceed with implementation?**
-```
-
-**Wait for explicit approval before coding.**
-
----
-
-## Step 5: Implement
-
-### Order of Implementation
-
-1. **Entity/Model** (if new)
- - Create entity
- - Create migration
- - Verify with `make migrate-diff`
-
-2. **Port (Interface)**
- - Define repository interface in `src/Domain/{X}/Port/`
-
-3. **Infrastructure**
- - Implement repository in `src/Infrastructure/Persistence/`
-
-4. **Command/Query**
- - Create in `src/Domain/{X}/Command/` or `Query/`
-
-5. **Handler**
- - Create in `src/Domain/{X}/Handler/`
- - This is where business logic goes
-
-6. **DTOs**
- - InputDTO with validation
- - OutputDTO for response
-
-7. **Controller**
- - Create in `src/UI/Controller/{X}/`
- - Orchestration only
-
-8. **Frontend** (if applicable)
- - Hand off to @frontend
-
-### Incremental Commits
-```bash
-feat(x): add CreateX command and handler
-feat(x): add CreateX controller and endpoint
-test(x): add CreateXHandler unit tests
-```
-
----
-
-## Step 6: Test
-
-### Required Tests
-
-| Type | Location | Coverage |
-|------|----------|----------|
-| Unit | `tests/Unit/Domain/{X}/Handler/` | Handler logic |
-| Web | `tests/Web/Controller/{X}/` | HTTP flow |
-| Integration | `tests/Integration/` | DB interactions |
-
-### Test Naming
-```
-test{Method}{Scenario}{Expected}
-```
-> camelCase only — no underscores (PHP CS Fixer enforces this)
-
-### Run Tests
-```bash
-make tests-unit # Unit tests
-make quality # lint + analyse + rector
-docker compose exec app vendor/bin/behat --suite=api # API tests
-```
-
----
-
-## Step 7: Security
-
-Run this checklist against every feature before code review. Tick only what is relevant — skip rows that don't apply.
-
-### Input & Output
-- [ ] All user-supplied input validated via InputDTO constraints (`#[Assert\*]`)
-- [ ] No raw user content rendered as HTML without sanitization (DOMPurify / HTMLPurifier)
-- [ ] URL fields validated against SSRF-safe constraint (`#[SsrfSafeUrl]`) if the app fetches the URL
-- [ ] Article/external content stored sanitized; URLs validated as `http/https` only
-
-### Authentication & Authorization
-- [ ] All new endpoints are behind JWT firewall (no `PUBLIC_ACCESS` unless intentional)
-- [ ] State processors/providers use `if (!$user instanceof User) throw new AccessDeniedException()` — never `assert()`
-- [ ] No user can access another user's resources (ownerId scope enforced in queries)
-
-### Sensitive Data & GDPR
-- [ ] No secrets, tokens, or credentials hardcoded — use environment variables
-- [ ] No personal data logged in plain text
-- [ ] New entities containing personal data have `deletedAt` soft-delete column
-- [ ] Data sent to external AI services is anonymized
-
-### API Design
-- [ ] New endpoints return RFC 7807 problem details on error
-- [ ] No internal error messages exposed to API consumers (generic messages only)
-- [ ] Rate limiting considered if endpoint is public or auth-related
-
-### Dependency & Supply Chain
-- [ ] Any new composer package checked: `composer audit`
-- [ ] Any new npm package checked: `npm audit --audit-level=high`
-
-### Quick Commands
-```bash
-# Check PHP dependencies for known vulnerabilities
-docker compose exec app composer audit
-
-# Check JS dependencies
-cd frontend && npm audit --audit-level=high
-```
-
-If any checklist item raises a concern, fix it before proceeding to review.
-
----
-
-## Step 8: Review
-
-Use `/review` to run a self-contained code review against architecture, quality, security, and test criteria.
-
-### Address Feedback
-- Fix issues raised
-- Run `make quality` again
-- Re-run tests
-
----
-
-## Step 9: Acceptance Check
-
-**Before marking complete, verify the implementation matches the original request.**
-
-### Acceptance Checklist
-
-- [ ] **Re-read original request** - What did the user actually ask for?
-- [ ] **All acceptance criteria met** - Every requirement addressed
-- [ ] **No scope creep** - Nothing built that wasn't requested
-- [ ] **No missing pieces** - All aspects of the request covered
-- [ ] **Edge cases handled** - As discussed in planning phase
-
-### Specification Compliance
-
-- [ ] API contract matches what was planned
-- [ ] Error responses follow RFC 7807
-- [ ] Domain context still accurate (update if behavior changed)
-
-### User Validation
-
-Present to user for final approval:
-```markdown
-## Ready for Acceptance
-
-### Original Request
-[Copy the original user request]
-
-### What Was Built
-- [Feature 1]: [Brief description]
-- [Feature 2]: [Brief description]
-
-### Acceptance Criteria Status
-- [x] Criteria 1 - Implemented in `Handler.php`
-- [x] Criteria 2 - Tested in `HandlerTest.php`
-- [ ] Criteria 3 - **Not implemented** (reason: [explain])
-
-### Demo
-[How to test/verify the feature]
-
-### Documentation Updated
-- [ ] `docs/ROADMAP.md` updated to mark tasks as Done
-- [ ] API docs reflect new endpoints (OpenAPI auto-generated)
-
-**Does this match what you requested?**
-```
-
-### If Acceptance Fails
-1. Document what's missing or wrong
-2. Return to Step 5 (Implement) or Step 3 (Plan)
-3. Do NOT merge incomplete features
-
----
-
-## Step 10: Merge
-
-### Pre-Merge Checklist
-- [ ] All tests passing
-- [ ] Lint passing
-- [ ] Static analysis passing
-- [ ] Code review approved
-- [ ] No merge conflicts
-- [ ] Commit messages follow conventions
-
-### Merge
-```bash
-gh pr create # create PR, wait for CI to pass, then merge
-```
-
----
-
-## Rollback Plan
-
-If something goes wrong:
-
-1. Revert merge commit
-2. Fix issue on feature branch
-3. Re-test
-4. Re-merge
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 567da67..9263c14 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -122,6 +122,38 @@ jobs:
with:
extra_args: --only-verified
+ trivy:
+ name: Trivy Security Scan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ - name: Build prod image
+ run: docker build -f docker/frankenphp/Dockerfile -t signalist:ci .
+
+ - name: Scan image for vulnerabilities
+ uses: aquasecurity/trivy-action@v0.35.0
+ with:
+ image-ref: signalist:ci
+ format: table
+ exit-code: 1
+ ignore-unfixed: true
+ severity: CRITICAL,HIGH
+
+ - name: Scan repository for misconfigurations
+ uses: aquasecurity/trivy-action@v0.35.0
+ with:
+ scan-type: fs
+ scan-ref: .
+ format: table
+ exit-code: 1
+ ignore-unfixed: true
+ severity: CRITICAL,HIGH
+
lint:
name: Docker Lint
runs-on: ubuntu-latest
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 12995dc..f9bae60 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -2,7 +2,10 @@
declare(strict_types=1);
-$finder = (new PhpCsFixer\Finder())
+use PhpCsFixer\Config;
+use PhpCsFixer\Finder;
+
+$finder = new Finder()
->in(__DIR__)
->exclude('var')
->exclude('vendor')
@@ -11,7 +14,7 @@
->notPath('config/reference.php')
;
-return (new PhpCsFixer\Config())
+return new Config()
->setRules([
'@PSR12' => true,
'@Symfony' => true,
@@ -25,8 +28,6 @@
'phpdoc_order' => true,
'phpdoc_separation' => true,
'phpdoc_align' => ['align' => 'left'],
- 'concat_space' => ['spacing' => 'one'],
- 'cast_spaces' => ['space' => 'single'],
'binary_operator_spaces' => [
'default' => 'single_space',
'operators' => [
@@ -41,7 +42,6 @@
],
],
'single_line_throw' => false,
- 'yoda_style' => false,
'blank_line_before_statement' => [
'statements' => [
'return',
@@ -66,7 +66,6 @@
],
'final_class' => true,
'self_accessor' => true,
- 'php_unit_method_casing' => ['case' => 'camel_case'],
'php_unit_test_annotation' => ['style' => 'prefix'],
'php_unit_strict' => true,
])
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f001fc4
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,230 @@
+# Agent Guide
+
+Canonical agent instructions for this repository live in this file.
+
+`CLAUDE.md` must stay a thin pointer to `AGENTS.md` so Claude and Codex share one source of truth.
+
+## Project
+
+- Signalist is an AI-powered RSS intelligence platform with a Symfony/API Platform backend and a React frontend
+- Backend stack: PHP 8.5, Symfony 7.4, API Platform 4.x, FrankenPHP, PostgreSQL, pgvector
+- Frontend stack: React 19, TypeScript, Vite 7, MUI 7, react-i18next, React Query
+- Runtime integrations: Symfony Messenger, Redis, Symfony AI providers, outbound RSS and third-party syncs
+- Primary route surfaces: `/api/v1/` for application APIs, `/mcp/` for project MCP endpoints, `frontend/` for the web UI
+
+## Architecture
+
+Use these principles pragmatically:
+
+- keep clean architecture, hexagonal boundaries, and CQRS where the repository already uses them
+- keep API Platform resources, providers, and state processors thin and orchestration-focused
+- keep controllers thin when a route is not handled through API Platform
+- keep business rules in handlers, domain services, and message handlers
+- keep repositories and infrastructure adapters focused on persistence, external APIs, RSS parsing, AI providers, or transport concerns
+- keep frontend components readable and focused on rendering plus interaction wiring
+- keep frontend data-fetching, mutations, and cache orchestration in dedicated hooks or clients instead of burying them in large components
+
+Current backend flow patterns:
+
+- write flow: `API Platform State Processor or Controller -> Handler -> Domain Model/Port -> Infrastructure Adapter`
+- read flow: `API Platform Resource/Doctrine -> normalized response`
+- async flow: `Handler -> Messenger Message -> MessageHandler -> Infrastructure Adapter`
+
+Current frontend flow pattern:
+
+- `Route/Page -> query or mutation hook -> API client -> backend`
+
+## Repository Structure
+
+```text
+src/
+├── Domain/
+│ ├── Article/
+│ ├── Auth/
+│ ├── Bookmark/
+│ ├── Category/
+│ └── Feed/
+├── Entity/
+├── Infrastructure/
+│ ├── ApiPlatform/
+│ ├── Auth/
+│ ├── EventListener/
+│ ├── Persistence/
+│ ├── RSS/
+│ └── Validator/
+└── UI/
+ └── Controller/
+
+frontend/
+├── src/
+├── package.json
+└── vite.config.ts
+
+tests/
+├── Unit/
+├── Integration/
+└── Behat/
+```
+
+## Engineering Rules
+
+Always:
+
+- keep `declare(strict_types=1);` in PHP files
+- prefer explicit naming and predictable local patterns over clever abstractions
+- follow SOLID pragmatically; do not add abstraction layers without a concrete reason
+- if API Platform already provides the requested behavior cleanly, prefer the built-in API Platform feature over extra architectural indirection
+- if React Query, React Router, or the existing frontend structure already provides the requested behavior cleanly, reuse it instead of adding a new frontend abstraction
+- keep RSS crawling, AI inference, newsletter generation, and other potentially slow outbound work asynchronous when the existing Messenger flow applies
+- write tests for behavior changes in the same session
+- run verification after changes
+- prefer readability, reviewability, and testability over premature optimization
+- if performance and readability conflict and there is no measured bottleneck, choose readability
+- prefer fixing PHPStan issues in code, types, or PHPDoc instead of changing `phpstan.neon`
+- keep frontend strings translatable and aligned with the project i18n setup
+- keep the final result easy for a human reviewer to understand quickly
+
+Execution principles:
+
+- think before coding: state assumptions that materially affect the implementation
+- simplicity first: prefer the minimum implementation that fully solves the request
+- surgical changes: touch only what the request and its verification require
+- reuse nearby patterns before inventing a new structure
+- bug fix: reproduce first when practical, then add a regression test
+- feature work: add the right test level instead of only broad smoke coverage
+- refactor: verify behavior before and after with the relevant checks
+
+Ask first:
+
+- adding composer or npm packages
+- changing PostgreSQL schema or pgvector dimensions
+- changing AI provider configuration or prompt contracts
+- changing the project MCP protocol implementation
+- changing `phpstan.neon`
+- changing shared instruction files
+- running `git commit`
+- running `git push`
+
+Never:
+
+- commit directly to `main`, `master`, or `develop`
+- hardcode secrets, tokens, or credentials
+- perform blocking HTTP, RSS, or AI calls in the request cycle when the existing async flow applies
+- send raw personal data to external AI services
+- leak internal errors, tokens, or private data in responses, logs, prompts, or fixtures
+- duplicate project instructions across `AGENTS.md` and `CLAUDE.md`
+- run `git commit` or `git push` silently; always ask for confirmation in the current conversation first
+
+## Shared `.claude` Assets
+
+Claude and Codex must both use the repo-local `.claude/` folder as shared operational guidance.
+
+Use these files as the common behavior layer:
+
+- `.claude/settings.json`
+- `.claude/rules/architecture.md`
+- `.claude/rules/testing.md`
+- `.claude/rules/security.md`
+- `.claude/patterns.md`
+
+Use the matching workflow when the task fits:
+
+- repository scan or inspection: `.claude/skills/scan-project/SKILL.md` or `.claude/commands/symfony/scan-project.md`
+- new functionality: `.claude/skills/new-feature/SKILL.md` or `.claude/commands/symfony/new-feature.md`
+- bug fixing: `.claude/skills/bug-fix/SKILL.md` or `.claude/commands/symfony/bug-fix.md`
+- general review: `.claude/skills/review-change/SKILL.md` or `.claude/commands/symfony/review-change.md`
+- security review: `.claude/skills/security-review/SKILL.md` or `.claude/commands/symfony/security-review.md`
+- verification and checks: `.claude/skills/verify-quality/SKILL.md` or `.claude/commands/symfony/verify-quality.md`
+- commit preparation: `.claude/skills/prepare-commit/SKILL.md` or `.claude/commands/symfony/prepare-commit.md`
+- instruction improvement: `.claude/skills/improve-instructions/SKILL.md` or `.claude/commands/symfony/improve-instructions.md`
+- production-urgency fixes: `.claude/skills/hotfix/SKILL.md` or `.claude/commands/symfony/hotfix.md`
+- execution discipline for review, refactor, or ambiguity-heavy tasks: `.claude/skills/karpathy-guidelines/SKILL.md`
+
+Guidance:
+
+- skills and commands are two interfaces for the same workflows; do not let them drift
+- prefer skills when the user is speaking naturally
+- prefer commands when the user explicitly invokes a named workflow
+- rules and patterns are the shared source of truth behind both interfaces
+- `.claude/settings.json` is the versioned repository-default settings file for both Claude and Codex
+- `.claude/settings.local.json` is only for optional local overrides and must not be treated as the shared team standard
+
+## AI, MCP, and Privacy Policy
+
+- agent runtime MCP access is blocked by default in `.claude/settings.json`; any exception requires explicit team approval
+- the repository may implement its own `/mcp/` endpoints; treat them as application attack surface, not as permission to use external MCP servers
+- do not expose destructive internal actions, secrets, or private user data through MCP tools or AI adapters
+- anonymize or minimize user data before external AI calls
+- preserve GDPR-oriented deletion, retention, and purpose-limitation expectations when changing flows involving personal data
+
+## Instructions Improvement Policy
+
+Instruction files are living documentation and should improve with the project and environment, but only through an explicit proposal-and-confirmation workflow.
+
+Files in scope:
+
+- `AGENTS.md`
+- `CLAUDE.md`
+- `.claude/rules/*.md`
+- `.claude/patterns.md`
+- `.claude/commands/symfony/*.md`
+- `.claude/skills/*/SKILL.md`
+
+Policy:
+
+- instructions may be improved when there is durable evidence of drift
+- examples of drift:
+ - repeated corrections or reviewer comments
+ - `Makefile`, `composer.json`, or frontend command changes
+ - architecture or testing conventions that changed in practice
+ - duplicated or conflicting guidance
+- only reusable, stable guidance should be added
+- temporary context, one-off fixes, and local anecdotes should not be added to instruction files
+- changes to instruction files must be proposed first and applied only after explicit confirmation in the current conversation
+
+## Quality Gates
+
+Run when relevant:
+
+- `make lint`
+- `make analyse`
+- `make rector`
+- `make tests-unit`
+- `make tests-integration`
+- `make tests`
+- `make tests-api`
+- `make front-lint`
+- `make front-test`
+- `npm run typecheck` from `frontend/` when frontend TypeScript changes
+- `make grumphp` when the broader pre-commit gate is required
+
+Preferred full verification for broad backend/frontend changes:
+
+- `make quality`
+- `make tests`
+- `make tests-api`
+- `make front-lint`
+- `make front-test`
+- `npm run typecheck` from `frontend/`
+
+## Testing Notes
+
+- backend unit tests: `tests/Unit`
+- backend integration tests: `tests/Integration`
+- API behavior tests: `tests/Behat`
+- frontend behavior tests: `frontend/src/**/*.test.tsx`
+- PHPUnit method names must stay camelCase
+
+## Documentation Policy
+
+Use this split:
+
+- `README.md`: human-facing project overview and usage
+- `AGENTS.md`: canonical agent instructions
+- `CLAUDE.md`: pointer file only
+
+If agent instructions need to change:
+
+1. update `AGENTS.md`
+2. keep `CLAUDE.md` minimal and referential
+3. update `README.md` only for human-facing behavior or workflow changes
diff --git a/CLAUDE.md b/CLAUDE.md
index b9cd38b..5c0f2e7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,401 +1 @@
-# Agent Project Guide: Signalist
-
----
-
-# 0. TL;DR (Read This First)
-
-- **Project:** AI-powered RSS intelligence platform (SaaS)
-- **Architecture:** Hexagonal Architecture + CQRS + API Platform + SOLID + DDD
-- **Flow:** `API Platform → State Processor → Handler → Domain Model → Repository`
-- **Backend:** PHP 8.5 + Symfony 7.4 + API Platform 4.x + FrankenPHP
-- **Frontend:** React 19 + TypeScript + Vite 7 + MUI v7 + react-i18next
-- **Database:** PostgreSQL + **pgvector** for embeddings
-- **AI:** Symfony AI (OpenAI, Anthropic, Mistral) + MCP Server
-- **Queue:** Symfony Messenger + Redis
-- **Testing:** PHPUnit + Vitest — mandatory
-- **Code Quality:** PHP CS Fixer + PHPStan (level 9) + Rector
-- **Compliance:** GDPR-compliant by design
-- **Process:**
- 1. **Explore** context
- 2. **Plan** step-by-step & get approval (explain choices)
- 3. **Implement** strictly following the plan (narrate actions)
- 4. **Verify** (Lint, Analyze, Rector, Test)
-
-## Quick Start
-```bash
-make install
-# Or manually:
-docker compose up -d --build
-```
-
----
-
-# 1. Project Overview
-
-**Signalist** is a smart intelligence platform designed to:
-- Aggregate, filter, and synthesize RSS feeds using AI
-- Provide a natural language command interface (Spotlight-like, `Cmd+K`)
-- Expose data via Model Context Protocol (MCP) for LLM ecosystem integration
-
-## 1.1 Key Features
-
-| Feature | Description |
-|---------|-------------|
-| **Feed Management** | Categories, RSS aggregation, full content extraction (Readability) |
-| **Navigation** | Global dashboard, categorized views, full-text & semantic search |
-| **Bookmarking** | Save articles, auto-tagging via LLM, Raindrop.io sync |
-| **AI Newsletters** | LLM-generated summaries, configurable reading time (200 wpm), scheduling |
-| **Social Sharing** | WhatsApp, X, LinkedIn, Threads, Bluesky integration |
-| **Spotlight** | Natural language command bar (`Cmd+K`) for CRUD and AI queries |
-
-## 1.2 Project Phases
-
-1. **Phase 1 (MVP):** Core RSS engine, PostgreSQL schema, Inbox UI ✅
-2. **Phase 2 (Landing Page):** Marketing site, waitlist, pricing
-3. **Phase 3 (AI Layer):** Symfony AI for summaries and auto-tagging
-4. **Phase 4 (Automation):** Newsletter scheduler, Raindrop.io sync
-5. **Phase 5 (Team):** Multi-user workspace, roles, billing
-6. **Phase 6 (Ecosystem):** Spotlight command engine, MCP server, social sharing
-
----
-
-# 2. Tech Stack
-
-## 2.1 Backend
-- **Language:** PHP **8.5**
-- **Framework:** Symfony **7.4** + **API Platform 4.x**
-- **Server:** **FrankenPHP** (built on Caddy, worker mode)
-- **Architecture:** CQRS, Clean Architecture, Hexagonal (Ports & Adapters)
-- **Database:** PostgreSQL + **pgvector** extension
-- **Queue:** Symfony Messenger + Redis
-- **AI Integration:** Symfony AI (`#[AsTool]` attributes)
-- **Protocol:** Model Context Protocol (MCP) Server
-
-## 2.2 Frontend
-- **Framework:** React **19** with TypeScript
-- **Build:** Vite **7**
-- **Components:** MUI (Material UI) **v7**
-- **i18n:** react-i18next (EN default, FR supported)
-- **State:** @tanstack/react-query v5
-- **HTTP:** Axios
-- **Testing:** Vitest + React Testing Library
-
-## 2.3 Third-Party Integrations
-- **Raindrop.io:** OAuth2 bookmark synchronization
-- **Email:** Symfony Mailer
-- **LLM Providers:** OpenAI, Anthropic, Mistral (via Symfony AI)
-
----
-
-# 3. Commands
-
-| Purpose | Command |
-|---------|---------|
-| Start containers | `docker compose up -d --build` |
-| Code style | `make lint` |
-| Static analysis | `make analyse` |
-| Code refactoring | `make rector` |
-| All quality checks | `make quality` |
-| Backend unit tests | `make tests-unit` |
-| All checks + tests | `make grumphp` |
-| API tests | `docker compose exec app vendor/bin/behat --suite=api` |
-| Frontend tests | `npm test` |
-| Frontend typecheck | `npm run typecheck` |
-| All commands | `make help` |
-
----
-
-# 4. Architecture & File Structure
-
-## 4.1 CQRS Components
-
-| Component | Responsibility |
-|-----------|----------------|
-| **Query** | Read intent (GET). Returns DTOs via read models. |
-| **Command** | Write intent (POST/PUT/DELETE). Encapsulates user intent. |
-| **Handler** | Orchestrates domain logic. **Only place for business logic.** |
-| **InputDTO** | Request payload validation (strict constraints). |
-| **OutputDTO** | Response shaping (read-only, English field names). |
-| **Controller** | HTTP Adapter. Maps Request → InputDTO → Command/Query → Response. |
-
-## 4.2 Directory Structure
-
-```
-src/
-├── Domain/ # Business logic (vertical slices)
-│ ├── Feed/
-│ │ ├── Command/
-│ │ ├── Query/
-│ │ ├── Handler/
-│ │ ├── DTO/
-│ │ ├── Model/
-│ │ └── Port/ # Repository interfaces
-│ ├── Category/
-│ ├── Article/
-│ ├── Bookmark/
-│ ├── Newsletter/
-│ └── Spotlight/
-├── Infrastructure/ # Adapters (implementations)
-│ ├── Persistence/ # Doctrine repositories
-│ ├── AI/ # Symfony AI adapters
-│ ├── MCP/ # MCP server implementation
-│ ├── RSS/ # Feed parsers
-│ └── External/ # Third-party APIs (Raindrop)
-├── UI/ # Controllers, CLI commands
-│ ├── Controller/
-│ └── Command/
-└── Entity/ # Doctrine entities
-```
-
-## 4.3 Routing Conventions
-
-| Route Type | Prefix | Purpose |
-|------------|--------|---------|
-| REST API | `/api/v1/` | Main application endpoints |
-| MCP | `/mcp/` | Model Context Protocol endpoints |
-| Internal | - | Use UUIDs, avoid numeric IDs |
-
----
-
-# 5. Code Style & Quality
-
-## 5.1 Standards
-- Every file: `declare(strict_types=1);`
-- Use PHP 8.5 features: `readonly` classes, constructor promotion
-- PSR-12 coding standard
-- Explicit, descriptive naming (e.g., `RssFeedParser` not `FeedService`)
-
-## 5.2 Test Naming (PHP)
-
-> **Critical:** PHP CS Fixer enforces **camelCase** method names — no underscores.
-
-Pattern: `test{Method}{Scenario}{Expected}`
-
-```php
-// Good
-public function testInvokeWithValidUrlReturnsFeedId(): void
-public function testInvokeWithInvalidCategoryThrowsNotFoundException(): void
-
-// Bad — PHP CS Fixer will reject
-public function testInvoke_ValidUrl_ReturnsFeedId(): void
-```
-
-## 5.3 Quality Tools
-
-| Tool | Purpose | Command |
-|------|---------|---------|
-| PHP CS Fixer | Code style (PSR-12) | `make lint` |
-| PHPStan | Static analysis (level 9) | `make analyse` |
-| Rector | Code modernization | `make rector` |
-| GrumPHP | Pre-commit gate | `make grumphp` |
-
----
-
-# 6. Agent Instructions & Boundaries
-
-## 6.0 Communication Style
-When working on this project, the agent MUST:
-- **Explain choices in real-time:** Before implementing, explain WHY a particular approach is chosen
-- **Narrate actions:** Describe what you are doing as you do it
-- **Justify technical decisions:** When choosing a pattern, library, or approach, explain the reasoning
-- **Highlight trade-offs:** When multiple valid approaches exist, explain the pros/cons
-- **Compare alternatives explicitly:** For non-trivial decisions, list at least two alternatives
-
-## 6.1 ALWAYS DO
-- Follow CQRS, Hexagonal Architecture, SOLID
-- Validate inputs strictly via InputDTOs
-- Run `make quality` on generated PHP code
-- Write tests for every change (PHPUnit/Vitest)
-- Use async processing for RSS crawling and AI inference
-- Ensure AI summaries retain source URLs (factual integrity)
-- Use Conventional Commits for git messages
-- After implementing a feature that passes `make quality` and tests, update `docs/ROADMAP.md`
-
-## 6.2 ASK FIRST
-- Adding new composer/npm packages
-- Changing PostgreSQL schema or pgvector dimensions
-- Modifying MCP protocol implementation
-- Changing LLM provider configurations
-
-## 6.3 NEVER DO
-- Commit to `master` directly — always create a `feat/` or `fix/` branch and PR
-- Hardcode API keys (use environment variables)
-- Perform blocking HTTP/AI calls in web request cycle
-- Add coupling between domains
-- Write business logic in controllers
-- Create "god services"
-- Store personal data without documented purpose (GDPR)
-- Send raw user data to external AI services without anonymization (GDPR)
-
----
-
-# 7. Exception Handling (RFC 7807)
-
-All API errors follow **RFC 7807 - Problem Details for HTTP APIs**.
-
-## 7.1 Problem Details Format
-```json
-{
- "type": "https://signalist.app/problems/feed-not-found",
- "title": "Feed Not Found",
- "status": 404,
- "detail": "The feed with ID 550e8400-e29b-41d4-a716-446655440000 was not found",
- "instance": "/api/v1/feeds/550e8400-e29b-41d4-a716-446655440000"
-}
-```
-
-## 7.2 Problem Types
-
-| Type URI | Title | Status | When |
-|----------|-------|--------|------|
-| `/problems/validation-error` | Validation Error | 400 | Input validation failed |
-| `/problems/not-found` | Resource Not Found | 404 | Entity doesn't exist |
-| `/problems/conflict` | Resource Conflict | 409 | Duplicate or conflict |
-| `/problems/unprocessable` | Unprocessable Entity | 422 | Business rule violation |
-| `/problems/quota-exceeded` | Quota Exceeded | 402 | Rate/usage limit hit |
-| `/problems/internal-error` | Internal Error | 500 | Unexpected server error |
-
----
-
-# 8. Testing
-
-Testing is **mandatory**. Target 80%+ coverage on business logic (enforced in CI).
-
-## 8.1 Backend (PHPUnit)
-
-| Type | Purpose | Location |
-|------|---------|----------|
-| **Unit** | Test Handlers, Domain Models | `tests/Unit/` |
-| **API** | Full HTTP flow via Behat | `features/api/` |
-
-**Naming:** `test{Method}{Scenario}{Expected}` (camelCase, no underscores — enforced by PHP CS Fixer)
-
-## 8.2 Frontend (Vitest + RTL)
-- Test React components and hooks
-- Mock API calls
-- Initialize i18n in `src/test/setup.ts` (already done)
-- Spotlight command parsing
-
----
-
-# 9. API Response Formats
-
-## 9.1 Single Resource
-```json
-{ "id": "uuid", "title": "Feed Title", "url": "https://..." }
-```
-
-## 9.2 Paginated Collection (`GET /api/v1/articles`)
-```json
-{
- "items": [...],
- "total": 100,
- "page": 1,
- "limit": 20,
- "pages": 5
-}
-```
-
-## 9.3 Hydra Collection (API Platform resources)
-```json
-{
- "@context": "/api/v1/contexts/Feed",
- "@type": "Collection",
- "member": [...],
- "totalItems": 10
-}
-```
-
----
-
-# 10. Project Specifics
-
-## 10.1 Vector Search (pgvector)
-Pipeline: `RSS Fetch → HTML Clean (Readability) → Chunking → Embedding → Postgres`
-
-```sql
-CREATE EXTENSION vector;
-CREATE TABLE article_embeddings (
- id UUID PRIMARY KEY,
- article_id UUID REFERENCES articles(id),
- embedding vector(1536),
- chunk_index INT
-);
-CREATE INDEX ON article_embeddings
-USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-```
-
-## 10.2 MCP Server
-```php
-#[AsTool(name: 'search_articles', description: 'Search articles by semantic query')]
-final class SearchArticlesTool
-{
- public function __invoke(string $query, int $limit = 10): array { ... }
-}
-```
-
-## 10.3 Spotlight Command Interface
-- Activation: `Cmd+K`
-- Shell: implemented in `frontend/src/components/Spotlight/`
-- Natural Language Processing for intent mapping
-
----
-
-# 11. Git Conventions
-
-## 11.1 Branching Strategy
-- Branch naming: `feat/`, `fix/`, `refactor/`, `chore/`
-- Create PR via `gh pr create` — never commit directly to `master`
-
-## 11.2 Commit Messages (Conventional Commits v1.0.0)
-
-```
-feat(feed): add RSS validation before save
-fix(newsletter): correct word count calculation
-refactor(spotlight): extract command parser
-test(bookmark): add integration tests for tagging
-chore(ci): bump actions to Node.js 24
-```
-
-**GrumPHP enforces 72-char line limit** on subject and body lines.
-
----
-
-# 12. Slash Commands
-
-| Command | Use For |
-|---------|---------|
-| `/new-feature ` | Start structured 9-step feature workflow |
-| `/bug-fix ` | Start structured 8-step bug fix workflow |
-| `/hotfix ` | Expedited minimal fix for production issues |
-| `/review` | Code review of current git diff |
-| `/quality` | Run `make quality` + unit tests and report |
-| `/simplify` | Clean up recently changed code without changing behavior |
-
----
-
-# 13. GDPR Compliance
-
-All features involving personal data require privacy-by-design.
-
-## 13.1 Core Principles
-
-| Principle | Implementation |
-|-----------|----------------|
-| **Data Minimization** | Only collect data strictly necessary |
-| **Purpose Limitation** | Data used only for stated purpose |
-| **Storage Limitation** | Enforce retention periods |
-| **Integrity & Confidentiality** | Encrypt at rest and in transit |
-
-## 13.2 Agent GDPR Rules
-
-### ALWAYS DO
-- Add `deletedAt` soft-delete column to entities with personal data
-- Anonymize data before sending to external AI services
-- Include data in user export endpoints
-
-### NEVER DO
-- Store personal data without documented purpose
-- Send raw personal data to AI providers
-- Retain data beyond defined retention periods
-- Log sensitive data (passwords, tokens) in plain text
+Source of truth: see `AGENTS.md`.
diff --git a/config/bundles.php b/config/bundles.php
index 932c1f8..3823886 100644
--- a/config/bundles.php
+++ b/config/bundles.php
@@ -2,16 +2,28 @@
declare(strict_types=1);
+use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
+use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
+use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
+use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
+use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle;
+use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
+use Nelmio\CorsBundle\NelmioCorsBundle;
+use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
+use Symfony\Bundle\SecurityBundle\SecurityBundle;
+use Symfony\Bundle\TwigBundle\TwigBundle;
+use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
+
return [
- Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
- ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
- Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
- Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
- Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
- FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle::class => ['test' => true],
- Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
- Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
- Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
- Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
- Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
+ FrameworkBundle::class => ['all' => true],
+ ApiPlatformBundle::class => ['all' => true],
+ DoctrineBundle::class => ['all' => true],
+ DoctrineMigrationsBundle::class => ['all' => true],
+ NelmioCorsBundle::class => ['all' => true],
+ FriendsOfBehatSymfonyExtensionBundle::class => ['test' => true],
+ TwigBundle::class => ['all' => true],
+ DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
+ WebProfilerBundle::class => ['dev' => true, 'test' => true],
+ SecurityBundle::class => ['all' => true],
+ LexikJWTAuthenticationBundle::class => ['all' => true],
];
diff --git a/config/preload.php b/config/preload.php
index 69de615..7cbe578 100644
--- a/config/preload.php
+++ b/config/preload.php
@@ -2,6 +2,6 @@
declare(strict_types=1);
-if (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) {
- require dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php';
+if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
+ require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}
diff --git a/docker/frankenphp/Dockerfile b/docker/frankenphp/Dockerfile
index 30c669f..48ecc9f 100644
--- a/docker/frankenphp/Dockerfile
+++ b/docker/frankenphp/Dockerfile
@@ -31,8 +31,12 @@ COPY . .
COPY docker/frankenphp/php.ini /usr/local/etc/php/conf.d/app.ini
COPY docker/frankenphp/Caddyfile /etc/caddy/Caddyfile
-# Install dependencies and set permissions
-RUN composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction \
+ENV APP_ENV=prod
+
+# Install dependencies without running post-install scripts (cache:clear requires
+# env vars and services that are not available at image build time)
+RUN composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction --no-scripts \
+ && mkdir -p /app/var \
&& chown -R www-data:www-data /app/var
# Expose ports (HTTP/HTTPS/HTTP3)
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index e49157d..755c2a1 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -425,6 +425,28 @@
| Sync status UI (frontend) | Not Started |
| Unit tests | Not Started |
+### 4.5 Social Recap (AI-Generated Weekly Post)
+
+> Reuses Phase 4.2 content aggregation + Phase 3 LLM summarization to generate
+> platform-native social posts from the week's curated articles.
+
+| Task | Status |
+|------|--------|
+| `SocialRecap` entity + migration (platform, period, content, status) | Not Started |
+| `CreateSocialRecapCommand` + `CreateSocialRecapHandler` | Not Started |
+| `DeleteSocialRecapCommand` + `DeleteSocialRecapHandler` | Not Started |
+| `GetSocialRecapQuery` + `GetSocialRecapHandler` | Not Started |
+| `ListSocialRecapsQuery` + `ListSocialRecapsHandler` | Not Started |
+| `SocialRecapRepositoryInterface` port | Not Started |
+| `DoctrineSocialRecapRepository` adapter | Not Started |
+| `SocialRecapContentBuilder` service (reuses `NewsletterContentBuilder` aggregation) | Not Started |
+| Platform output formatters: LinkedIn, X (thread), Instagram caption | Not Started |
+| LLM prompt templates per platform (tone, length, hashtag style) | Not Started |
+| `GenerateSocialRecapMessage` (async) + handler | Not Started |
+| Social Recap API Platform resource | Not Started |
+| Frontend: recap generator UI (select period, platform, preview, copy) | Not Started |
+| Unit tests for content builder + formatters | Not Started |
+
---
## Phase 5 — Team: Multi-User Workspace & Collaborative Curation
@@ -498,6 +520,8 @@
### 6.3 Social Sharing
+#### 6.3.1 Share Links (link-based, no OAuth required)
+
| Task | Status |
|------|--------|
| Share intent model | Not Started |
@@ -509,6 +533,27 @@
| Share button UI (frontend) | Not Started |
| Unit tests | Not Started |
+#### 6.3.2 Crossposting via Platform APIs (requires OAuth2 per platform)
+
+> Upgrade path from 6.3.1 — posts content directly via platform APIs.
+> Builds on OAuth2 infrastructure established in Phase 4.4 (Raindrop.io).
+
+| Task | Status |
+|------|--------|
+| `SocialAccount` entity + migration (platform, accessToken, refreshToken, expiresAt) | Not Started |
+| `SocialAccountRepositoryInterface` port | Not Started |
+| `DoctrineSocialAccountRepository` adapter | Not Started |
+| OAuth2 flow: LinkedIn (`Infrastructure/External/LinkedIn/`) | Not Started |
+| OAuth2 flow: X / Twitter (`Infrastructure/External/X/`) | Not Started |
+| OAuth2 flow: Bluesky (`Infrastructure/External/Bluesky/`) | Not Started |
+| `CrosspostMessage` (async) + `CrosspostMessageHandler` | Not Started |
+| `CrosspostAdapterInterface` port (per-platform implementations) | Not Started |
+| Token refresh strategy (background job) | Not Started |
+| Crosspost status tracking (pending, published, failed) | Not Started |
+| Frontend: connect social accounts UI (OAuth2 redirect flow) | Not Started |
+| Frontend: crosspost action on articles + recap posts | Not Started |
+| Unit tests for adapters | Not Started |
+
---
## Cross-Cutting: GDPR Compliance
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4e92939..a196d64 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -2455,9 +2455,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2794,14 +2794,14 @@
"license": "MIT"
},
"node_modules/axios": {
- "version": "1.13.6",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
- "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
+ "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
- "proxy-from-env": "^1.1.0"
+ "proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-plugin-macros": {
@@ -2847,9 +2847,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3109,9 +3109,9 @@
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
- "version": "1.10.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
- "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
+ "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC",
"engines": {
"node": ">= 6"
@@ -3278,9 +3278,9 @@
}
},
"node_modules/dompurify": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
- "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
+ "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -3779,16 +3779,16 @@
}
},
"node_modules/flatted": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
- "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -4552,9 +4552,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4851,9 +4851,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4971,10 +4971,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "license": "MIT"
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -5712,9 +5715,9 @@
}
},
"node_modules/vite": {
- "version": "7.3.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
- "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6059,9 +6062,9 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {
diff --git a/public/index.php b/public/index.php
index f4e815d..6ff993c 100644
--- a/public/index.php
+++ b/public/index.php
@@ -4,8 +4,6 @@
use App\Kernel;
-require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
+require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
-return static function (array $context) {
- return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
-};
+return static fn (array $context) => new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
diff --git a/rector.php b/rector.php
index ddc1b19..813be79 100644
--- a/rector.php
+++ b/rector.php
@@ -9,14 +9,18 @@
use Rector\Symfony\Set\SymfonySetList;
return RectorConfig::configure()
+ ->withImportNames(importShortClasses: false)
->withPaths([
- __DIR__ . '/src',
- __DIR__ . '/tests',
+ __DIR__.'/config',
+ __DIR__.'/public',
+ __DIR__.'/src',
+ __DIR__.'/migrations',
+ __DIR__.'/tests',
])
->withSkip([
- __DIR__ . '/var',
- __DIR__ . '/vendor',
- __DIR__ . '/config/reference.php',
+ __DIR__.'/var',
+ __DIR__.'/vendor',
+ __DIR__.'/config/reference.php',
// Skip Override attribute for PHP 8.5 compatibility
AddOverrideAttributeToOverriddenMethodsRector::class,
])
@@ -24,8 +28,6 @@
->withPreparedSets(
deadCode: true,
codeQuality: true,
- typeDeclarations: true,
- earlyReturn: true,
)
->withAttributesSets(
symfony: true,
@@ -35,7 +37,9 @@
SymfonySetList::SYMFONY_74,
SymfonySetList::SYMFONY_CODE_QUALITY,
SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION,
+ SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES,
SymfonySetList::CONFIGS,
DoctrineSetList::DOCTRINE_CODE_QUALITY,
PHPUnitSetList::PHPUNIT_100,
+ DoctrineSetList::DOCTRINE_ORM_300,
]);
diff --git a/src/DataFixtures/ArticleFixture.php b/src/DataFixtures/ArticleFixture.php
index 4fd004b..9598699 100644
--- a/src/DataFixtures/ArticleFixture.php
+++ b/src/DataFixtures/ArticleFixture.php
@@ -5,6 +5,7 @@
namespace App\DataFixtures;
use App\Entity\Article;
+use App\Entity\Feed;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
@@ -232,7 +233,7 @@ public function load(ObjectManager $manager): void
$article->setImageUrl($data['imageUrl']);
$article->setIsRead($data['isRead']);
$article->setPublishedAt(new DateTimeImmutable($data['publishedAt']));
- $article->setFeed($this->getReference($data['feed'], \App\Entity\Feed::class));
+ $article->setFeed($this->getReference($data['feed'], Feed::class));
$manager->persist($article);
$this->addReference($key, $article);
diff --git a/src/DataFixtures/BookmarkFixture.php b/src/DataFixtures/BookmarkFixture.php
index 1ab01a9..72e315f 100644
--- a/src/DataFixtures/BookmarkFixture.php
+++ b/src/DataFixtures/BookmarkFixture.php
@@ -4,6 +4,7 @@
namespace App\DataFixtures;
+use App\Entity\Article;
use App\Entity\Bookmark;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
@@ -42,7 +43,7 @@ public function load(ObjectManager $manager): void
{
foreach (self::BOOKMARKS as $index => $data) {
$bookmark = new Bookmark();
- $bookmark->setArticle($this->getReference($data['article'], \App\Entity\Article::class));
+ $bookmark->setArticle($this->getReference($data['article'], Article::class));
$bookmark->setNotes($data['notes']);
$manager->persist($bookmark);
diff --git a/src/DataFixtures/FeedFixture.php b/src/DataFixtures/FeedFixture.php
index 76fe4b6..2711897 100644
--- a/src/DataFixtures/FeedFixture.php
+++ b/src/DataFixtures/FeedFixture.php
@@ -4,6 +4,7 @@
namespace App\DataFixtures;
+use App\Entity\Category;
use App\Entity\Feed;
use App\Entity\User;
use DateTimeImmutable;
@@ -97,7 +98,7 @@ public function load(ObjectManager $manager): void
$feed->setTitle($data['title']);
$feed->setUrl($data['url']);
$feed->setStatus($data['status']);
- $feed->setCategory($this->getReference($data['category'], \App\Entity\Category::class));
+ $feed->setCategory($this->getReference($data['category'], Category::class));
$feed->setLastFetchedAt(new DateTimeImmutable($data['lastFetchedAt']));
$feed->setOwner($admin);
diff --git a/src/Domain/Article/Handler/ListArticlesHandler.php b/src/Domain/Article/Handler/ListArticlesHandler.php
index ff91097..9d852cf 100644
--- a/src/Domain/Article/Handler/ListArticlesHandler.php
+++ b/src/Domain/Article/Handler/ListArticlesHandler.php
@@ -21,19 +21,19 @@ public function __invoke(ListArticlesQuery $query): PaginatedArticlesResult
{
$filters = ['ownerId' => $query->ownerId];
- if ($query->feedId !== null) {
+ if (null !== $query->feedId) {
$filters['feedId'] = $query->feedId;
}
- if ($query->categoryId !== null) {
+ if (null !== $query->categoryId) {
$filters['categoryId'] = $query->categoryId;
}
- if ($query->isRead !== null) {
+ if (null !== $query->isRead) {
$filters['isRead'] = $query->isRead;
}
- if ($query->search !== null) {
+ if (null !== $query->search) {
$filters['search'] = $query->search;
}
diff --git a/src/Domain/Feed/Handler/ListFeedsHandler.php b/src/Domain/Feed/Handler/ListFeedsHandler.php
index 4593b31..2a2b990 100644
--- a/src/Domain/Feed/Handler/ListFeedsHandler.php
+++ b/src/Domain/Feed/Handler/ListFeedsHandler.php
@@ -20,7 +20,7 @@ public function __construct(
*/
public function __invoke(ListFeedsQuery $query): array
{
- if ($query->categoryId !== null) {
+ if (null !== $query->categoryId) {
return $this->feedRepository->findByCategoryAndOwner($query->categoryId, $query->ownerId);
}
diff --git a/src/Domain/Feed/MessageHandler/CrawlFeedMessageHandler.php b/src/Domain/Feed/MessageHandler/CrawlFeedMessageHandler.php
index 22b77b1..7f21c08 100644
--- a/src/Domain/Feed/MessageHandler/CrawlFeedMessageHandler.php
+++ b/src/Domain/Feed/MessageHandler/CrawlFeedMessageHandler.php
@@ -100,6 +100,6 @@ private function articleExists(Feed $feed, string $guid): bool
'guid' => $guid,
]);
- return $existingArticle !== null;
+ return null !== $existingArticle;
}
}
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 6226755..49e3499 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -70,7 +70,7 @@ public function setEmail(string $email): self
public function getUserIdentifier(): string
{
- assert($this->email !== '');
+ assert('' !== $this->email);
return $this->email;
}
diff --git a/src/Infrastructure/ApiPlatform/State/ArticleStateProvider.php b/src/Infrastructure/ApiPlatform/State/ArticleStateProvider.php
index fba058f..98d62ba 100644
--- a/src/Infrastructure/ApiPlatform/State/ArticleStateProvider.php
+++ b/src/Infrastructure/ApiPlatform/State/ArticleStateProvider.php
@@ -62,7 +62,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
$isRead = null;
- if ($isReadParam !== null) {
+ if (null !== $isReadParam) {
$isRead = filter_var($isReadParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
diff --git a/src/Infrastructure/EventListener/ProblemDetailsExceptionListener.php b/src/Infrastructure/EventListener/ProblemDetailsExceptionListener.php
index 0837a20..a274047 100644
--- a/src/Infrastructure/EventListener/ProblemDetailsExceptionListener.php
+++ b/src/Infrastructure/EventListener/ProblemDetailsExceptionListener.php
@@ -68,7 +68,7 @@ private function buildProblemDetails(Throwable $exception, string $path): array
if ($exception instanceof MissingConstructorArgumentsException) {
return [
- 'type' => self::BASE_TYPE_URI . '/validation-error',
+ 'type' => self::BASE_TYPE_URI.'/validation-error',
'title' => 'Validation Error',
'status' => Response::HTTP_UNPROCESSABLE_ENTITY,
'detail' => $exception->getMessage(),
@@ -78,7 +78,7 @@ private function buildProblemDetails(Throwable $exception, string $path): array
if ($exception instanceof HttpExceptionInterface) {
return [
- 'type' => self::BASE_TYPE_URI . '/http-error',
+ 'type' => self::BASE_TYPE_URI.'/http-error',
'title' => Response::$statusTexts[$exception->getStatusCode()] ?? 'Error',
'status' => $exception->getStatusCode(),
'detail' => $exception->getMessage(),
@@ -88,10 +88,10 @@ private function buildProblemDetails(Throwable $exception, string $path): array
// Generic server error
return [
- 'type' => self::BASE_TYPE_URI . '/internal-error',
+ 'type' => self::BASE_TYPE_URI.'/internal-error',
'title' => 'Internal Server Error',
'status' => Response::HTTP_INTERNAL_SERVER_ERROR,
- 'detail' => $this->environment === 'prod'
+ 'detail' => 'prod' === $this->environment
? 'An unexpected error occurred.'
: $exception->getMessage(),
'instance' => $path,
diff --git a/src/Infrastructure/Exception/ProblemException.php b/src/Infrastructure/Exception/ProblemException.php
index 87b3feb..d1af2e9 100644
--- a/src/Infrastructure/Exception/ProblemException.php
+++ b/src/Infrastructure/Exception/ProblemException.php
@@ -60,11 +60,11 @@ public function toProblemDetails(): array
'detail' => $this->getDetail(),
'instance' => $this->instance,
...$this->extensions,
- ], static fn (mixed $value): bool => $value !== null);
+ ], static fn (mixed $value): bool => null !== $value);
}
protected static function buildTypeUri(string $problemType): string
{
- return self::BASE_TYPE_URI . '/' . $problemType;
+ return self::BASE_TYPE_URI.'/'.$problemType;
}
}
diff --git a/src/Infrastructure/Persistence/Article/DoctrineArticleRepository.php b/src/Infrastructure/Persistence/Article/DoctrineArticleRepository.php
index ab4e8cc..7a90d6e 100644
--- a/src/Infrastructure/Persistence/Article/DoctrineArticleRepository.php
+++ b/src/Infrastructure/Persistence/Article/DoctrineArticleRepository.php
@@ -139,9 +139,9 @@ private function applyFilters(QueryBuilder $qb, array $filters): void
->setParameter('isRead', $filters['isRead']);
}
- if (isset($filters['search']) && $filters['search'] !== '') {
+ if (isset($filters['search']) && '' !== $filters['search']) {
$qb->andWhere('(LOWER(a.title) LIKE :search OR LOWER(a.summary) LIKE :search)')
- ->setParameter('search', '%' . mb_strtolower($filters['search']) . '%');
+ ->setParameter('search', '%'.mb_strtolower($filters['search']).'%');
}
}
}
diff --git a/src/Infrastructure/RSS/LaminasFeedRssFetcher.php b/src/Infrastructure/RSS/LaminasFeedRssFetcher.php
index 849fe08..50b1fe8 100644
--- a/src/Infrastructure/RSS/LaminasFeedRssFetcher.php
+++ b/src/Infrastructure/RSS/LaminasFeedRssFetcher.php
@@ -46,17 +46,17 @@ public function fetch(string $url): RssFetchResult
foreach ($feed as $entry) {
$guid = $entry->getId() ?? $entry->getLink() ?? '';
- if ($guid === '') {
+ if ('' === $guid) {
continue;
}
$link = $entry->getLink();
- if ($link === null) {
+ if (null === $link) {
continue;
}
- if ($link === '') {
+ if ('' === $link) {
continue;
}
@@ -87,7 +87,7 @@ public function fetch(string $url): RssFetchResult
private function cleanText(?string $text): ?string
{
- if ($text === null) {
+ if (null === $text) {
return null;
}
@@ -95,7 +95,7 @@ private function cleanText(?string $text): ?string
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$normalized = trim((string) preg_replace('/\s+/u', ' ', $decoded));
- if ($normalized === '' || mb_strlen($normalized) < 30) {
+ if ('' === $normalized || mb_strlen($normalized) < 30) {
return null;
}
@@ -106,14 +106,14 @@ private function extractAuthor(EntryInterface $entry): ?string
{
$authors = $entry->getAuthors();
- if ($authors === null) {
+ if (null === $authors) {
return null;
}
// Convert iterable to array to safely access first element
$authorsArray = iterator_to_array($authors);
- if ($authorsArray === []) {
+ if ([] === $authorsArray) {
return null;
}
diff --git a/tests/Behat/ApiContext.php b/tests/Behat/ApiContext.php
index a707050..2fbacbe 100644
--- a/tests/Behat/ApiContext.php
+++ b/tests/Behat/ApiContext.php
@@ -4,8 +4,10 @@
namespace App\Tests\Behat;
+use App\DataFixtures\UserFixture;
use App\Entity\Article;
use App\Entity\Feed;
+use App\Entity\User;
use function array_key_exists;
@@ -88,7 +90,7 @@ public function resetDatabase(BeforeScenarioScope $scope): void
public function thereAreDefaultUsers(): void
{
$loader = new Loader();
- $loader->addFixture(new \App\DataFixtures\UserFixture($this->passwordHasher));
+ $loader->addFixture(new UserFixture($this->passwordHasher));
$executor = new ORMExecutor($this->entityManager);
$executor->execute($loader->getFixtures(), append: true);
@@ -101,7 +103,7 @@ public function thereAreDefaultUsers(): void
*/
public function thereIsAnUnverifiedUser(string $email, string $password): void
{
- $user = new \App\Entity\User();
+ $user = new User();
$user->setEmail($email);
$user->setPassword($this->passwordHasher->hashPassword($user, $password));
@@ -128,8 +130,8 @@ public function iAmAuthenticatedAs(string $email): void
/** @var array{token: string}|null $data */
$data = json_decode($content, true);
- if (json_last_error() !== JSON_ERROR_NONE || !isset($data['token'])) {
- throw new RuntimeException('Authentication failed: ' . $content);
+ if (JSON_ERROR_NONE !== json_last_error() || !isset($data['token'])) {
+ throw new RuntimeException('Authentication failed: '.$content);
}
$this->jwtToken = $data['token'];
@@ -168,7 +170,7 @@ public function iSendARequestToWithBody(string $method, string $url, PyStringNod
$url = $this->replaceStoredVariables($url);
$bodyContent = $this->replaceStoredVariables($body->getRaw());
- $contentType = $method === 'PATCH' ? 'application/merge-patch+json' : 'application/json';
+ $contentType = 'PATCH' === $method ? 'application/merge-patch+json' : 'application/json';
$this->getClient()->request(
$method,
@@ -207,8 +209,8 @@ public function theResponseShouldBeJson(): void
/** @var array|null $data */
$data = json_decode($content, true);
- if (json_last_error() !== JSON_ERROR_NONE) {
- throw new RuntimeException('Response is not valid JSON: ' . $content);
+ if (JSON_ERROR_NONE !== json_last_error()) {
+ throw new RuntimeException('Response is not valid JSON: '.$content);
}
$this->lastResponseData = $data;
@@ -272,8 +274,8 @@ public function theJsonCollectionShouldBeEmpty(): void
? $items['member']
: (is_array($items) && !isset($items['@type']) ? $items : []);
- if ($collection !== []) {
- throw new RuntimeException('Expected empty collection, got: ' . json_encode($collection));
+ if ([] !== $collection) {
+ throw new RuntimeException('Expected empty collection, got: '.json_encode($collection));
}
return;
@@ -281,16 +283,16 @@ public function theJsonCollectionShouldBeEmpty(): void
// Handle Hydra collection format
if (isset($data['member'])) {
- if ($data['member'] !== []) {
- throw new RuntimeException('Expected empty collection, got: ' . json_encode($data['member']));
+ if ([] !== $data['member']) {
+ throw new RuntimeException('Expected empty collection, got: '.json_encode($data['member']));
}
return;
}
// Handle plain array
- if ($data !== []) {
- throw new RuntimeException('Expected empty array, got: ' . json_encode($data));
+ if ([] !== $data) {
+ throw new RuntimeException('Expected empty array, got: '.json_encode($data));
}
}
@@ -380,7 +382,7 @@ public function aCategoryExistsWithNameAndSlug(string $name, string $slug): void
$statusCode = $this->session->getStatusCode();
- if ($statusCode !== 201) {
+ if (201 !== $statusCode) {
throw new RuntimeException(sprintf(
'Failed to create category. Status: %d, Response: %s',
$statusCode,
@@ -420,7 +422,7 @@ public function aFeedExistsWithTitleAndUrlInCategory(string $title, string $url,
}
}
- if ($categoryId === null) {
+ if (null === $categoryId) {
throw new RuntimeException(sprintf('Category with slug "%s" not found', $slug));
}
@@ -435,7 +437,7 @@ public function aFeedExistsWithTitleAndUrlInCategory(string $title, string $url,
$statusCode = $this->session->getStatusCode();
- if ($statusCode !== 201) {
+ if (201 !== $statusCode) {
throw new RuntimeException(sprintf(
'Failed to create feed. Status: %d, Response: %s',
$statusCode,
@@ -458,9 +460,9 @@ public function anArticleExistsWithTitleInFeed(string $title, string $feedTitle)
}
$article = new Article();
- $article->setGuid('guid-' . $title);
+ $article->setGuid('guid-'.$title);
$article->setTitle($title);
- $article->setUrl('https://example.com/articles/' . urlencode($title));
+ $article->setUrl('https://example.com/articles/'.urlencode($title));
$article->setFeed($feed);
$article->setPublishedAt(new DateTimeImmutable());
@@ -508,7 +510,7 @@ public function iStoreTheResponseFieldAs(string $field, string $variable): void
*/
private function getJsonResponse(): array
{
- if ($this->lastResponseData !== null) {
+ if (null !== $this->lastResponseData) {
return $this->lastResponseData;
}
@@ -516,8 +518,8 @@ private function getJsonResponse(): array
/** @var array|null $data */
$data = json_decode($content, true);
- if (json_last_error() !== JSON_ERROR_NONE || $data === null) {
- throw new RuntimeException('Response is not valid JSON: ' . $content);
+ if (JSON_ERROR_NONE !== json_last_error() || null === $data) {
+ throw new RuntimeException('Response is not valid JSON: '.$content);
}
$this->lastResponseData = $data;
@@ -543,15 +545,15 @@ private function replaceStoredVariables(string $text): string
private function castValue(string $value): mixed
{
- if ($value === 'true') {
+ if ('true' === $value) {
return true;
}
- if ($value === 'false') {
+ if ('false' === $value) {
return false;
}
- if ($value === 'null') {
+ if ('null' === $value) {
return null;
}
@@ -572,8 +574,8 @@ private function buildHeaders(string $contentType): array
'HTTP_ACCEPT' => 'application/ld+json',
];
- if ($this->jwtToken !== null) {
- $headers['HTTP_AUTHORIZATION'] = 'Bearer ' . $this->jwtToken;
+ if (null !== $this->jwtToken) {
+ $headers['HTTP_AUTHORIZATION'] = 'Bearer '.$this->jwtToken;
}
return $headers;
diff --git a/tests/Integration/Persistence/DoctrineArticleRepositoryTest.php b/tests/Integration/Persistence/DoctrineArticleRepositoryTest.php
index 9ec4885..3919fce 100644
--- a/tests/Integration/Persistence/DoctrineArticleRepositoryTest.php
+++ b/tests/Integration/Persistence/DoctrineArticleRepositoryTest.php
@@ -168,13 +168,13 @@ private function createFeedGraph(string $email = 'test@example.com'): array
$category = new Category();
$category->setName('Tech');
- $category->setSlug('tech-' . bin2hex(random_bytes(4)));
+ $category->setSlug('tech-'.bin2hex(random_bytes(4)));
$category->setOwner($user);
$this->entityManager->persist($category);
$feed = new Feed();
$feed->setTitle('Test Feed');
- $feed->setUrl('https://example.com/feed-' . bin2hex(random_bytes(4)));
+ $feed->setUrl('https://example.com/feed-'.bin2hex(random_bytes(4)));
$feed->setCategory($category);
$feed->setOwner($user);
$this->entityManager->persist($feed);
@@ -201,9 +201,9 @@ private function createArticle(string $title, Feed $feed): Article
{
$article = new Article();
$article->setTitle($title);
- $article->setGuid('guid-' . bin2hex(random_bytes(8)));
- $article->setUrl('https://example.com/' . bin2hex(random_bytes(4)));
- $article->setSummary('Summary of ' . $title);
+ $article->setGuid('guid-'.bin2hex(random_bytes(8)));
+ $article->setUrl('https://example.com/'.bin2hex(random_bytes(4)));
+ $article->setSummary('Summary of '.$title);
$article->setPublishedAt(new DateTimeImmutable());
$article->setFeed($feed);
diff --git a/tests/Integration/Persistence/DoctrineBookmarkRepositoryTest.php b/tests/Integration/Persistence/DoctrineBookmarkRepositoryTest.php
index bee7aa1..7c12dd2 100644
--- a/tests/Integration/Persistence/DoctrineBookmarkRepositoryTest.php
+++ b/tests/Integration/Persistence/DoctrineBookmarkRepositoryTest.php
@@ -106,21 +106,21 @@ private function createArticleGraph(string $email = 'test@example.com'): array
$category = new Category();
$category->setName('Tech');
- $category->setSlug('tech-' . bin2hex(random_bytes(4)));
+ $category->setSlug('tech-'.bin2hex(random_bytes(4)));
$category->setOwner($user);
$this->entityManager->persist($category);
$feed = new Feed();
$feed->setTitle('Feed');
- $feed->setUrl('https://example.com/feed-' . bin2hex(random_bytes(4)));
+ $feed->setUrl('https://example.com/feed-'.bin2hex(random_bytes(4)));
$feed->setCategory($category);
$feed->setOwner($user);
$this->entityManager->persist($feed);
$article = new Article();
$article->setTitle('Article');
- $article->setGuid('guid-' . bin2hex(random_bytes(8)));
- $article->setUrl('https://example.com/' . bin2hex(random_bytes(4)));
+ $article->setGuid('guid-'.bin2hex(random_bytes(8)));
+ $article->setUrl('https://example.com/'.bin2hex(random_bytes(4)));
$article->setPublishedAt(new DateTimeImmutable());
$article->setFeed($feed);
$this->entityManager->persist($article);
diff --git a/tests/Unit/Infrastructure/Auth/HmacEmailVerificationTokenGeneratorTest.php b/tests/Unit/Infrastructure/Auth/HmacEmailVerificationTokenGeneratorTest.php
index eba3528..48c7b69 100644
--- a/tests/Unit/Infrastructure/Auth/HmacEmailVerificationTokenGeneratorTest.php
+++ b/tests/Unit/Infrastructure/Auth/HmacEmailVerificationTokenGeneratorTest.php
@@ -31,8 +31,8 @@ public function testGenerateSignedUrlContainsAllParams(): void
$url = $this->generator->generateSignedUrl($userId, $email);
$this->assertStringStartsWith('http://localhost:5173/verify-email?', $url);
- $this->assertStringContainsString('userId=' . urlencode($userId), $url);
- $this->assertStringContainsString('email=' . urlencode($email), $url);
+ $this->assertStringContainsString('userId='.urlencode($userId), $url);
+ $this->assertStringContainsString('email='.urlencode($email), $url);
$this->assertStringContainsString('expiresAt=', $url);
$this->assertStringContainsString('signature=', $url);
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index daec0f8..05b01ce 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -4,10 +4,10 @@
use Symfony\Component\Dotenv\Dotenv;
-require dirname(__DIR__) . '/vendor/autoload.php';
+require dirname(__DIR__).'/vendor/autoload.php';
-if (file_exists(dirname(__DIR__) . '/config/bootstrap.php')) {
- require dirname(__DIR__) . '/config/bootstrap.php';
+if (file_exists(dirname(__DIR__).'/config/bootstrap.php')) {
+ require dirname(__DIR__).'/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
- new Dotenv()->bootEnv(dirname(__DIR__) . '/.env');
+ new Dotenv()->bootEnv(dirname(__DIR__).'/.env');
}